diff --git a/docs/superpowers/plans/2026-08-22-pl-plan-blank-slip-print.md b/docs/superpowers/plans/2026-08-22-pl-plan-blank-slip-print.md new file mode 100644 index 000000000..d769d308e --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-pl-plan-blank-slip-print.md @@ -0,0 +1,498 @@ +# 运输计划空白单据打印 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 运输计划列表工具栏支持打印空白入库单 / 送(提)货单 / 分装单(未勾选也可打空白;填数打印逻辑不变)。 + +**Architecture:** 三类 `pl-*-slip-template.ts` 各导出 `renderBlank*SlipHtml()`(默认模板 + 全空业务字段 + 空产品行);三打印弹窗 `defineExpose({ open, openBlank })`;列表工具栏「打印单据」始终可点并增加三个空白菜单项。不改后端。 + +**Tech Stack:** Vue 3 + TypeScript + Element Plus;现有 `pl-*-slip-template.ts` / 打印弹窗;自检用 `pnpm exec tsx` + +**Spec:** `docs/superpowers/specs/2026-08-22-pl-plan-blank-slip-print-design.md` + +--- + +## 文件结构 + +| 文件 | 职责 | +|------|------| +| `web-admin/src/utils/pl-inbound-slip-template.ts` | `BLANK_SLIP_PRODUCT_MIN_ROWS`;`buildProductRows` 空白空行;`renderBlankInboundSlipHtml()` | +| `web-admin/src/utils/pl-delivery-slip-template.ts` | 同上;空白时 `{{companySeal}}` 为空 | +| `web-admin/src/utils/pl-subpack-slip-template.ts` | `renderBlankSubpackSlipHtml()`:单号/日期/人员等全空,产品用现有最小空行 | +| `web-admin/src/components/business/pl-inbound-slip-print-dialog/index.vue` | `openBlank()`;空白态隐藏保存模板 | +| `web-admin/src/components/business/pl-delivery-slip-print-dialog/index.vue` | 同上;空白默认关章 | +| `web-admin/src/components/business/pl-subpack-slip-print-dialog/index.vue` | 同上;不调 `ensureSubpackSlipNo` | +| `web-admin/src/views/pl/plan/index.vue` | 工具栏按钮始终可点;下拉加空白三项;handler 分流 | + +--- + +### Task 1: 入库单 / 送(提)货单空白 HTML 纯函数 + +**Files:** +- Modify: `web-admin/src/utils/pl-inbound-slip-template.ts` +- Modify: `web-admin/src/utils/pl-delivery-slip-template.ts` + +- [ ] **Step 1: 在入库模板中增加空行常量与 `buildProductRows` 空白分支** + +在 `web-admin/src/utils/pl-inbound-slip-template.ts` 靠近 `buildProductRows` 处增加: + +```ts +/** 空白单据物料空行数(与分装单最小行数对齐) */ +export const BLANK_SLIP_PRODUCT_MIN_ROWS = 5 +``` + +将 `buildProductRows` 签名改为支持空白模式(在文件内其它调用处不传第三参后的 options,行为不变): + +```ts +function buildProductRows( + products: PlanProduct[] = [], + planNo = '', + ctx?: InboundSlipRenderContext, + options?: { blank?: boolean } +): string { + if (options?.blank) { + const rows: string[] = [] + for (let i = 0; i < BLANK_SLIP_PRODUCT_MIN_ROWS; i += 1) { + rows.push(` +   +   +   +   +   +   +   +   + `) + } + return rows.join('') + } + if (!products.length) { + return `暂无产品信息` + } + // ... 保持现有 dataRows 逻辑不变 +} +``` + +在 `replaceInboundSlipPlaceholders` 增加可选第四参,并在 `{{productTable}}` 处传入: + +```ts +export function replaceInboundSlipPlaceholders( + htmlContent: string, + plan: Plan, + ctx?: InboundSlipRenderContext, + options?: { blank?: boolean } +): string { + if (!htmlContent || !plan) return htmlContent + + const replacements: Record = { + '{{companyName}}': INBOUND_SLIP_DEFAULTS.companyName, + '{{receivingUnit}}': options?.blank ? '' : buildDeliveryUnit(plan), + '{{planNo}}': options?.blank ? '' : plan.planNo || '', + '{{receiveContact}}': '', + '{{driverInfo}}': '', + '{{planDateChinese}}': options?.blank ? '' : formatPlanDateChinese(plan.planDate), + '{{productTable}}': buildProductRows(plan.products, plan.planNo || '', ctx, options), + '{{remark}}': options?.blank ? '' : formatRemarkForSlipHtml(plan.remark), + '{{companyAddress}}': INBOUND_SLIP_DEFAULTS.companyAddress, + '{{companyPhone}}': INBOUND_SLIP_DEFAULTS.companyPhone, + '{{contactPhone}}': INBOUND_SLIP_DEFAULTS.contactPhone, + '{{companySeal}}': buildInboundSlipCompanySealHtml(), + '{{sideLabels}}': buildInboundSlipSideLabelsHtml() + } + // ... 其余 normalize + replace 逻辑不变 +} +``` + +文件末尾(或 `replaceInboundSlipPlaceholders` 之后)新增: + +```ts +/** 空白入库单 HTML:默认模板 + 业务字段全空 + 空产品行(保留印章) */ +export function renderBlankInboundSlipHtml(): string { + const blankPlan = { + id: '', + planNo: '', + products: [], + customerName: '', + remark: '' + } as Plan + const html = generateInboundSlipTemplate(blankPlan) + return replaceInboundSlipPlaceholders(html, blankPlan, undefined, { blank: true }) +} +``` + +- [ ] **Step 2: 对送(提)货单模板做对称改动** + +在 `web-admin/src/utils/pl-delivery-slip-template.ts`: + +1. 增加: + +```ts +export const BLANK_SLIP_PRODUCT_MIN_ROWS = 5 +``` + +2. `buildProductRows` 增加 `options?: { blank?: boolean }`,空白时输出 8 列 × `BLANK_SLIP_PRODUCT_MIN_ROWS` 空行(class 前缀为 `delivery-slip-`)。 + +3. `replaceDeliverySlipPlaceholders` 增加第四参 `options?: { blank?: boolean }`: + +```ts +'{{receivingUnit}}': options?.blank ? '' : buildReceivingUnit(plan), +'{{planNo}}': options?.blank ? '' : plan.planNo || '', +'{{planDateChinese}}': options?.blank ? '' : formatPlanDateChinese(plan.planDate), +'{{productTable}}': buildProductRows(plan.products, plan.planNo || '', ctx, options), +'{{remark}}': options?.blank ? '' : formatRemarkForSlipHtml(plan.remark), +'{{companySeal}}': options?.blank ? '' : buildDeliverySlipCompanySealHtml(), +``` + +其余字段与现网一致(公司名等 defaults 保留)。 + +4. 新增: + +```ts +export function renderBlankDeliverySlipHtml(): string { + const blankPlan = { + id: '', + planNo: '', + products: [], + customerName: '', + remark: '' + } as Plan + const html = generateDeliverySlipTemplate(blankPlan) + return replaceDeliverySlipPlaceholders(html, blankPlan, undefined, { blank: true }) +} +``` + +- [ ] **Step 3: 用 tsx 自检空白 HTML** + +Run: + +```bash +cd web-admin && pnpm exec tsx -e " +import { renderBlankInboundSlipHtml } from './src/utils/pl-inbound-slip-template.ts' +import { renderBlankDeliverySlipHtml } from './src/utils/pl-delivery-slip-template.ts' + +const inbound = renderBlankInboundSlipHtml() +const delivery = renderBlankDeliverySlipHtml() + +function assert(cond: boolean, msg: string) { + if (!cond) throw new Error(msg) +} + +assert(inbound.includes('入'), 'inbound title') +assert(!inbound.includes('暂无产品信息'), 'inbound no empty tip') +assert((inbound.match(/inbound-slip-product-grid-cell/g) || []).length >= 40, 'inbound empty cells') +assert(inbound.includes('alt=\"印章\"'), 'inbound blank keeps seal') + +assert(delivery.includes('送') || delivery.includes('提'), 'delivery title') +assert(!delivery.includes('暂无产品信息'), 'delivery no empty tip') +assert(!delivery.includes('alt=\"印章\"'), 'delivery blank must not have seal img') +assert((delivery.match(/delivery-slip-product-grid-cell/g) || []).length >= 40, 'delivery empty cells') + +console.log('blank inbound/delivery OK') +" +``` + +Expected: 打印 `blank inbound/delivery OK`,exit 0。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/utils/pl-inbound-slip-template.ts web-admin/src/utils/pl-delivery-slip-template.ts +git commit -m "$(cat <<'EOF' +feat(pl): add blank HTML renderers for inbound and delivery slips + +EOF +)" +``` + +--- + +### Task 2: 分装单空白 HTML 纯函数 + +**Files:** +- Modify: `web-admin/src/utils/pl-subpack-slip-template.ts` + +- [ ] **Step 1: 扩展 `replaceSubpackSlipPlaceholders` 支持 blank** + +将签名改为: + +```ts +export function replaceSubpackSlipPlaceholders( + htmlContent: string, + plan: Plan, + ctx?: SubpackSlipRenderContext, + options?: { blank?: boolean } +): string { + if (!htmlContent || !plan) return htmlContent + + const blank = Boolean(options?.blank) + const orderDate = blank ? '' : ctx?.orderDate || formatDateIso() + const orderUser = blank ? '' : ctx?.orderUser || '' + const preparerUser = blank ? '' : ctx?.preparerUser || orderUser + + const replacements: Record = { + '{{companyName}}': SUBPACK_SLIP_DEFAULTS.companyName, + '{{subpackSlipNo}}': blank ? '' : ctx?.subpackSlipNo || '', + '{{orderDate}}': orderDate, + '{{deliveryDate}}': '', + '{{orderUser}}': orderUser, + '{{preparerUser}}': preparerUser, + '{{handlerName}}': '', + '{{receivingUnit}}': blank ? '' : orderDate, + '{{planNo}}': '', + '{{receiveContact}}': blank ? '' : orderUser, + '{{driverInfo}}': '', + '{{planDateChinese}}': '', + '{{productTable}}': buildProductRows(blank ? [] : plan.products, ctx), + '{{remark}}': blank ? '' : formatRemarkForSlipHtml(plan.remark), + '{{remarkExtra}}': '', + '{{companyAddress}}': '', + '{{companyPhone}}': '', + '{{contactPhone}}': '', + '{{companySeal}}': '', + '{{sideLabels}}': '' + } + // ... 其余 normalize + for 循环替换保持不变 + // blank 时跳过「用 ctx.subpackSlipNo 强制写回 display」那段 if (ctx?.subpackSlipNo) { ... } +} +``` + +注意:现有文件末尾若有 `if (ctx?.subpackSlipNo) { result = result.replace(... display ...) }`,在 `blank === true` 时不要执行该分支。 + +- [ ] **Step 2: 新增 `renderBlankSubpackSlipHtml`** + +```ts +export function renderBlankSubpackSlipHtml(): string { + const blankPlan = { + id: '', + planNo: '', + products: [], + customerName: '', + remark: '' + } as Plan + const html = generateSubpackSlipTemplate(blankPlan) + return replaceSubpackSlipPlaceholders(html, blankPlan, undefined, { blank: true }) +} +``` + +- [ ] **Step 3: tsx 自检** + +```bash +cd web-admin && pnpm exec tsx -e " +import { renderBlankSubpackSlipHtml } from './src/utils/pl-subpack-slip-template.ts' + +const html = renderBlankSubpackSlipHtml() +function assert(cond: boolean, msg: string) { + if (!cond) throw new Error(msg) +} +assert(html.includes('分'), 'title') +assert(!/NO:SXFZ\d{8}-\d{3}/.test(html), 'no slip no') +assert(!html.includes('暂无产品信息'), 'no empty tip') +assert((html.match(/subpack-slip-product-grid-cell/g) || []).length >= 20, 'empty product cells') +console.log('blank subpack OK') +" +``` + +Expected: `blank subpack OK` + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/utils/pl-subpack-slip-template.ts +git commit -m "$(cat <<'EOF' +feat(pl): add blank HTML renderer for subpack slips + +EOF +)" +``` + +--- + +### Task 3: 三个打印弹窗 `openBlank()` + +**Files:** +- Modify: `web-admin/src/components/business/pl-inbound-slip-print-dialog/index.vue` +- Modify: `web-admin/src/components/business/pl-delivery-slip-print-dialog/index.vue` +- Modify: `web-admin/src/components/business/pl-subpack-slip-print-dialog/index.vue` + +- [ ] **Step 1: 入库打印弹窗** + +在 `pl-inbound-slip-print-dialog/index.vue`: + +1. import 增加 `renderBlankInboundSlipHtml`。 +2. 增加 `const isBlankMode = ref(false)`。 +3. footer「保存模板」按钮加 `v-if="!isBlankMode"`。 +4. 实现: + +```ts +function openBlank() { + currentPlanId.value = '' + currentPlan.value = null + isBlankMode.value = true + isPreviewMode.value = false + showRedSeal.value = true + slipContent.value = renderBlankInboundSlipHtml() + visible.value = true +} +``` + +5. 在现有 `open` 开头设 `isBlankMode.value = false`。 +6. `handleClosed` 中重置 `isBlankMode.value = false`。 +7. PDF 文件名:若 `isBlankMode`,用 `空白入库单.pdf`;否则保持现逻辑。 +8. `defineExpose({ open, openBlank })`。 + +- [ ] **Step 2: 送(提)货打印弹窗** + +对称改动,import `renderBlankDeliverySlipHtml`: + +```ts +function openBlank() { + currentPlanId.value = '' + currentPlan.value = null + isBlankMode.value = true + isPreviewMode.value = false + showRedSeal.value = false + slipContent.value = renderBlankDeliverySlipHtml() + visible.value = true +} +``` + +- 「保存模板」`v-if="!isBlankMode"`。 +- 「显示/隐藏红章」按钮空白模式仍可保留(用户可临时打开章);打开空白时默认无章。 +- PDF:`空白送提货单.pdf`(文件名去掉非法字符)。 +- `defineExpose({ open, openBlank })`。 + +- [ ] **Step 3: 分装打印弹窗** + +import `renderBlankSubpackSlipHtml`: + +```ts +function openBlank() { + currentPlanId.value = '' + currentPlan.value = null + currentSubpackSlipNo.value = '' + isBlankMode.value = true + isPreviewMode.value = false + showRedSeal.value = false + slipContent.value = renderBlankSubpackSlipHtml() + visible.value = true +} +``` + +**禁止**调用 `planApi.ensureSubpackSlipNo` / `get` / `getSubpackSlipTemplate`。 + +- 「保存模板」`v-if="!isBlankMode"`。 +- PDF:`空白分装单.pdf`。 +- `defineExpose({ open, openBlank })`。 + +- [ ] **Step 4: Commit** + +```bash +git add \ + web-admin/src/components/business/pl-inbound-slip-print-dialog/index.vue \ + web-admin/src/components/business/pl-delivery-slip-print-dialog/index.vue \ + web-admin/src/components/business/pl-subpack-slip-print-dialog/index.vue +git commit -m "$(cat <<'EOF' +feat(pl): expose openBlank on slip print dialogs + +EOF +)" +``` + +--- + +### Task 4: 列表工具栏入口 + +**Files:** +- Modify: `web-admin/src/views/pl/plan/index.vue` + +- [ ] **Step 1: 调整工具栏「打印单据」下拉** + +找到工具栏中 `el-dropdown`(约 L129–160),改为: + +1. 去掉 dropdown / 按钮上的 `:disabled="selectedRows.length < 1"`(按钮始终可点)。 +2. 在三个原菜单项之后增加: + +```vue +空白入库单 +空白送(提)货单 +空白分装单 +``` + +原三项的 `:disabled="!canBatchPrintSlip(...).ok"` **保持不变**(未勾选时 `canBatchPrintSlip` 返回 false,自然灰掉)。 + +- [ ] **Step 2: 扩展 `handleToolbarPrintSlip`** + +```ts +function handleToolbarPrintSlip(command: string) { + if (command === 'blank-inbound') { + inboundSlipPrintRef.value?.openBlank() + return + } + if (command === 'blank-delivery') { + deliverySlipPrintRef.value?.openBlank() + return + } + if (command === 'blank-subpack') { + subpackSlipPrintRef.value?.openBlank() + return + } + + const cmd = command as PlPrintSlipCommand + const plans = selectedRows.value + const check = canBatchPrintSlip(plans, cmd) + if (!check.ok) { + ElMessage.warning(check.reason || '无法打印') + return + } + const ids = plans.map((p) => p.id) + if (cmd === 'inbound') { + inboundSlipPrintRef.value?.open(ids) + return + } + if (cmd === 'delivery') { + deliverySlipPrintRef.value?.open(ids) + return + } + subpackSlipPrintRef.value?.open(ids) +} +``` + +查看弹窗内的打印下拉:**不要**加空白项(spec D2)。 + +- [ ] **Step 3: 手工验收清单(在 `pnpm dev` 下勾选)** + +1. 未勾选:点「打印单据」→ 原三项灰、空白三项可点。 +2. 空白入库单:无客户/日期/编号业务值;无「暂无产品信息」;有空行;有印章(若默认模板含章);保存模板按钮不可见。 +3. 空白送(提)货单:无业务值;无印章;可打印。 +4. 空白分装单:无分装单号;无下单日期/人等;可打印;网络无 `subpack-slip-no` 请求。 +5. 勾选一条入库计划:原「入库单」可填数打印;空白三项仍出空白且不带计划数据。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/views/pl/plan/index.vue +git commit -m "$(cat <<'EOF' +feat(pl): add blank slip print commands to plan list toolbar + +EOF +)" +``` + +--- + +## Spec 覆盖自检 + +| Spec 项 | Task | +|---------|------| +| D1 openBlank | Task 3 | +| D2 仅列表工具栏 | Task 4(查看弹窗不加) | +| D3 未勾选可点、原三项灰 | Task 4 | +| D4 勾选时空白仍可用 | Task 4 | +| D5 空白入库全空 | Task 1 + 3 | +| D6 送提货默认无章 | Task 1(seal 空)+ Task 3(showRedSeal false) | +| D7 分装单号空、不占号 | Task 2 + 3 | +| D8 空行非「暂无产品信息」 | Task 1–2 | +| D9 不读计划模板 | Task 3 openBlank | +| D10 禁用保存模板 | Task 3 `v-if="!isBlankMode"` | + +无后端任务。无 TBD 占位。