feat: flatten transport plan list rows to one product per line
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user