diff --git a/web-admin/src/utils/pl-plan-list-display-rows.test.ts b/web-admin/src/utils/pl-plan-list-display-rows.test.ts new file mode 100644 index 000000000..cc2a3fa85 --- /dev/null +++ b/web-admin/src/utils/pl-plan-list-display-rows.test.ts @@ -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 & Pick): 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) + }) +}) diff --git a/web-admin/src/utils/pl-plan-list-display-rows.ts b/web-admin/src/utils/pl-plan-list-display-rows.ts index 154b5df71..cb8db0e78 100644 --- a/web-admin/src/utils/pl-plan-list-display-rows.ts +++ b/web-admin/src/utils/pl-plan-list-display-rows.ts @@ -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