Просмотр исходного кода

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

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 4 недель назад
Родитель
Сommit
e241a17077

+ 95 - 0
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<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)
+  })
+})

+ 89 - 7
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