feat(web-admin): plan form package UI keyed by product and package spec

Contract-bound stop products use package options from the contract, arrange-qty
cache/API include packageSpec, and save validates required package selections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-10 18:06:52 +08:00
1 parent ded6aeaa85
commit 39161e43e4
9 files changed
+342 -37

No files matched your search

@@ -12,6 +12,7 @@ export type ContractQtyDialogRow = {
customerName?: string
stopKind?: string
productId?: string
packageSpec?: string
productName?: string
model?: string
unit?: string
@@ -86,6 +87,7 @@ function normalizeDialogRows(
customerName: it.customerName != null ? String(it.customerName) : '',
stopKind: it.stopKind != null ? String(it.stopKind) : '',
productId: it.productId != null ? String(it.productId) : '',
packageSpec: it.packageSpec != null ? String(it.packageSpec).trim() : '',
productName: it.productName != null ? String(it.productName) : '',
model: it.model != null ? String(it.model) : '',
unit: it.unit != null ? String(it.unit) : '',
@@ -141,7 +143,10 @@ export function buildContractQtyDialogHtml(
const title = groupTitle(list[0])
const body = list
.map((row) => {
const name = escapeHtml(row.productName || row.productId || '—')
const baseName = row.productName || row.productId || '—'
const name = escapeHtml(
row.packageSpec ? `${baseName}(${row.packageSpec})` : baseName
)
const model = escapeHtml(row.model || '—')
const unit = escapeHtml(resolveUnitLabel(row.unit))
const contractQty = row.framework
+89 -10
View File
@@ -13,6 +13,72 @@ export function collectContractProductIds(
return ids
}
export type ContractProductRowLite = {
productId?: string | null
packageSpec?: string | null
productName?: string | null
productNo?: string | null
quantity?: number | null
quantityMode?: string | null
}
export function normalizePackageSpec(value?: string | null): string {
return String(value ?? '').trim()
}
/** 稳定复合键:productId + '\\0' + 归一化包装 */
export function productPackageKey(
productId?: string | null,
packageSpec?: string | null
): string {
const pid = String(productId || '').trim()
if (!pid) return ''
return `${pid}\0${normalizePackageSpec(packageSpec)}`
}
export function collectContractProductRows(
products?: ContractProductRowLite[] | null
): ContractProductRowLite[] {
return (products || [])
.map((row) => ({
productId: String(row?.productId || '').trim(),
packageSpec: normalizePackageSpec(row?.packageSpec),
productName: row?.productName,
productNo: row?.productNo,
quantity: row?.quantity,
quantityMode: row?.quantityMode
}))
.filter((row) => row.productId)
}
/** 本合同该产品的包装列表(去重保序,仅非空) */
export function packageSpecsForProduct(
rows: ContractProductRowLite[] | null | undefined,
productId: string
): string[] {
const pid = String(productId || '').trim()
const out: string[] = []
const seen = new Set<string>()
for (const row of rows || []) {
if (String(row.productId || '').trim() !== pid) continue
const pkg = normalizePackageSpec(row.packageSpec)
if (!pkg || seen.has(pkg)) continue
seen.add(pkg)
out.push(pkg)
}
return out
}
export function isPackageAllowedForContractProduct(
rows: ContractProductRowLite[] | null | undefined,
productId: string,
packageSpec?: string | null
): boolean {
const allowed = packageSpecsForProduct(rows, productId)
if (!allowed.length) return true
return allowed.includes(normalizePackageSpec(packageSpec))
}
/** 保留 productId 落在 allowed 内的装货行;allowed 为空则全部移除(换合同且新合同无产品) */
export function filterProductsByAllowedProductIds<T extends { productId?: string | null }>(
products: T[],
@@ -27,23 +93,36 @@ export function filterProductsByAllowedProductIds<T extends { productId?: string
/**
* 找出不在装货产品列表中的送货产品(仅提醒,不拦截)。
* 返回展示名列表(去重)。
* 按 productId + packageSpec 比对;返回展示名列表(去重)。
*/
export function findDeliveryProductsMissingFromLoad(
deliveryProducts: Array<{ productId?: string | null; productName?: string | null }> | null | undefined,
loadProducts: Array<{ productId?: string | null }> | null | undefined
deliveryProducts:
| Array<{
productId?: string | null
productName?: string | null
packageSpec?: string | null
}>
| null
| undefined,
loadProducts:
| Array<{ productId?: string | null; packageSpec?: string | null }>
| null
| undefined
): string[] {
const loadIds = new Set(
(loadProducts || []).map((row) => String(row?.productId || '').trim()).filter(Boolean)
const loadKeys = new Set(
(loadProducts || [])
.map((row) => productPackageKey(row?.productId, row?.packageSpec))
.filter(Boolean)
)
const names: string[] = []
const seen = new Set<string>()
for (const row of deliveryProducts || []) {
const pid = String(row?.productId || '').trim()
if (!pid || loadIds.has(pid) || seen.has(pid)) continue
seen.add(pid)
const name = String(row?.productName || '').trim() || pid
names.push(name)
const key = productPackageKey(row?.productId, row?.packageSpec)
if (!key || loadKeys.has(key) || seen.has(key)) continue
seen.add(key)
const name = String(row?.productName || '').trim() || String(row?.productId || '').trim()
const pkg = normalizePackageSpec(row?.packageSpec)
names.push(pkg ? `${name}(${pkg})` : name)
}
return names
}
+12
View File
@@ -6,6 +6,7 @@ import {
isPlDibangOptionalVehiclePlate,
isPlInternalCustomerName
} from '@/utils/pl-internal-customer'
import { normalizePackageSpec } from '@/utils/pl-plan-contract-product'
export interface PlanStopProductFormRow {
id?: string
@@ -214,6 +215,17 @@ function matchPrevStopProduct(
return stopProducts[byPlanProductId]
}
}
const targetPkg = normalizePackageSpec(planRow.packageSpec)
const byProductAndPkg = stopProducts.findIndex(
(row, index) =>
!used.has(index) &&
String(row.productId) === String(planRow.productId) &&
normalizePackageSpec(row.packageSpec) === targetPkg
)
if (byProductAndPkg >= 0) {
used.add(byProductAndPkg)
return stopProducts[byProductAndPkg]
}
const byProductId = stopProducts.findIndex(
(row, index) => !used.has(index) && String(row.productId) === String(planRow.productId)
)