2026-10-06-pl-plan-list-excel-merge.md 25 KB

运输计划类列表一行一产品 + 两级合并 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: 把运输计划族列表从「装×送配对行 + 格内虚线/色块」改成「一行一件货 + 计划/送货两级 rowspan」。

Architecture: 只改前端展平。flattenPlanArchivePairRows 在现有装×送过滤之后再按送货产品拆行,并匹配装货产品。span-method 改为按列 property/type 判断计划级或送货段级,不再假设「左侧连续 N 列」。四页与关联计划列表共用 usePlPlanMergeListTable。不改后端分页、不改小程序 flattenPlanListRows。

Tech Stack: Vue 3 · Element Plus · TypeScript · Node test (tsx --test)

Spec: docs/superpowers/specs/2026-10-06-pl-plan-list-excel-merge-design.md

提交约定: 每个 Task 末尾可 commit;若用户要求暂不提交,攒到全部完成后再问。


文件地图

职责 路径
行模型 / 匹配 / span Modify web-admin/src/utils/pl-plan-list-display-rows.ts
单测 Create web-admin/src/utils/pl-plan-list-display-rows.test.ts
表格组合 Modify web-admin/src/composables/usePlPlanMergeListTable.ts
叠放样式(确认无引用后可留空规则) Modify web-admin/src/assets/styles/business/pl-plan-merge-list-table.scss
运输计划 Modify web-admin/src/views/pl/plan/index.vue + columnFilterDefs.ts
归档 Modify web-admin/src/views/pl/plan-archive/index.vue + columnFilterDefs.ts
危化证 Modify web-admin/src/views/pl/plan-cert/index.vue + columnFilterDefs.ts
合同完善 Modify web-admin/src/views/pl/plan-contract/index.vue + columnFilterDefs.ts
合同详情关联计划 Modify web-admin/src/components/business/pl-contract-linked-plans-panel/index.vue
合同用量弹窗 Modify web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue

不做: 后端 API、小程序、flattenPlanListRows、计划表单/归档弹窗。产品格不重写 PlPlanListProductCell:只传单件且不传 contracts,色块自然不出现。

列 prop 对照(全页统一):

旧 prop 新 prop
stopAddressTime loadAddressTime、deliveryAddressTime
stopContract loadContract、deliveryContract
productPlanQty loadPlanQty、deliveryPlanQty
productActualQty loadActualQty、deliveryActualQty
productInboundQty loadInboundQty、deliveryInboundQty
productOutboundQty loadOutboundQty、deliveryOutboundQty
productUnitPrice loadUnitPrice、deliveryUnitPrice
productAmount loadAmount、deliveryAmount
stopDibang loadDibang、deliveryDibang
gapType(合同完善,保留筛选项) 展示列拆 loadContractGap、deliveryContractGap;漏斗仍可用 gapType

计划级合并还包含:loadCustomer、loadAddressTime、loadContract、loadDibang、loadContractGap。
送货段级:deliveryCustomer、deliveryAddressTime、deliveryContract、deliveryDibang、deliveryContractGap。

列设置 persistence key 必须升版本,避免旧隐藏状态对不上新 prop:

  • plan: pl-plan-list-columns-v6 → v7
  • archive: pl-plan-archive-list-columns-v7 → v8
  • cert: 找到现 key 同样 +1

Task 1: 产品匹配 + 拆行展平(TDD)

Files:

  • Create: web-admin/src/utils/pl-plan-list-display-rows.test.ts
  • Modify: web-admin/src/utils/pl-plan-list-display-rows.ts

  • [ ] Step 1: 写失败单测

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { Plan, PlanStopProduct } from '@/api/pl'
import {
  flattenPlanArchivePairRows,
  matchLoadProduct
} from './pl-plan-list-display-rows'

function sp(partial: Partial<PlanStopProduct> & Pick<PlanStopProduct, 'productId'>): PlanStopProduct {
  return {
    productNo: partial.productNo || 'P',
    productName: partial.productName || '货',
    unit: partial.unit || '吨',
    ...partial
  }
}

