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

15 KiB
Raw Blame History

运输计划多选合打打印 — 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, '&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

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. 选 1 条入库 → 入库单,与改前一致。
  2. 选同客户同日 2 条入库 → 合打物料多行、单据编号各异、备注 计划号:… 换行。
  3. 跨客户勾选 → 入库/送货菜单禁用。
  4. 跨客户出库多选 → 分装可开,入库/送货不可。
  5. 查看弹窗打印仍单条。
  • 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;步骤含具体代码与命令。