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:
pl-plan-list-columns-v6 → v7pl-plan-archive-list-columns-v7 → v8Files:
web-admin/src/utils/pl-plan-list-display-rows.test.tsModify: 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 形状。以能跑通为准。
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts
Expected: FAIL(matchLoadProduct / 新字段未定义,或行数仍为 2)。
在 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 行占位。
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts
Expected: PASS。
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"
Files:
web-admin/src/utils/pl-plan-list-display-rows.tsweb-admin/src/utils/pl-plan-list-display-rows.test.tsModify: 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)
})
})
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts
Expected: FAIL(仍按 columnIndex 合并,或符号未导出)。
导出:
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)。
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts
Expected: PASS。
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"
Files:
web-admin/src/utils/pl-plan-list-display-rows.tsModify: 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。
cd web-admin && pnpm exec tsx --test src/utils/pl-plan-list-display-rows.test.ts
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(可选)
Files:
web-admin/src/views/pl/plan/index.vueModify: 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'两列共用同一 queryKey 时,列头漏斗会写成同一筛选(可接受)。
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。
getDisplayStatCellValue按新 prop 返回单件值。装货侧数量在合计时按 plan.id + (loadProduct.planProductId || loadProduct.productId) 去重:仅当该行是该计划内该装货产品第一次出现时才计入,避免同一装货量在多个送货行重复加总。送货侧按行加。
planExportColumns 的 skip 去掉已拆的客户/地址/合同/地磅(这些现在应进入导出,一行一货)。若后端导出仍按计划维度拼行,不要在本 Task 改后端;前端只改「前端拼表导出」路径。搜 planExportColumns / getPlanExportFilterParams:若导出走后端且后端已是产品配对(归档),计划页保持调用不变即可。
usePlPlanMergeListTable 增加 productKeywords 并传给 flattenPlanArchivePairRows。plan/index.vue 从产品名漏斗取出关键字(与客户关键字同一套 selectedKeywordsFromFilter 或文本值)。服务端已按产品滤计划时,客户端再滤可去掉同计划其它产品行。
cd web-admin && pnpm dev,打开 /#/pl/plan:多送货+多产品、勾选只在计划第一行、序号按计划、无虚线无色块。
Files:
web-admin/src/views/pl/plan-archive/index.vueweb-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。
Files:
web-admin/src/views/pl/plan-cert/index.vueweb-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。
Files:
web-admin/src/views/pl/plan-contract/index.vueweb-admin/src/views/pl/plan-contract/columnFilterDefs.tsgapType 列拆成:
loadContractGap:getContractGapSideLines(row, 'load') + showGapSupplementAction(row, 'load') 的补充按钮deliveryContractGap:送货侧漏斗仍绑定 gapType(可只挂在其中一列头,或两列都挂同一 filter)。去掉 pair-stack。数量列与 Task 4 相同。
手测合同完善。
Files:
web-admin/src/components/business/pl-contract-linked-plans-panel/index.vueModify: 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
Files:
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、isDeliveryFirstRowmatchLoadProduct、getRowSidePlanQty、getRowSideIoQty、PL_PLAN_SPAN_PROPS、PL_DELIVERY_SPAN_PROPSbuildPlPlanListSpanMethod 旧参数保留但不再使用