function samplePlan(): Plan {
  return {
    id: 'plan-1',
    planNo: 'JH001',
    load: {
      customerName: '云栋化工',
      products: [
        sp({ planProductId: 'pp-u', productId: 'u', productName: '尿素', planQty: 30 }),
        sp({ planProductId: 'pp-a', productId: 'a', productName: '液氨', planQty: 10 })
      ]
    },
    deliveries: [
      {
        seq: 1,
        customerName: '宜昌磷肥厂',
        products: [
          sp({ planProductId: 'pp-u', productId: 'u', productName: '尿素', planQty: 20 }),
          sp({ planProductId: 'pp-a', productId: 'a', productName: '液氨', planQty: 8 })
        ]
      },
      {
        seq: 2,
        customerName: '荆州农资',
        products: [sp({ planProductId: 'pp-u', productId: 'u', productName: '尿素', planQty: 12 })]
      }
    ]
  } as Plan
}

describe('matchLoadProduct', () => {
  it('prefers planProductId then productId', () => {
    const load = [
      sp({ planProductId: 'pp-u', productId: 'u', productName: '尿素-装' }),
      sp({ planProductId: 'pp-x', productId: 'u', productName: '尿素-旧' })
    ]
    const hit = matchLoadProduct(load, sp({ planProductId: 'pp-u', productId: 'u' }))
    assert.equal(hit?.productName, '尿素-装')
    const byId = matchLoadProduct(load, sp({ productId: 'u' }))
    assert.equal(byId?.planProductId, 'pp-u')
  })
})

describe('flattenPlanArchivePairRows product rows', () => {
  it('splits 2 deliveries / 3 products with two-level spans', () => {
    const rows = flattenPlanArchivePairRows([samplePlan()])
    assert.equal(rows.length, 3)
    assert.equal(rows[0].planRowSpan, 3)
    assert.equal(rows[0].deliveryRowSpan, 2)
    assert.equal(rows[0].isPlanFirstRow, true)
    assert.equal(rows[0].isDeliveryFirstRow, true)
    assert.equal(rows[1].isDeliveryFirstRow, false)
    assert.equal(rows[1].deliveryRowSpan, 2)
    assert.equal(rows[2].isPlanLastRow, true)
    assert.equal(rows[2].isDeliveryFirstRow, true)
    assert.equal(rows[2].deliveryRowSpan, 1)
    assert.equal(rows[0].deliveryProduct?.productName, '尿素')
    assert.equal(rows[0].loadProduct?.planQty, 30)
    assert.equal(rows[2].deliveryProduct?.planQty, 12)
    assert.equal(rows[0].rowKey, 'plan-1-pair-1-pp-u')
  })

  it('keeps one placeholder row when delivery has no products', () => {
    const plan = samplePlan()
    plan.deliveries = [{ seq: 1, customerName: '宜昌磷肥厂', products: [] }] as Plan['deliveries']
    const rows = flattenPlanArchivePairRows([plan])
    assert.equal(rows.length, 1)
    assert.equal(rows[0].deliveryProduct, undefined)
    assert.equal(rows[0].planRowSpan, 1)
  })

  it('recomputes spans after product name filter', () => {
    const rows = flattenPlanArchivePairRows([samplePlan()], { productKeywords: ['液氨'] })
    assert.equal(rows.length, 1)
    assert.equal(rows[0].deliveryProduct?.productName, '液氨')
    assert.equal(rows[0].planRowSpan, 1)
    assert.equal(rows[0].deliveryRowSpan, 1)
  })
})

若 @/api/pl 在 tsx --test 下解析失败,把 import 改成相对路径或在测试文件里用最小 struct 断言、给 matchLoadProduct 的参数用 any 形状。以能跑通为准。

  • Step 2: 跑测确认失败
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts

Expected: FAIL(matchLoadProduct / 新字段未定义,或行数仍为 2)。

  • Step 3: 最小实现

