Explorar o código

docs(pl): add implementation plan for blank slip print

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hai 2 meses
pai
achega
7ebe635fca
Modificáronse 1 ficheiros con 498 adicións e 0 borrados
  1. 498 0
      docs/superpowers/plans/2026-08-22-pl-plan-blank-slip-print.md

+ 498 - 0
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(`<tr>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+        <td class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
+      </tr>`)
+    }
+    return rows.join('')
+  }
+  if (!products.length) {
+    return `<tr><td class="inbound-slip-product-cell" colspan="8" style="${PRODUCT_CELL}">暂无产品信息</td></tr>`
+  }
+  // ... 保持现有 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<string, string> = {
+    '{{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<string, string> = {
+    '{{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
+<el-dropdown-item divided command="blank-inbound">空白入库单</el-dropdown-item>
+<el-dropdown-item command="blank-delivery">空白送(提)货单</el-dropdown-item>
+<el-dropdown-item command="blank-subpack">空白分装单</el-dropdown-item>
+```
+
+原三项的 `: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 占位。