补齐证书齐备、证件折算、移动用证和合同绑定,并带上报销批量发放、付款备注以及个人开发流程升级,便于继续讨论已安排计划的调整规则。 Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
9.9 KiB
TypeScript
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
|
|
}
|