Files
shanxia2.0/web-admin/src/utils/pl-plan-contract-product.ts
T
wuzilongandCursor 8e9536332a feat: 保存运输证件、报销付款和开发流程的当前实现。
补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:42:33 +08:00

312 lines
9.9 KiB
TypeScript

import { resolvePackageSpecsFromPayload } from '@/utils/crm-package-spec'
import { normalizeProductUnitValue } from '@/utils/crm-product-display'
/** 从合同明细行收集 CRM productId */
export function collectContractProductIds(
products?: Array<{ productId?: string | null }> | null
): string[] {
const ids: string[] = []
const seen = new Set<string>()
for (const row of products || []) {
const id = String(row?.productId || '').trim()
if (!id || seen.has(id)) continue
seen.add(id)
ids.push(id)
}
return ids
}
export type ContractProductRowLite = {
id?: string | null
contractId?: string | null
contractNo?: string | null
productId?: string | null
packageSpec?: string | null
packageSpecs?: string[] | null
productName?: string | null
productNo?: string | null
quantity?: number | null
quantityMode?: string | null
price?: number | null
remainingQty?: number | null
unit?: string | null
model?: string | null
productModel?: string | null
standardProductName?: string | null
aliasName?: string | null
brandMode?: string | null
brandName?: string | null
taxRate?: number | null
status?: string | null
deliveryMethod?: string | null
}
export type ContractLineChoice = {
id: string
contractId: string
contractNo: string
label: string
unit: string
}
/** 同一产品在当前合同里的明细行,供历史行原位补合同编号。 */
export function contractLineChoicesForProduct(
rows: ContractProductRowLite[] | null | undefined,
productId?: string | null
): ContractLineChoice[] {
const targetId = String(productId || '').trim()
const seen = new Set<string>()
const choices: ContractLineChoice[] = []
for (const row of rows || []) {
if (String(row.productId || '').trim() !== targetId) continue
const id = String(row.id || '').trim()
const contractId = String(row.contractId || '').trim()
const key = `${contractId}:${id}`
if (!id || !contractId || seen.has(key)) continue
seen.add(key)
const contractNo = String(row.contractNo || '').trim() || contractId
const specs = resolvePackageSpecsFromPayload(row).filter(Boolean)
choices.push({
id,
contractId,
contractNo,
unit: String(row.unit || '').trim() ? normalizeProductUnitValue(row.unit) : '',
label: specs.length ? `${contractNo} ${specs.join('、')}` : contractNo
})
}
return choices
}
export function collectStopContractIds(
contracts?: Array<{ contractId?: string | null }> | null
): string[] {
const ids: string[] = []
const seen = new Set<string>()
for (const item of contracts || []) {
const id = String(item?.contractId || '').trim()
if (!id || seen.has(id)) continue
seen.add(id)
ids.push(id)
}
return ids
}
export function unionContractProductIds(
byContract: Record<string, string[]> | undefined,
contractIds: string[]
): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const cid of contractIds) {
for (const raw of byContract?.[cid] || []) {
const id = String(raw || '').trim()
if (!id || seen.has(id)) continue
seen.add(id)
out.push(id)
}
}
return out
}
export function unionContractProductRows(
byContract: Record<string, ContractProductRowLite[]> | undefined,
contractIds: string[]
): ContractProductRowLite[] {
const out: ContractProductRowLite[] = []
for (const cid of contractIds) {
out.push(...(byContract?.[cid] || []))
}
return out
}
export function resolveRowPackageSpecs(row?: ContractProductRowLite | null): string[] {
return resolvePackageSpecsFromPayload(row)
}
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[] {
const out: ContractProductRowLite[] = []
for (const row of products || []) {
const productId = String(row?.productId || '').trim()
if (!productId) continue
const specs = resolveRowPackageSpecs(row)
const base = {
id: row?.id,
contractId: row?.contractId,
contractNo: row?.contractNo,
productId,
productName: row?.productName,
productNo: row?.productNo,
quantity: row?.quantity,
quantityMode: row?.quantityMode,
price: row?.price,
remainingQty: row?.remainingQty,
unit: row?.unit,
model: row?.model,
productModel: row?.productModel,
standardProductName: row?.standardProductName,
aliasName: row?.aliasName,
brandMode: row?.brandMode,
brandName: row?.brandName,
taxRate: row?.taxRate,
status: row?.status,
deliveryMethod: row?.deliveryMethod
}
if (!specs.length) {
out.push({ ...base, packageSpec: '' })
continue
}
for (const packageSpec of specs) {
out.push({ ...base, packageSpec: normalizePackageSpec(packageSpec) })
}
}
return out
}
/** 本合同该产品的包装列表(去重保序,仅非空) */
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
for (const pkg of resolveRowPackageSpecs(row)) {
if (!pkg || seen.has(pkg)) continue
seen.add(pkg)
out.push(pkg)
}
}
return out
}
/**
* 装/送货行允许的包装。
* collectContractProductRows 会把一条合同明细按包装拆成多行并共用 id,
* 绑了 contractProductId 时要汇总该明细的全部包装,不能只取第一行。
* 未绑定或绑定行不在缓存中时,回退为该产品在所选合同中的全部包装。
*/
export function allowedPackageSpecsForStopProduct(
rows: ContractProductRowLite[] | null | undefined,
product: { productId?: string | null; contractProductId?: string | null }
): string[] {
const pid = String(product.productId || '').trim()
const boundId = String(product.contractProductId || '').trim()
if (!boundId) return packageSpecsForProduct(rows, pid)
const boundRows = (rows || []).filter((row) => String(row.id || '').trim() === boundId)
if (!boundRows.length) return packageSpecsForProduct(rows, pid)
return packageSpecsForProduct(boundRows, pid)
}
/** 已绑定合同产品行时,单位跟合同走,不能再改。 */
export function stopProductUnitLocked(row: { contractProductId?: string | null }): boolean {
return Boolean(String(row.contractProductId || '').trim())
}
/** 合同明细上的单位。同一明细被按包装拆成多行时,取第一条有单位的记录。 */
export function boundContractProductUnit(
rows: ContractProductRowLite[] | null | undefined,
contractProductId?: string | null
): string {
const boundId = String(contractProductId || '').trim()
if (!boundId) return ''
for (const row of rows || []) {
if (String(row.id || '').trim() !== boundId) continue
const unit = String(row.unit || '').trim()
if (unit) return normalizeProductUnitValue(unit)
}
return ''
}
/**
* 把已绑定行的单位改成合同单位。
* 返回 true 表示本次改写了单位,调用方应提示按合同单位核对数量。
*/
export function alignStopProductUnitToContract(
row: { contractProductId?: string | null; unit?: string | null },
rows: ContractProductRowLite[] | null | undefined
): boolean {
if (!stopProductUnitLocked(row)) return false
const contractUnit = boundContractProductUnit(rows, row.contractProductId)
if (!contractUnit) return false
if (normalizeProductUnitValue(row.unit) === contractUnit) return false
row.unit = contractUnit
return true
}
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[],
allowedProductIds: string[]
): T[] {
const allowed = new Set(
(allowedProductIds || []).map((id) => String(id).trim()).filter(Boolean)
)
if (!allowed.size) return []
return (products || []).filter((row) => allowed.has(String(row.productId || '').trim()))
}
/**
* 找出不在装货产品列表中的送货产品(仅提醒,不拦截)。
* 按 productId 比对(同品即视为已在装货中)。
* 不再要求包装规格一致:装货来自合同行常带包装,送货(尤其内部客户主数据选品)
* 包装可能为空或尚未填写,用 productId+package 会误报「产品不在装货列表」。
*/
export function findDeliveryProductsMissingFromLoad(
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 loadProductIds = new Set(
(loadProducts || [])
.map((row) => String(row?.productId || '').trim())
.filter(Boolean)
)
const names: string[] = []
const seen = new Set<string>()
for (const row of deliveryProducts || []) {
const pid = String(row?.productId || '').trim()
if (!pid || loadProductIds.has(pid) || seen.has(pid)) continue
seen.add(pid)
const name = String(row?.productName || '').trim() || pid
names.push(name)
}
return names
}