Files
shanxia2.0/docs/superpowers/plans/2026-07-27-pl-plan-batch-print-merge.md
T

463 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 运输计划多选合打打印 — 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:** 前端纯函数校验与合打视图拼装;三个打印弹窗 `open` 支持 `string | string[]`;列表工具栏 N≥1 启用打印并按规则禁用菜单项。不改后端、不改模版 HTML 结构。
**Tech Stack:** Vue 3 + TypeScript + Element Plus;现有 `pl-*-slip-template.ts` / 打印弹窗
**Spec:** `docs/superpowers/specs/2026-07-27-pl-plan-batch-print-merge-design.md`
---
## 文件结构
| 文件 | 职责 |
|------|------|
| `web-admin/src/utils/pl-batch-print.ts` | `canBatchPrintSlip`、备注合并、合打 Plan 视图、清空车辆字段 |
| `web-admin/src/utils/pl-inbound-slip-template.ts` | `buildProductRows` 支持行级 `slipPlanNo`;备注 `\n`→`<br />` |
| `web-admin/src/utils/pl-delivery-slip-template.ts` | 同上(送货) |
| `web-admin/src/utils/pl-subpack-slip-template.ts` | 备注 `\n`→`<br />`(序号已按合并 products 递增) |
| `web-admin/src/components/business/pl-inbound-slip-print-dialog/index.vue` | `open(ids)` 多计划拉取+合打 |
| `web-admin/src/components/business/pl-delivery-slip-print-dialog/index.vue` | 同上 |
| `web-admin/src/components/business/pl-subpack-slip-print-dialog/index.vue` | 同上 |
| `web-admin/src/views/pl/plan/index.vue` | 工具栏多选打印入口 |
---
### Task 1: 合打纯函数 + 自检
**Files:**
- Create: `web-admin/src/utils/pl-batch-print.ts`
- Test via: `cd web-admin && pnpm exec tsx -e "…"` 或自检脚本
- [ ] **Step 1: 先写 `pl-batch-print.ts`(见 Step 2 完整代码),再用 tsx 跑断言**
因仓库无 vitest,用 `tsx` 直接测正式模块:
```bash
cd web-admin && pnpm exec tsx <<'TS'
import assert from 'node:assert/strict'
import {
canBatchPrintSlip,
mergeBatchPrintRemarks,
buildBatchPrintPlanView
} from './src/utils/pl-batch-print.ts'
const same = [
{ id: '1', planNo: 'A', planType: 'inbound', customerName: '云栋', planDate: '2026-07-27', remark: '早到', hasFreight: 0, totalAmount: 0, freightAmount: 0, createTime: '' },
{ id: '2', planNo: 'B', planType: 'inbound', customerName: '云栋', planDate: '2026-07-27', remark: '质检', hasFreight: 0, totalAmount: 0, freightAmount: 0, createTime: '' }
] as any
assert.equal(canBatchPrintSlip(same, 'inbound').ok, true)
assert.equal(canBatchPrintSlip([{ ...same[0], customerName: '其他' }, same[1]], 'inbound').ok, false)
assert.equal(
canBatchPrintSlip(
[
{ planNo: 'O1', planType: 'outbound', customerName: '甲', planDate: '2026-07-01' },
{ planNo: 'O2', planType: 'outbound', customerName: '乙', planDate: '2026-07-02' }
] as any,
'subpack'
).ok,
true
)
assert.equal(mergeBatchPrintRemarks(same), 'A:早到\nB:质检')
const view = buildBatchPrintPlanView([
{ ...same[0], products: [{ productId: 'p1', productNo: 'n1', productName: '酸', unit: '吨', quantity: 1 }] },
{ ...same[1], products: [{ productId: 'p2', productNo: 'n2', productName: '碱', unit: '吨', quantity: 2 }] }
] as any)
assert.equal(view.products?.length, 2)
assert.equal((view.products as any)[0].slipPlanNo, 'A')
assert.equal(view.remark, 'A:早到\nB:质检')
console.log('pl-batch-print: ok')
TS
```
Expected: `pl-batch-print: ok`
(Step 2 写出 `.ts` 后再跑本命令;顺序可先 Step 2 再 Step 1。)
- [ ] **Step 2: 实现 `web-admin/src/utils/pl-batch-print.ts`**
```ts
// web-admin/src/utils/pl-batch-print.ts
import type { Plan, PlanProduct } from '@/api/pl'
import type { PlPrintSlipCommand } from '@/utils/pl-display'
import { canPrintSlipCommand } from '@/utils/pl-display'
export type SlipPlanProduct = PlanProduct & { slipPlanNo?: string }
export function normalizeCustomerName(name?: string | null) {
return String(name || '').trim()
}
export function canBatchPrintSlip(
plans: Array<Pick<Plan, 'planType' | 'customerName' | 'planDate'>>,
command: PlPrintSlipCommand
): { ok: boolean; reason?: string } {
if (!plans?.length) return { ok: false, reason: '请先勾选运输计划' }
if (plans.length === 1) {
if (!canPrintSlipCommand(plans[0].planType, command)) {
return { ok: false, reason: '当前单据类型不支持打印该单据' }
}
return { ok: true }
}
if (command === 'inbound') {
if (!plans.every((p) => p.planType === 'inbound')) {
return { ok: false, reason: '合打入库单要求:均为入库类计划' }
}
const c = normalizeCustomerName(plans[0].customerName)
const d = plans[0].planDate
if (!c || !d || !plans.every((p) => normalizeCustomerName(p.customerName) === c && p.planDate === d)) {
return {
ok: false,
reason: '合打入库单要求:客户名称一致且计划日期一致,且均为入库类计划'
}
}
return { ok: true }
}
if (command === 'delivery') {
if (!plans.every((p) => p.planType === 'outbound')) {
return { ok: false, reason: '合打送(提)货单要求:均为出库类计划' }
}
const c = normalizeCustomerName(plans[0].customerName)
const d = plans[0].planDate
if (!c || !d || !plans.every((p) => normalizeCustomerName(p.customerName) === c && p.planDate === d)) {
return {
ok: false,
reason: '合打送(提)货单要求:客户名称一致且计划日期一致,且均为出库类计划'
}
}
return { ok: true }
}
if (command === 'subpack') {
if (!plans.every((p) => canPrintSlipCommand(p.planType, 'subpack'))) {
return { ok: false, reason: '分装单仅支持出库类计划' }
}
return { ok: true }
}
return { ok: false, reason: '未知单据类型' }
}
export function mergeBatchPrintRemarks(plans: Array<Pick<Plan, 'planNo' | 'remark'>>) {
return plans
.map((p) => {
const text = String(p.remark || '').trim()
if (!text) return ''
return `${p.planNo}:${text}`
})
.filter(Boolean)
.join('\n')
}
/** 备注写入 HTML 模版时换行 */
export function formatRemarkForSlipHtml(remark?: string | null) {
return String(remark || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br />')
}
/** 合打视图:首条为抬头基准;产品展开并打 slipPlanNo;清空车辆;备注合并 */
export function buildBatchPrintPlanView(plans: Plan[]): Plan {
if (!plans.length) throw new Error('plans empty')
const base = { ...plans[0] }
const products: SlipPlanProduct[] = []
for (const p of plans) {
for (const prod of p.products || []) {
products.push({ ...prod, slipPlanNo: p.planNo })
}
}
base.products = products
base.remark = mergeBatchPrintRemarks(plans)
base.licensePlate = undefined
base.vehicleId = undefined
base.driverId = undefined
base.driverName = undefined
base.trailerNo = undefined
base.personnelSummary = undefined
return base
}
export function normalizePlanIds(planIdOrIds: string | string[]): string[] {
const ids = Array.isArray(planIdOrIds) ? planIdOrIds : [planIdOrIds]
return ids.map((id) => String(id || '').trim()).filter(Boolean)
}
```
- [ ] **Step 3: 再跑 Step 1 命令,确认 PASS**
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/utils/pl-batch-print.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add batch print merge helpers for transport plans
EOF
)"
```
---
### Task 2: 模版 buildProductRows 支持行级计划号 + 备注换行
**Files:**
- Modify: `web-admin/src/utils/pl-inbound-slip-template.ts`(`buildProductRows`、`replaceInboundSlipPlaceholders` 中 `{{remark}}`)
- Modify: `web-admin/src/utils/pl-delivery-slip-template.ts`(同上)
- Modify: `web-admin/src/utils/pl-subpack-slip-template.ts`(`{{remark}}` 替换)
- [ ] **Step 1: 入库单**
在 `buildProductRows` 内,将单行 `planNoText` 改为:
```ts
import type { SlipPlanProduct } from '@/utils/pl-batch-print'
import { formatRemarkForSlipHtml } from '@/utils/pl-batch-print'
// 循环内:
const rowPlanNo = (product as SlipPlanProduct).slipPlanNo || planNo
const planNoText = formatInboundSlipProductGridCellContent(rowPlanNo)
```
`replaceInboundSlipPlaceholders`(及 restore 对称处若有写死 remark)中:
```ts
'{{remark}}': formatRemarkForSlipHtml(plan.remark),
```
注意:`restoreInboundSlipPlaceholders` 若把备注还原为占位符,保持现逻辑即可,勿把 `<br />` 写回污染。
- [ ] **Step 2: 送货单**
对 `pl-delivery-slip-template.ts` 的 `buildProductRows` 做同样 `slipPlanNo` 处理;`{{remark}}` 用 `formatRemarkForSlipHtml`。`{{receiveContact}}` 保持 `''`(已有则不动)。
- [ ] **Step 3: 分装单**
仅改备注替换为 `formatRemarkForSlipHtml(plan.remark)`;产品行已按 `products` 长度递增序号,合打展开 products 后自然正确。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/utils/pl-inbound-slip-template.ts \
web-admin/src/utils/pl-delivery-slip-template.ts \
web-admin/src/utils/pl-subpack-slip-template.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): support per-row planNo and multiline remarks on slip templates
EOF
)"
```
---
### Task 3: 三个打印弹窗支持 `open(string | string[])`
**Files:**
- Modify: `pl-inbound-slip-print-dialog/index.vue`
- Modify: `pl-delivery-slip-print-dialog/index.vue`
- Modify: `pl-subpack-slip-print-dialog/index.vue`
对各弹窗统一模式(以入库为例):
- [ ] **Step 1: 改 `open`**
```ts
import {
buildBatchPrintPlanView,
normalizePlanIds
} from '@/utils/pl-batch-print'
async function open(planIdOrIds: string | string[]) {
const ids = normalizePlanIds(planIdOrIds)
if (!ids.length) {
ElMessage.warning('无法获取计划ID')
return
}
currentPlanId.value = ids[0] // 模板读写仍落在第一条
isPreviewMode.value = false
showRedSeal.value = true
try {
ElMessage.info({ message: ids.length > 1 ? '正在生成合打入库单...' : '正在生成入库单...', duration: 800 })
const plans = await Promise.all(
ids.map(async (id) => {
const res = await planApi.get(id)
return (res.data ?? res) as Plan
})
)
const plan = ids.length === 1 ? plans[0] : buildBatchPrintPlanView(plans)
currentPlan.value = plan
let htmlContent = ''
try {
const templateRes = await planApi.getInboundSlipTemplate(ids[0])
const saved = (templateRes.data ?? templateRes) as string
htmlContent = saved?.trim() ? saved : generateInboundSlipTemplate(plan)
} catch {
htmlContent = generateInboundSlipTemplate(plan)
}
const renderCtx = await buildSlipRenderContext(plan)
slipContent.value = replaceInboundSlipPlaceholders(htmlContent, plan, renderCtx)
visible.value = true
} catch (error) {
console.error('打开入库单打印失败', error)
ElMessage.error('加载入库单失败')
}
}
```
送货 / 分装:同样改 `open`,文案改为「合打送(提)货单」「合打分装单」;分装取号仍用 `ids[0]` 走现网逻辑。
`defineExpose({ open })` 签名自然兼容旧调用 `open(id)`。
- [ ] **Step 2: 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(web-admin): allow multi planIds in transport slip print dialogs
EOF
)"
```
---
### Task 4: 列表工具栏多选合打入口
**Files:**
- Modify: `web-admin/src/views/pl/plan/index.vue`
- [ ] **Step 1: 导入与计算属性**
```ts
import { canBatchPrintSlip } from '@/utils/pl-batch-print'
```
模板中打印下拉:
```vue
<el-dropdown
trigger="click"
:disabled="selectedRows.length < 1"
@command="handleToolbarPrintSlip"
>
<el-button class="btn-ghost" :disabled="selectedRows.length < 1">
<el-icon><Printer /></el-icon>打印单据
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="inbound"
:disabled="!canBatchPrintSlip(selectedRows, 'inbound').ok"
>
入库单
</el-dropdown-item>
<el-dropdown-item
command="delivery"
:disabled="!canBatchPrintSlip(selectedRows, 'delivery').ok"
>
送(提)货单
</el-dropdown-item>
<el-dropdown-item
command="subpack"
:disabled="!canBatchPrintSlip(selectedRows, 'subpack').ok"
>
分装单
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
```
- [ ] **Step 2: 改 `handleToolbarPrintSlip`**
```ts
function handleToolbarPrintSlip(command: string) {
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)
}
```
保留 `handlePrintSlip(command, row)` 给查看弹窗单条使用,不改。
- [ ] **Step 3: 手工验收**
1. 选 1 条入库 → 入库单,与改前一致。
2. 选同客户同日 2 条入库 → 合打物料多行、单据编号各异、备注 `计划号:…` 换行。
3. 跨客户勾选 → 入库/送货菜单禁用。
4. 跨客户出库多选 → 分装可开,入库/送货不可。
5. 查看弹窗打印仍单条。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/pl/plan/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): enable multi-select batch slip print on plan list toolbar
EOF
)"
```
---
### Task 5: graphify 更新
- [ ] **Step 1:** `./scripts/update-graph.sh frontend`(或 `all`)
- [ ] **Step 2:** 提交图谱变更(若有)
```bash
git add web-admin/graphify-out/GRAPH_REPORT.md web-admin/graphify-out/graph.json
git commit -m "$(cat <<'EOF'
chore: update graphify after pl batch print merge
EOF
)" || true
```
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| D1 前端合打 | 3–4 |
| D2–D4 校验 | 1, 4 |
| D5 分行 + 单据编号 | 1–2 |
| D6 备注格式 | 1–2 |
| D7–D8 联系方式空/无车辆 | 1(`buildBatchPrintPlanView`)+ delivery 既有 receiveContact |
| D9 模版结构不变 | 2(仅数据) |
| D10 仅列表工具栏 | 4 |
| D11 单选兼容 | 3–4 |
## Placeholder 扫描
无 TBD;步骤含具体代码与命令。