在 PlPlanListDisplayRow 增加:

deliveryProduct?: PlanStopProduct
loadProduct?: PlanStopProduct
deliveryRowSpan: number
isDeliveryFirstRow: boolean

新增并导出:

export function matchLoadProduct(
  loadProducts: PlanStopProduct[] | undefined,
  deliveryProduct: PlanStopProduct | null | undefined
): PlanStopProduct | undefined {
  if (!deliveryProduct) return undefined
  const list = loadProducts || []
  const ppid = String(deliveryProduct.planProductId || '').trim()
  if (ppid) {
    const hit = list.find((p) => String(p.planProductId || '').trim() === ppid)
    if (hit) return hit
  }
  const pid = String(deliveryProduct.productId || '').trim()
  if (!pid) return undefined
  return list.find((p) => String(p.productId || '').trim() === pid)
}

function productNameMatchesKeywords(
  name: string | null | undefined,
  keywords: string[]
): boolean {
  const text = String(name || '').trim().toLowerCase()
  if (!text) return false
  return keywords.some((k) => text.includes(k.trim().toLowerCase()))
}

改 flattenPlanArchivePairRows:在现有 slots 过滤之后,不要 slots.forEach 一行一段,改为:

type ProductSlot = {
  delivery: PlanDelivery
  deliverySeq: number
  deliveryProduct?: PlanStopProduct
  loadProduct?: PlanStopProduct
}

const productSlots: ProductSlot[] = []
for (const slot of slots) {
  const products = slot.delivery.products || []
  const picked =
    products.length > 0
      ? products.filter((p) =>
          productKeywords
            ? productNameMatchesKeywords(p.productName, productKeywords)
            : true
        )
      : productKeywords
        ? []
        : [undefined]
  for (const deliveryProduct of picked) {
    productSlots.push({
      delivery: slot.delivery,
      deliverySeq: slot.deliverySeq,
      deliveryProduct,
      loadProduct: matchLoadProduct(load.products, deliveryProduct)
    })
  }
}
if (!productSlots.length) return

const planRowSpan = productSlots.length
// deliveryRowSpan: count consecutive same deliverySeq
productSlots.forEach((slot, index) => {
  const same = productSlots.filter((s) => s.deliverySeq === slot.deliverySeq)
  const firstIndex = productSlots.findIndex((s) => s.deliverySeq === slot.deliverySeq)
  const productKey = slot.deliveryProduct?.planProductId || slot.deliveryProduct?.productId || 'empty'
  rows.push({
    rowKey: `${plan.id}-pair-${slot.deliverySeq}-${productKey}`,
    plan,
    kind: 'delivery',
    stop: slot.delivery,
    deliverySeq: slot.deliverySeq,
    isPairRow: true,
    pairLoad: load,
    deliveryProduct: slot.deliveryProduct,
    loadProduct: slot.loadProduct,
    planListIndex: rowPlanIndex,
    isPlanFirstRow: index === 0,
    planRowSpan,
    isPlanLastRow: index === productSlots.length - 1,
    deliveryRowSpan: same.length,
    isDeliveryFirstRow: index === firstIndex
  })
})

options 增加 productKeywords?: string[] | null,用 normalizeStopCustomerKeywords 同样规范化。

旧调用方不传 productKeywords 时行为:有产品则拆行,无产品 1 行占位。

  • Step 4: 跑测确认通过
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts

Expected: PASS。

  • Step 5: Commit(可选)
git add web-admin/src/utils/pl-plan-list-display-rows.ts web-admin/src/utils/pl-plan-list-display-rows.test.ts
git commit -m "feat: flatten transport plan list rows to one product per line"

Task 2: 按列 prop 的两级 span-method(TDD)

Files:

  • Modify: web-admin/src/utils/pl-plan-list-display-rows.ts
  • Modify: web-admin/src/utils/pl-plan-list-display-rows.test.ts
  • Modify: web-admin/src/composables/usePlPlanMergeListTable.ts

  • [ ] Step 1: 写失败单测

