15 KiB
运输计划多选合打打印 — 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 直接测正式模块:
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
// 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.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
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 改为:
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)中:
'{{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
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
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
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: 导入与计算属性
import { canBatchPrintSlip } from '@/utils/pl-batch-print'
模板中打印下拉:
<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
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 条入库 → 入库单,与改前一致。
- 选同客户同日 2 条入库 → 合打物料多行、单据编号各异、备注
计划号:…换行。 - 跨客户勾选 → 入库/送货菜单禁用。
- 跨客户出库多选 → 分装可开,入库/送货不可。
- 查看弹窗打印仍单条。
- Step 4: Commit
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: 提交图谱变更(若有)
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;步骤含具体代码与命令。