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:
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user