import { buildPlPlanListSpanMethod, PL_PLAN_SPAN_PROPS, PL_DELIVERY_SPAN_PROPS } from './pl-plan-list-display-rows'

describe('buildPlPlanListSpanMethod two-level', () => {
  it('merges plan and delivery columns independently', () => {
    const rows = flattenPlanArchivePairRows([samplePlan()])
    const span = buildPlPlanListSpanMethod()
    const planFirst = span({
      row: rows[0],
      column: { property: 'loadCustomer' },
      rowIndex: 0,
      columnIndex: 0
    })
    assert.deepEqual(planFirst, { rowspan: 3, colspan: 1 })
    const planSecond = span({
      row: rows[1],
      column: { property: 'loadCustomer' },
      rowIndex: 1,
      columnIndex: 0
    })
    assert.deepEqual(planSecond, { rowspan: 0, colspan: 0 })
    const delFirst = span({
      row: rows[0],
      column: { property: 'deliveryCustomer' },
      rowIndex: 0,
      columnIndex: 0
    })
    assert.deepEqual(delFirst, { rowspan: 2, colspan: 1 })
    const delThird = span({
      row: rows[2],
      column: { property: 'deliveryCustomer' },
      rowIndex: 2,
      columnIndex: 0
    })
    assert.deepEqual(delThird, { rowspan: 1, colspan: 1 })
    const leaf = span({
      row: rows[0],
      column: { property: 'productName' },
      rowIndex: 0,
      columnIndex: 0
    })
    assert.equal(leaf, undefined)
    const sel = span({
      row: rows[0],
      column: { type: 'selection' },
      rowIndex: 0,
      columnIndex: 0
    })
    assert.deepEqual(sel, { rowspan: 3, colspan: 1 })
    assert.equal(PL_PLAN_SPAN_PROPS.has('loadAddressTime'), true)
    assert.equal(PL_DELIVERY_SPAN_PROPS.has('deliveryContract'), true)
  })
})
  • Step 2: 跑测确认失败
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts

Expected: FAIL(仍按 columnIndex 合并,或符号未导出)。

  • Step 3: 改 span-method 为按列身份

导出:

export const PL_PLAN_SPAN_PROPS = new Set([
  'planNo',
  'followStatus',
  'planDate',
  'deliveryTimes',
  'planType',
  'licensePlate',
  'personnelSummary',
  'followUserName',
  'arrangeCopy',
  'archiveStatus',
  'documentAttachments',
  'erpEntered',
  'planAttachments',
  'carrierName',
  'certReady',
  'loadCustomer',
  'loadAddressTime',
  'loadContract',
  'loadDibang',
  'loadContractGap'
])

export const PL_DELIVERY_SPAN_PROPS = new Set([
  'deliveryCustomer',
  'deliveryAddressTime',
  'deliveryContract',
  'deliveryDibang',
  'deliveryContractGap'
])

certReady 若危化证页实际 prop 不同,以该页 el-table-column 的 prop 为准(证书齐备列)。

替换 buildPlPlanListSpanMethod:

export function buildPlPlanListSpanMethod(
  _visiblePlanProps?: string[],
  _fixedMergeColCount?: number
): (args: {
  row: PlPlanListDisplayRow
  column: { type?: string; property?: string }
  rowIndex: number
  columnIndex: number
}) => { rowspan: number; colspan: number } | undefined {
  return ({ row, column }) => {
    const type = column?.type
    const prop = column?.property || ''
    const isPlanCol =
      type === 'selection' || type === 'index' || PL_PLAN_SPAN_PROPS.has(prop)
    const isDeliveryCol = PL_DELIVERY_SPAN_PROPS.has(prop)
    if (isPlanCol) {
      if (row.isPlanFirstRow) return { rowspan: row.planRowSpan, colspan: 1 }
      return { rowspan: 0, colspan: 0 }
    }
    if (isDeliveryCol) {
      if (row.isDeliveryFirstRow) return { rowspan: row.deliveryRowSpan || 1, colspan: 1 }
      return { rowspan: 0, colspan: 0 }
    }
    return undefined
  }
}

