feat: flatten transport plan list rows to one product per line

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-06 09:23:59 +08:00
1 parent 6abfb6e251
commit e241a17077
2 files changed
+184 -7

No files matched your search

@@ -0,0 +1,95 @@
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)
})
})
@@ -23,11 +23,15 @@ export interface PlPlanListDisplayRow {
*/
isPairRow?: boolean
pairLoad?: PlanLoad
deliveryProduct?: PlanStopProduct
loadProduct?: PlanStopProduct
/** 当前页内计划序号(0-based),用于整单斑马纹 */
planListIndex: number
isPlanFirstRow: boolean
planRowSpan: number
isPlanLastRow: boolean
deliveryRowSpan: number
isDeliveryFirstRow: boolean
}
const PLAN_FIXED_MERGE_COL_COUNT = 3
@@ -79,6 +83,31 @@ export function stopCustomerMatchesKeywords(
})
}
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()))
}
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)
}
export function flattenPlanListRows(
plans: Plan[],
options?: { kinds?: PlPlanListDisplayRowKind[] | null; customerKeywords?: string[] | null }
@@ -130,7 +159,9 @@ export function flattenPlanListRows(
planListIndex: rowPlanIndex,
isPlanFirstRow: index === 0,
planRowSpan,
isPlanLastRow: index === slots.length - 1
isPlanLastRow: index === slots.length - 1,
deliveryRowSpan: 1,
isDeliveryFirstRow: true
})
})
if (narrowed) visiblePlanIndex += 1
@@ -156,11 +187,13 @@ export function flattenPlanArchivePairRows(
deliveryCustomerKeywords?: string[] | null
/** 任一侧客户包含关键字即保留(兼容旧 stopCustomers) */
customerKeywords?: string[] | null
productKeywords?: string[] | null
}
): PlPlanListDisplayRow[] {
const loadKeywords = normalizeStopCustomerKeywords(options?.loadCustomerKeywords)
const deliveryKeywords = normalizeStopCustomerKeywords(options?.deliveryCustomerKeywords)
const eitherKeywords = normalizeStopCustomerKeywords(options?.customerKeywords)
const productKeywords = normalizeStopCustomerKeywords(options?.productKeywords)
const rows: PlPlanListDisplayRow[] = []
let visiblePlanIndex = 0
@@ -195,22 +228,61 @@ export function flattenPlanArchivePairRows(
})
if (!slots.length) return
const narrowed = !!(loadKeywords || deliveryKeywords || eitherKeywords)
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 narrowed = !!(loadKeywords || deliveryKeywords || eitherKeywords || productKeywords)
const rowPlanIndex = narrowed ? visiblePlanIndex : planListIndex
const planRowSpan = slots.length
slots.forEach((slot, index) => {
const planRowSpan = productSlots.length
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}`,
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 === slots.length - 1
isPlanLastRow: index === productSlots.length - 1,
deliveryRowSpan: same.length,
isDeliveryFirstRow: index === firstIndex
})
})
if (narrowed) visiblePlanIndex += 1
@@ -282,6 +354,14 @@ export function getDeliveryProductsFromRow(row: PlPlanListDisplayRow): PlanStopP
return getPairDeliveryStop(row)?.products || []
}
export function getLoadContractsFromRow(row: PlPlanListDisplayRow) {
return getPairLoadStop(row)?.contracts || []
}
export function getDeliveryContractsFromRow(row: PlPlanListDisplayRow) {
return getPairDeliveryStop(row)?.contracts || []
}
function stopHasProductId(
stop: PlanLoad | PlanDelivery | null | undefined,
productId: string
@@ -355,7 +435,9 @@ export function flattenPlanListRowsForProduct(
planListIndex,
isPlanFirstRow: index === 0,
planRowSpan,
isPlanLastRow: index === stops.length - 1
isPlanLastRow: index === stops.length - 1,
deliveryRowSpan: 1,
isDeliveryFirstRow: true
})
})
planListIndex += 1