保留旧参数以免 usePlPlanMergeListTable 立刻炸,随后 Task 可不再传 visiblePlanMergeProps 进 span(仍可用于别的逻辑则留下)。

非配对行(flattenPlanListRows,小程序):isDeliveryFirstRow 可能为 undefined。对非 pair 行:isDeliveryFirstRow 视为 true 且 deliveryRowSpan = 1,或 span 里 if (!row.isPairRow) { /* 保持原:仅计划列按 planRowSpan */ }。必须写进实现,避免小程序误用新 span(小程序不用这个 span,但合同用量弹窗若仍走 flattenPlanListRowsForProduct 则要走下面 Task 9)。

  • Step 4: 跑测通过
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts

Expected: PASS。

  • Step 5: Commit(可选)
git add web-admin/src/utils/pl-plan-list-display-rows.ts web-admin/src/utils/pl-plan-list-display-rows.test.ts
git commit -m "feat: span transport plan list by plan and delivery column props"

Task 3: 单件数量 / 入出库读取

Files:

  • Modify: web-admin/src/utils/pl-plan-list-display-rows.ts
  • Modify: web-admin/src/utils/pl-plan-list-display-rows.test.ts

  • [ ] Step 1: 写失败单测

it('reads load and delivery qtys from matched products', () => {
  const rows = flattenPlanArchivePairRows([samplePlan()])
  assert.equal(getRowSidePlanQty(rows[0], 'load'), 30)
  assert.equal(getRowSidePlanQty(rows[0], 'delivery'), 20)
  assert.equal(getRowSidePlanQty(rows[2], 'load'), 30)
  assert.equal(getRowSidePlanQty(rows[2], 'delivery'), 12)
})

液氨行装货量 10、送货量 8。

  • Step 2: 跑测失败
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts
  • Step 3: 实现
export function getRowSidePlanQty(
  row: PlPlanListDisplayRow,
  side: 'load' | 'delivery'
): number | null {
  const product = side === 'load' ? row.loadProduct : row.deliveryProduct
  if (product?.planQty == null || product.planQty === ('' as unknown)) return null
  const num = Number(product.planQty)
  return Number.isFinite(num) ? num : null
}

export function formatDashNumber(value: number | null | undefined): string {
  if (value == null) return '—'
  return String(value)
}

入出库(给列表单元格用):

export function getRowSideIoQty(
  row: PlPlanListDisplayRow,
  side: 'load' | 'delivery',
  column: 'inbound' | 'outbound'
): number | null {
  const stop = side === 'load' ? getPairLoadStop(row) : getPairDeliveryStop(row)
  const product = side === 'load' ? row.loadProduct : row.deliveryProduct
  const kind = side
  if (!product) return null
  if (resolveArchiveListIoColumn(kind, stop?.customerName) !== column) return null
  return getStopArchiveActualQty(product, kind)
}

实际数量:getStopArchiveActualQty(product, side)。单价/金额:已有 getStopArchiveUnitPrice / getStopArchiveAmount。

  • [ ] Step 4: 跑测通过

  • [ ] Step 5: Commit(可选)


Task 4: 运输计划页列拆分

Files:

  • Modify: web-admin/src/views/pl/plan/index.vue
  • Modify: web-admin/src/views/pl/plan/columnFilterDefs.ts

  • [ ] Step 1: 列定义与 persistence

listColumnDefs 把 stopAddressTime 换成两项,其余按文件地图替换。usePersistedListColumns('pl-plan-list-columns-v7', listColumnDefs)。

PLAN_MERGE_COLUMN_PROPS 不要再承担 span(span 已按 Set)。页面仍可用它标「计划区左侧列」但必须把 loadCustomer、loadAddressTime、loadContract、loadDibang 从「非合并」里拿掉——它们由 PL_PLAN_SPAN_PROPS 处理。visiblePlanMergeProps 可继续过滤可见计划列,不影响新 span。

columnFilterDefs.ts:拆列后漏斗 queryKey 尽量复用旧键,避免改后端:

  • loadAddressTime / deliveryAddressTime → 都用 queryKey: 'stopAddress'
  • loadContract / deliveryContract → queryKey: 'contractNo'
  • loadPlanQty / deliveryPlanQty → queryKeyPrefix: 'productPlanQty'
  • 实际/入库/出库同样复用旧 prefix

两列共用同一 queryKey 时,列头漏斗会写成同一筛选(可接受)。

  • Step 2: 模板替换(禁止再出现 pl-plan-pair-stack)

地址:拆成两列,分别:

<div class="pl-plan-stop-address-time">
  <div class="pl-plan-stop-address-time__addr">{{ formatStopAddressText(getPairLoadStop(row)) }}</div>
  <div class="pl-plan-stop-address-time__time">{{ formatLoadTimeText(getPairLoadStop(row)) }}</div>
</div>

送货列用 getPairDeliveryStop + formatDeliveryTimeText。

合同:

<PlPlanContractCell :lines="getLoadContractLinesFromRow(row)" @open="openContractDetail" />

产品名称 / 规格 / 包装 / 单位:

<PlPlanListProductCell
  :stop-products="row.deliveryProduct ? [row.deliveryProduct] : []"
  :plan-products="row.plan.products"
  field="name"
/>

不要传 :contracts。

数量列(装货计划量示例):

<PlPlanListProductCell
  stop-kind="load"
  :stop-products="row.loadProduct ? [row.loadProduct] : []"
  :plan-products="row.plan.products"
  field="planQty"
/>

送货计划量 stop-kind="delivery" + deliveryProduct。实际数量、入库、出库同样:一侧一列,只传该侧单件。入库/出库列继续传 archive-list-mode(若该页已有)以及 stop-customer-name。

地磅:装/送各一列,formatStopDibangLabel + 现有 Tag。

  • Step 3: 合计行 getDisplayStatCellValue

按新 prop 返回单件值。装货侧数量在合计时按 plan.id + (loadProduct.planProductId || loadProduct.productId) 去重:仅当该行是该计划内该装货产品第一次出现时才计入,避免同一装货量在多个送货行重复加总。送货侧按行加。

  • Step 4: 导出列

planExportColumns 的 skip 去掉已拆的客户/地址/合同/地磅(这些现在应进入导出,一行一货)。若后端导出仍按计划维度拼行,不要在本 Task 改后端;前端只改「前端拼表导出」路径。搜 planExportColumns / getPlanExportFilterParams:若导出走后端且后端已是产品配对(归档),计划页保持调用不变即可。

  • Step 5: 产品关键字传入展平(若该页客户端再滤)

usePlPlanMergeListTable 增加 productKeywords 并传给 flattenPlanArchivePairRows。plan/index.vue 从产品名漏斗取出关键字(与客户关键字同一套 selectedKeywordsFromFilter 或文本值)。服务端已按产品滤计划时,客户端再滤可去掉同计划其它产品行。

  • Step 6: 手测运输计划页

cd web-admin && pnpm dev,打开 /#/pl/plan:多送货+多产品、勾选只在计划第一行、序号按计划、无虚线无色块。

  • Step 7: Commit(可选)

Task 5: 归档页

Files:

  • Modify: web-admin/src/views/pl/plan-archive/index.vue
  • Modify: web-admin/src/views/pl/plan-archive/columnFilterDefs.ts

与 Task 4 相同列拆分。另拆单价/金额为装/送四列,PRICE_COLUMN_PROPS 改为新 prop 集合。persistence → pl-plan-archive-list-columns-v8。去掉全部 pl-plan-pair-stack。入库/出库用 getRowSideIoQty 或现 PlPlanListProductCell + archiveListMode 单件。

手测 /#/pl/plan-archive。

  • Step 1–n: 按 Task 4 清单改归档文件并手测。
  • Commit(可选)

Task 6: 危化证页

Files:

  • Modify: web-admin/src/views/pl/plan-cert/index.vue
  • Modify: web-admin/src/views/pl/plan-cert/columnFilterDefs.ts

同样拆列、去掉 pair-stack。证书列对 resolveStopCertProducts(row.plan, row.deliveryProduct ? [row.deliveryProduct] : []),一行一个产品的证。证书齐备、计划附件保持计划级(已在 PL_PLAN_SPAN_PROPS)。升 persistence key。

手测 /#/pl/plan-cert。


Task 7: 合同完善页

Files:

  • Modify: web-admin/src/views/pl/plan-contract/index.vue
  • Modify: web-admin/src/views/pl/plan-contract/columnFilterDefs.ts

gapType 列拆成:

  • loadContractGap:getContractGapSideLines(row, 'load') + showGapSupplementAction(row, 'load') 的补充按钮
  • deliveryContractGap:送货侧

漏斗仍绑定 gapType(可只挂在其中一列头,或两列都挂同一 filter)。去掉 pair-stack。数量列与 Task 4 相同。

手测合同完善。


Task 8: 关联计划列表 + 合同用量弹窗

Files:

  • Modify: web-admin/src/components/business/pl-contract-linked-plans-panel/index.vue
  • Modify: web-admin/src/components/business/pl-contract-product-arrange-qty-dialog/index.vue

  • [ ] 关联列表: 与运输计划页同一套拆列(无勾选,fixedMergeColCount 可忽略)。确认 archivePairMode: true。产品列单件。

  • [ ] 用量弹窗: flattenPlanListRowsForProduct 改为调用 flattenPlanArchivePairRows([filterPlanToProductId(plan, pid)], {})(或给 pair flatten 增加 productId 精确过滤)。该弹窗每段通常只剩一件货,两级合并仍正确。把弹窗里 buildPlPlanListSpanMethod([...PLAN_MERGE_COLUMN_PROPS], 2) 换成无参/新实现。去掉弹窗内 pair-stack(若有)。

  • [ ] 回归: flattenPlanListRows / mp-pl-plan-card.ts 不改;跑一次既有 pl-plan-list-tabs.test.ts。

cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts src/utils/pl-plan-list-tabs.test.ts

Task 9: 清理与图谱

Files:

  • Modify: web-admin/src/assets/styles/business/pl-plan-merge-list-table.scss(删除 .pl-plan-pair-stack* 若四页已无 class)
  • Spec 状态改为已确认

  • [ ] Step 1: 全库搜 pl-plan-pair-stack,web-admin 业务列表应为 0。

  • [ ] Step 2: ./scripts/update-graph.sh frontend(或 graphify update . 于 web-admin)。

  • [ ] Step 3: 四页手测清单:多送货多产品、无产品占位、勾选/序号按计划、列设置显隐后合并不错位、导出一行一货(走前端拼表的页)。


规格覆盖

Spec Task
一行 = 送货 × 产品 1
planProductId / productId 1
无产品占位 1
产品筛选重算 span 1、4
两级合并 2
列拆分 / 去虚线色块 4–7
勾选序号分页按计划 已有 composable,4 手测
导出一行一货 4 导出列
合同完善缺口分侧 7
关联计划列表 8
用量弹窗 8
不改小程序 / flattenPlanListRows 8 约束
入出库方向 3

类型一致性

  • 新行字段:deliveryProduct、loadProduct、deliveryRowSpan、isDeliveryFirstRow
  • 新列 prop:见文件地图表
  • matchLoadProduct、getRowSidePlanQty、getRowSideIoQty、PL_PLAN_SPAN_PROPS、PL_DELIVERY_SPAN_PROPS
  • buildPlPlanListSpanMethod 旧参数保留但不再使用