import type { Plan, PlanContractLink, PlanDelivery, PlanLoad, PlanStopProduct } from '@/api/pl' import type { PlanFormProductRow } from '@/composables/usePlContractToPlan' import type { BrandOption } from '@/utils/crm-product-brand' import { formatBrandConstraintLabel } from '@/utils/crm-product-brand' import { normalizeProductUnitValue } from '@/utils/crm-product-display' import { isPlDibangOptionalVehiclePlate, isPlInternalCustomerName } from '@/utils/pl-internal-customer' import { collectStopContractIds, normalizePackageSpec } from '@/utils/pl-plan-contract-product' export interface PlanStopProductFormRow { id?: string planProductId?: string productId: string productNo: string productName: string /** 展示用:主数据标准名 */ standardProductName?: string /** 展示用:别名 */ aliasName?: string aliasOptions?: string[] brandMode?: string brandId?: string brandName?: string brandOptions?: BrandOption[] model?: string unit: string packageSpec?: string deliveryMethod?: string planQty?: number /** 装货段归档:入库数量即实际数量 */ inboundQty?: number inboundPrice?: number inboundAmount?: number /** 送货段归档:出库数量即实际数量 */ outboundQty?: number outboundPrice?: number outboundAmount?: number /** 本段归档品牌数量 */ brandQtys?: Array<{ brandId: string; brandName?: string; qty: number; unitPrice?: number }> remark?: string isExplosive?: number isToxic?: number contractId?: string contractProductId?: string contractNo?: string /** 打开旧单时单位曾与合同不一致,提示按合同单位核对数量。不提交。 */ contractUnitNote?: string } /** 段次查看/归档:装货段取 inbound,送货段取 outbound */ export function resolveStopActualQty( row: PlanStopProductFormRow, stopType: 'load' | 'delivery' ): number | undefined { const raw = stopType === 'load' ? row.inboundQty : row.outboundQty return raw != null ? Number(raw) : undefined } export function resolveStopUnitPrice( row: PlanStopProductFormRow, stopType: 'load' | 'delivery' ): number | undefined { const raw = stopType === 'load' ? row.inboundPrice : row.outboundPrice return raw != null ? Number(raw) : undefined } export function resolveStopArchiveAmount( row: PlanStopProductFormRow, stopType: 'load' | 'delivery' ): number | undefined { const stored = stopType === 'load' ? row.inboundAmount : row.outboundAmount if (stored != null) { const n = Number(stored) return Number.isFinite(n) ? n : undefined } const qty = resolveStopActualQty(row, stopType) const price = resolveStopUnitPrice(row, stopType) if (qty == null || price == null) return undefined const amount = qty * price return Number.isFinite(amount) ? amount : undefined } export function formatStopArchiveAmount( row: PlanStopProductFormRow, stopType: 'load' | 'delivery' ): string { const amount = resolveStopArchiveAmount(row, stopType) return amount != null ? amount.toFixed(2) : '—' } export interface PlanLoadForm { id?: string customerId: string customerName: string address: string lng: number | null lat: number | null loadTime: string contact: string notes: string generateDibang: number dibangDirection: string /** 1 时本段易制毒只校验购买证 */ sameCity: number contracts: PlanContractLink[] products: PlanStopProductFormRow[] } export interface PlanDeliveryForm { id?: string seq: number customerId: string customerName: string address: string lng: number | null lat: number | null deliveryTime: string contact: string notes: string generateDibang: number dibangDirection: string /** 1 时本段易制毒只校验购买证 */ sameCity: number contracts: PlanContractLink[] products: PlanStopProductFormRow[] } /** * 地磅勾选规则: * - 内部客户(山峡/云栋):默认勾选且不可改 * - 地磅可选车(如皖H21522)+ 内部客户:默认不勾,可手动勾选 * - 其他客户:默认不勾且不可勾 */ export function resolveStopDibangDefaults( customerName?: string | null, stopType: 'load' | 'delivery' = 'load', vehiclePlate?: string | null ): { generateDibang: number; dibangDirection: string } { if (!isPlInternalCustomerName(customerName)) { return { generateDibang: 0, dibangDirection: 'none' } } if (isPlDibangOptionalVehiclePlate(vehiclePlate)) { return { generateDibang: 0, dibangDirection: 'none' } } if (stopType === 'load') { return { generateDibang: 1, dibangDirection: 'outbound' } } return { generateDibang: 1, dibangDirection: 'inbound' } } /** 安排前检查易制毒段的合同证件。同市段只要求购买证。 */ export function toxicStopCertBindingMessage( rows: Array<{ partyName?: string | null sameCity?: boolean | number | null certificates?: { certType?: string | null }[] | null contractId?: string | null contractNo?: string | null productName?: string | null sourceStopProductId?: string | null }> ): string { const missingBoth = new Map() const missingPurchase = new Map() for (const row of rows) { if (isPlInternalCustomerName(row.partyName)) continue const certificates = row.certificates || [] const hasPurchase = certificates.some((cert) => cert.certType === 'purchase') const hasTransport = certificates.some((cert) => cert.certType === 'transport') const sameCity = row.sameCity === true || Number(row.sameCity) === 1 const key = row.contractId || row.contractNo || row.productName || row.sourceStopProductId || '' const label = row.contractNo || '未编号合同' if (sameCity) { if (!hasPurchase) missingPurchase.set(key, label) continue } if (hasPurchase && hasTransport) continue missingBoth.set(key, label) } const parts: string[] = [] if (missingBoth.size) { parts.push( `合同${[...missingBoth.values()].join('、')}尚未绑定购买证和运输证,请先到合同中绑定后再安排运输` ) } if (missingPurchase.size) { parts.push( `合同${[...missingPurchase.values()].join('、')}的同市段尚未绑定购买证,请先到合同中绑定后再安排运输` ) } return parts.join(';') } /** 仅「内部客户 + 地磅可选车」允许用户手动勾选地磅单 */ export function canManualToggleStopDibang( customerName?: string | null, vehiclePlate?: string | null ): boolean { return isPlInternalCustomerName(customerName) && isPlDibangOptionalVehiclePlate(vehiclePlate) } function normalizeStopDibangFieldsFromApi( customerName: string | null | undefined, stopType: 'load' | 'delivery', generateDibang?: number | null, dibangDirection?: string | null, vehiclePlate?: string | null ): { generateDibang: number; dibangDirection: string } { const defaults = resolveStopDibangDefaults(customerName, stopType, vehiclePlate) // 非山峡/云栋:禁止生成地磅单(编辑回填也强制清掉历史勾选) if (!isPlInternalCustomerName(customerName)) { return { generateDibang: 0, dibangDirection: 'none' } } const gen = Number(generateDibang ?? defaults.generateDibang) === 1 ? 1 : 0 let direction = (dibangDirection || '').trim() if (gen) { if (direction !== 'outbound' && direction !== 'inbound') { // 可选车默认 direction 为 none,勾选回填时按段类型补方向 direction = stopType === 'load' ? 'outbound' : 'inbound' } } else { direction = 'none' } return { generateDibang: gen, dibangDirection: direction } } export function createStopProductsFromPlanProducts( products: PlanFormProductRow[], qtyField: 'quantity' | 'actualQty' = 'quantity' ): PlanStopProductFormRow[] { return products.map((item) => ({ planProductId: item.id, productId: item.productId, productNo: item.productNo, productName: item.productName, standardProductName: item.standardProductName || item.productName, aliasName: item.aliasName, aliasOptions: item.aliasOptions ? [...item.aliasOptions] : undefined, brandMode: item.brandMode, brandId: item.brandId, brandName: item.brandName, brandOptions: item.brandOptions ? [...item.brandOptions] : undefined, model: item.model, unit: normalizeProductUnitValue(item.unit), packageSpec: item.packageSpec, deliveryMethod: item.deliveryMethod, planQty: Number(item[qtyField] ?? item.quantity ?? 0), remark: item.remark, isExplosive: item.isExplosive, isToxic: item.isToxic })) } /** * 只新增合同时保留现有产品。 * 删掉或换掉合同时,去掉被移除合同的产品,也去掉没有合同编号的产品。 */ export function retainStopProductsForContracts( products: PlanStopProductFormRow[], previousContractIds: string[], nextContractIds: string[] ): PlanStopProductFormRow[] { const previous = new Set( (previousContractIds || []).map((id) => String(id || '').trim()).filter(Boolean) ) const next = new Set((nextContractIds || []).map((id) => String(id || '').trim()).filter(Boolean)) const removed = [...previous].filter((id) => !next.has(id)) if (!removed.length) return products return products.filter((row) => { const contractId = String(row.contractId || '').trim() return Boolean(contractId) && next.has(contractId) }) } function stopProductLabel(row: PlanStopProductFormRow) { return ( String(row.standardProductName || row.productName || row.productNo || '未命名产品').trim() || '未命名产品' ) } /** 外部客户已选合同时,尚未绑到合同明细行的产品。山峡、云栋不选合同,不在此列。 */ export function describeUntaggedExternalStopProducts( load: { customerName?: string | null contracts?: Array<{ contractId?: string | null }> | null products?: PlanStopProductFormRow[] | null }, deliveries: Array<{ customerName?: string | null contracts?: Array<{ contractId?: string | null }> | null products?: PlanStopProductFormRow[] | null }> ): string { const gaps: string[] = [] const collect = ( label: string, customerName: string | null | undefined, contracts: Array<{ contractId?: string | null }> | null | undefined, products: PlanStopProductFormRow[] | null | undefined ) => { if (isPlInternalCustomerName(customerName)) return const selected = new Set(collectStopContractIds(contracts)) if (!selected.size) return for (const row of products || []) { const contractId = String(row.contractId || '').trim() const contractProductId = String(row.contractProductId || '').trim() if (contractId && selected.has(contractId) && contractProductId) continue gaps.push(`${label}「${stopProductLabel(row)}」`) } } collect('装货产品', load.customerName, load.contracts, load.products) deliveries.forEach((row, index) => { collect(`送货段#${index + 1}`, row.customerName, row.contracts, row.products) }) if (!gaps.length) return '' return `${gaps.join('、')}没有合同编号,请从当前合同补到原来的产品行后再正式保存` } function matchPrevStopProduct( stopProducts: PlanStopProductFormRow[], planRow: PlanFormProductRow, used: Set ): PlanStopProductFormRow | undefined { if (planRow.id) { const byPlanProductId = stopProducts.findIndex( (row, index) => !used.has(index) && row.planProductId && String(row.planProductId) === String(planRow.id) ) if (byPlanProductId >= 0) { used.add(byPlanProductId) 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) ) if (byProductId >= 0) { used.add(byProductId) return stopProducts[byProductId] } return undefined } /** * 按计划产品重建段产品列表:增删对齐计划层;共享字段取自计划; * 可选保留段内已改的数量 / 备注。 */ export function reconcileStopProductsWithPlan( stopProducts: PlanStopProductFormRow[], planProducts: PlanFormProductRow[], options?: { preserveQty?: boolean preserveRemark?: boolean } ): PlanStopProductFormRow[] { const preserveQty = options?.preserveQty !== false const preserveRemark = options?.preserveRemark !== false const used = new Set() const nextRows = createStopProductsFromPlanProducts(planProducts) return nextRows.map((row, index) => { const planRow = planProducts[index] const prev = planRow ? matchPrevStopProduct(stopProducts, planRow, used) : undefined if (!prev) return row return { ...row, planQty: preserveQty ? (prev.planQty ?? row.planQty) : row.planQty, remark: preserveRemark ? (prev.remark ?? row.remark) : row.remark } }) } /** 用计划产品行补齐装货/送货产品表展示字段(别名、品牌等) */ export function sumStopProductsPlanQty(products: PlanStopProductFormRow[]): number { return products.reduce((sum, row) => sum + Number(row.planQty ?? 0), 0) } /** 段内紧凑表「产品」列副行:别名 · 品牌 · 规格 · 单位 */ export function formatStopProductMetaSubline( row: PlanStopProductFormRow, unitLabel?: string ): string { const parts: string[] = [] if (row.aliasName) parts.push(`别名:${row.aliasName}`) const brand = formatBrandConstraintLabel(row.brandMode, row.brandName) if (brand && brand !== '—') parts.push(`品牌:${brand}`) if (row.model) parts.push(row.model) const unit = unitLabel || row.unit if (unit) parts.push(unit) return parts.join(' · ') } export function enrichStopProductsDisplayFromPlan( stopProducts: PlanStopProductFormRow[], planProducts: Array<{ id?: string productId: string productNo?: string productName?: string standardProductName?: string aliasName?: string aliasOptions?: string[] brandMode?: string brandId?: string brandName?: string brandOptions?: BrandOption[] model?: string unit?: string packageSpec?: string deliveryMethod?: string quantity?: number isExplosive?: number isToxic?: number }> ): PlanStopProductFormRow[] { return stopProducts.map((row) => { const match = planProducts.find( (p) => row.planProductId && p.id && String(p.id) === String(row.planProductId) ) || planProducts.find((p) => String(p.productId) === String(row.productId)) if (!match) return row const planQty = row.planQty != null && Number.isFinite(Number(row.planQty)) ? Number(row.planQty) : match.quantity != null ? Number(match.quantity) : row.planQty return { ...row, productNo: row.productNo || match.productNo || row.productNo, standardProductName: match.standardProductName || match.productName || row.productName, aliasName: match.aliasName ?? row.aliasName, aliasOptions: match.aliasOptions?.length ? [...match.aliasOptions] : row.aliasOptions, brandMode: match.brandMode ?? row.brandMode, brandId: match.brandId ?? row.brandId, brandName: match.brandName ?? row.brandName, brandOptions: match.brandOptions?.length ? [...match.brandOptions] : row.brandOptions, model: row.model || match.model, unit: normalizeProductUnitValue(row.unit || match.unit), packageSpec: row.packageSpec || match.packageSpec, deliveryMethod: row.deliveryMethod || match.deliveryMethod, planQty, isExplosive: match.isExplosive ?? row.isExplosive, isToxic: match.isToxic ?? row.isToxic } }) } /** 查看态:仅展示段上真实产品(空段不回退计划产品);有段产品时补齐展示字段 */ export function resolveStopProductsForView( stopProducts: PlanStopProductFormRow[] | undefined | null, planProducts: PlanFormProductRow[] ): PlanStopProductFormRow[] { const rows = stopProducts?.length ? stopProducts : [] return enrichStopProductsDisplayFromPlan(rows, planProducts) } export function createEmptyLoadForm(): PlanLoadForm { return { customerId: '', customerName: '', address: '', lng: null, lat: null, loadTime: '', contact: '', notes: '', generateDibang: 0, dibangDirection: 'none', sameCity: 0, contracts: [], products: [] } } export function createEmptyDeliveryForm(seq = 1): PlanDeliveryForm { return { seq, customerId: '', customerName: '', address: '', lng: null, lat: null, deliveryTime: '', contact: '', notes: '', generateDibang: 0, dibangDirection: 'none', sameCity: 0, contracts: [], products: [] } } function mapStopProductFromApi(row: PlanStopProduct): PlanStopProductFormRow { return { id: row.id, planProductId: row.planProductId, productId: row.productId, productNo: row.productNo, productName: row.productName, model: row.model, unit: normalizeProductUnitValue(row.unit), packageSpec: row.packageSpec, deliveryMethod: row.deliveryMethod, contractId: row.contractId, contractProductId: row.contractProductId, contractNo: row.contractNo, planQty: row.planQty != null ? Number(row.planQty) : undefined, inboundQty: row.inboundQty != null ? Number(row.inboundQty) : undefined, inboundPrice: row.inboundPrice != null ? Number(row.inboundPrice) : undefined, inboundAmount: row.inboundAmount != null ? Number(row.inboundAmount) : undefined, outboundQty: row.outboundQty != null ? Number(row.outboundQty) : undefined, outboundPrice: row.outboundPrice != null ? Number(row.outboundPrice) : undefined, outboundAmount: row.outboundAmount != null ? Number(row.outboundAmount) : undefined, brandQtys: (row.brandQtys || []) .filter((item) => Number(item.qty) > 0) .map((item) => ({ brandId: String(item.brandId || ''), brandName: item.brandName, qty: Number(item.qty), unitPrice: item.unitPrice != null && Number.isFinite(Number(item.unitPrice)) ? Number(item.unitPrice) : undefined })), remark: row.remark } } /** 查看态装货地址:优先段表 address,回退计划 pickupAddress */ export function resolveLoadViewAddress( plan?: { pickupAddress?: string | null } | null, load?: { address?: string | null } | null ): string { const fromStop = (load?.address || '').trim() if (fromStop) return fromStop return (plan?.pickupAddress || '').trim() } /** 查看态送货地址:优先段表 address,首段可回退计划 destinationAddress */ export function resolveDeliveryViewAddress( plan?: { destinationAddress?: string | null } | null, delivery?: { address?: string | null } | null, index = 0 ): string { const fromStop = (delivery?.address || '').trim() if (fromStop) return fromStop if (index === 0) { return (plan?.destinationAddress || '').trim() } return '' } export function mapLoadFromApi( load?: PlanLoad | null, vehiclePlate?: string | null ): PlanLoadForm { if (!load) return createEmptyLoadForm() const dibang = normalizeStopDibangFieldsFromApi( load.customerName, 'load', load.generateDibang, load.dibangDirection, vehiclePlate ) return { id: load.id || undefined, customerId: load.customerId || '', customerName: load.customerName || '', address: load.address || '', lng: load.lng != null ? Number(load.lng) : null, lat: load.lat != null ? Number(load.lat) : null, loadTime: (load.loadTime || '').slice(0, 10), contact: load.contact || '', notes: load.notes || '', generateDibang: dibang.generateDibang, dibangDirection: dibang.dibangDirection, sameCity: Number(load.sameCity) === 1 ? 1 : 0, contracts: (load.contracts || []).map((item) => ({ contractId: String(item.contractId || ''), contractNo: item.contractNo })), products: (load.products || []).map(mapStopProductFromApi) } } export function mapDeliveriesFromApi( deliveries?: PlanDelivery[], vehiclePlate?: string | null ): PlanDeliveryForm[] { if (!deliveries?.length) return [createEmptyDeliveryForm(1)] return deliveries.map((row, index) => { const dibang = normalizeStopDibangFieldsFromApi( row.customerName, 'delivery', row.generateDibang, row.dibangDirection, vehiclePlate ) return { id: row.id || undefined, seq: row.seq ?? index + 1, customerId: row.customerId || '', customerName: row.customerName || '', address: row.address || '', lng: row.lng != null ? Number(row.lng) : null, lat: row.lat != null ? Number(row.lat) : null, deliveryTime: (row.deliveryTime || '').slice(0, 10), contact: row.contact || '', notes: row.notes || '', generateDibang: dibang.generateDibang, dibangDirection: dibang.dibangDirection, sameCity: Number(row.sameCity) === 1 ? 1 : 0, contracts: (row.contracts || []).map((item) => ({ contractId: String(item.contractId || ''), contractNo: item.contractNo })), products: (row.products || []).map(mapStopProductFromApi) } }) } /** 旧数据无 load/deliveries 时从供方/需方字段回填 */ export function mapLegacyPlanToStops(plan: Plan): { load: PlanLoadForm deliveries: PlanDeliveryForm[] } { const plate = plan.licensePlate const loadDefaults = resolveStopDibangDefaults(plan.supplierName, 'load', plate) const deliveryDefaults = resolveStopDibangDefaults(plan.customerName, 'delivery', plate) const stopProducts = createStopProductsFromPlanProducts( (plan.products || []).map((item, index) => ({ id: item.id, productId: String(item.productId || index), productNo: item.productNo || '', productName: item.productName || '', model: item.model, unit: normalizeProductUnitValue(item.unit), quantity: Number(item.quantity ?? item.actualQty ?? 0), packageSpec: item.packageSpec, deliveryMethod: item.deliveryMethod, remark: item.remark })) ) return { load: { customerId: plan.supplierId || '', customerName: plan.supplierName || '', address: plan.pickupAddress || '', lng: plan.pickupLng != null ? Number(plan.pickupLng) : null, lat: plan.pickupLat != null ? Number(plan.pickupLat) : null, loadTime: (plan.pickupTime || '').slice(0, 10), contact: plan.pickupContact || '', notes: plan.pickupNotes || '', generateDibang: loadDefaults.generateDibang, dibangDirection: loadDefaults.dibangDirection, sameCity: 0, contracts: (plan.supplierContracts || []).map((item) => ({ contractId: String(item.contractId || ''), contractNo: item.contractNo })), products: stopProducts.length ? stopProducts.map((row) => ({ ...row })) : [] }, deliveries: [ { seq: 1, customerId: plan.customerId || '', customerName: plan.customerName || '', address: plan.destinationAddress || '', lng: plan.destinationLng != null ? Number(plan.destinationLng) : null, lat: plan.destinationLat != null ? Number(plan.destinationLat) : null, deliveryTime: (plan.deliveryTime || '').slice(0, 10), contact: plan.deliveryContact || '', notes: plan.destinationNotes || '', generateDibang: deliveryDefaults.generateDibang, dibangDirection: deliveryDefaults.dibangDirection, sameCity: 0, contracts: (plan.demanderContracts || []).map((item) => ({ contractId: String(item.contractId || ''), contractNo: item.contractNo })), products: stopProducts.length ? stopProducts.map((row) => ({ ...row })) : [] } ] } } export function syncLoadProductsFromPlan( load: PlanLoadForm, planProducts: PlanFormProductRow[], preserveQty = true ) { load.products = reconcileStopProductsWithPlan(load.products, planProducts, { preserveQty, preserveRemark: preserveQty }) } export function syncDeliveryProductsFromPlan( delivery: PlanDeliveryForm, planProducts: PlanFormProductRow[], preserveQty = false ) { delivery.products = reconcileStopProductsWithPlan(delivery.products, planProducts, { preserveQty, preserveRemark: preserveQty }) } function matchPrevDeliveryProduct( stopProducts: PlanStopProductFormRow[], loadRow: PlanStopProductFormRow, used: Set ): PlanStopProductFormRow | undefined { if (loadRow.planProductId) { const byPlanProductId = stopProducts.findIndex( (row, index) => !used.has(index) && row.planProductId && String(row.planProductId) === String(loadRow.planProductId) ) if (byPlanProductId >= 0) { used.add(byPlanProductId) return stopProducts[byPlanProductId] } } const byProductId = stopProducts.findIndex( (row, index) => !used.has(index) && String(row.productId) === String(loadRow.productId) ) if (byProductId >= 0) { used.add(byProductId) return stopProducts[byProductId] } return undefined } function cloneStopProductSharedFields( loadRow: PlanStopProductFormRow, planQty: number, remark?: string ): PlanStopProductFormRow { return { ...loadRow, aliasOptions: loadRow.aliasOptions ? [...loadRow.aliasOptions] : undefined, brandOptions: loadRow.brandOptions ? [...loadRow.brandOptions] : undefined, planQty, remark: remark ?? loadRow.remark } } /** * 从装货产品同步到各送货段(产品清单 ⊆ 装货)。 * C1:仅 1 个送货段时数量跟装货;≥2 段时保留已有段数量,新增行/新增段默认 0。 * 注:运输计划表单已不再调用;送货产品需按合同重新选择。 */ export function syncDeliveriesProductsFromLoad( deliveries: PlanDeliveryForm[], loadProducts: PlanStopProductFormRow[], options?: { forceQtyFromLoad?: boolean } ): { deliveries: PlanDeliveryForm[]; needSplitHint: boolean } { const multi = deliveries.length >= 2 const forceQty = options?.forceQtyFromLoad === true const next = deliveries.map((delivery) => { const used = new Set() const products = loadProducts.map((loadRow) => { const prev = matchPrevDeliveryProduct(delivery.products || [], loadRow, used) let planQty: number if (forceQty || !multi) { planQty = Number(loadRow.planQty ?? 0) } else if (prev) { planQty = Number(prev.planQty ?? 0) } else { planQty = 0 } return { ...cloneStopProductSharedFields(loadRow, planQty, prev?.remark), id: prev?.id } }) return { ...delivery, products } }) return { deliveries: next, needSplitHint: multi } } /** 装货产品 → 计划层产品行(保存 pl_plan_product / 运费计算) */ export function mapLoadProductsToPlanFormRows( loadProducts: PlanStopProductFormRow[] ): PlanFormProductRow[] { return loadProducts.map((row) => ({ id: row.planProductId, productId: row.productId, productNo: row.productNo, productName: row.productName, standardProductName: row.standardProductName || row.productName, aliasName: row.aliasName, aliasOptions: row.aliasOptions ? [...row.aliasOptions] : undefined, brandMode: row.brandMode, brandId: row.brandId, brandName: row.brandName, brandOptions: row.brandOptions ? [...row.brandOptions] : undefined, model: row.model, unit: normalizeProductUnitValue(row.unit), quantity: Number(row.planQty ?? 0), actualQty: 0, packageSpec: row.packageSpec, deliveryMethod: row.deliveryMethod, remark: row.remark, isExplosive: row.isExplosive, isToxic: row.isToxic })) } /** * 更换装货客户会先清空装货产品。这段空窗不要抹掉已有计划产品, * 重新选回同一产品和包装时才能继承原 id。正式保存仍按当前装货产品镜像。 */ export function shouldRetainPlanProductsWhileLoadEmpty( loadProducts: Array, previous: Array ): boolean { return loadProducts.length === 0 && previous.length > 0 } /** 合并装货映射行与既有计划产品元数据(危化证、税额等) */ export function mergePlanProductsFromLoadMirror( fromLoad: PlanFormProductRow[], previous: PlanFormProductRow[] ): PlanFormProductRow[] { const used = new Set() return fromLoad.map((row) => { const prev = row.id ? previous.find((item) => !used.has(item) && item.id && String(item.id) === String(row.id)) : previous.find((item) => !used.has(item) && String(item.productId) === String(row.productId) && normalizePackageSpec(item.packageSpec) === normalizePackageSpec(row.packageSpec)) if (!prev) { // Historical mirrors could collapse different package rows onto one plan identity. if (row.id && [...used].some((item) => String(item.id) === String(row.id))) { return { ...row, id: undefined } } return row } used.add(prev) return { ...row, id: prev.id || row.id, categoryName: row.categoryName || prev.categoryName, standardProductName: row.standardProductName || prev.standardProductName || row.productName, aliasOptions: row.aliasOptions?.length ? row.aliasOptions : prev.aliasOptions, brandOptions: row.brandOptions?.length ? row.brandOptions : prev.brandOptions, taxRate: prev.taxRate, taxPrice: prev.taxPrice ?? row.taxPrice, taxAmount: prev.taxAmount, taxSubtotal: prev.taxSubtotal, actualQty: prev.actualQty, purchaseCertUrl: prev.purchaseCertUrl, transportCertUrl: prev.transportCertUrl, legalUseCertUrl: prev.legalUseCertUrl } }) } type StopProductIdentity = { productId?: string productNo?: string productName?: string planQty?: number | string | null } function balanceProductKey(row: StopProductIdentity): string { const productId = String(row.productId || '').trim() if (productId) return `id:${productId}` const productNo = String(row.productNo || '').trim() if (productNo) return `no:${productNo}` const productName = String(row.productName || '').trim() if (productName) return `name:${productName}` return '' } function balanceProductLabel(row: StopProductIdentity): string { return String(row.productName || row.productNo || row.productId || '').trim() || '未命名产品' } function orderedStopProductLabels(rows: StopProductIdentity[] | undefined) { const labels = new Map() const order: string[] = [] for (const row of rows || []) { const key = balanceProductKey(row) if (!key || labels.has(key)) continue labels.set(key, balanceProductLabel(row)) order.push(key) } return { labels, order } } /** 装货段与全部送货段的产品种类必须一致。空字符串表示一致。 */ export function describeLoadDeliveryProductGap( loadProducts: StopProductIdentity[] | undefined, deliveries: Array<{ products?: StopProductIdentity[] }> | undefined ): string { const load = orderedStopProductLabels(loadProducts) const delivery = orderedStopProductLabels((deliveries || []).flatMap((stop) => stop.products || [])) const onlyLoad = load.order.filter((key) => !delivery.labels.has(key)).map((key) => load.labels.get(key) || '') const onlyDelivery = delivery.order .filter((key) => !load.labels.has(key)) .map((key) => delivery.labels.get(key) || '') const parts: string[] = [] if (onlyLoad.length) parts.push(`装货段有产品 ${onlyLoad.join('、')},送货段没有`) if (onlyDelivery.length) parts.push(`送货段有产品 ${onlyDelivery.join('、')},装货段没有`) if (!parts.length) return '' return `${parts.join(';')}。两边产品必须一致,不能保存` } export function buildStopProductPayload(row: PlanStopProductFormRow) { return { id: row.id || undefined, planProductId: row.planProductId || undefined, productId: row.productId, productNo: row.productNo, productName: row.productName, model: row.model, unit: normalizeProductUnitValue(row.unit), packageSpec: row.packageSpec, deliveryMethod: row.deliveryMethod, contractId: row.contractId || undefined, contractProductId: row.contractProductId || undefined, planQty: row.planQty ?? 0, remark: row.remark } } export function buildLoadPayload(load: PlanLoadForm) { return { ...(load.id ? { id: load.id } : {}), customerId: load.customerId, customerName: load.customerName, address: load.address || undefined, lng: load.lng, lat: load.lat, loadTime: load.loadTime || undefined, contact: load.contact || undefined, notes: load.notes || undefined, generateDibang: load.generateDibang, dibangDirection: load.dibangDirection, sameCity: load.sameCity === 1 ? 1 : 0, contracts: load.contracts, products: load.products.map(buildStopProductPayload) } } export function buildDeliveriesPayload(deliveries: PlanDeliveryForm[]) { return deliveries.map((row, index) => ({ ...(row.id ? { id: row.id } : {}), seq: row.seq || index + 1, customerId: row.customerId, customerName: row.customerName, address: row.address || undefined, lng: row.lng, lat: row.lat, deliveryTime: row.deliveryTime || undefined, contact: row.contact || undefined, notes: row.notes || undefined, generateDibang: row.generateDibang, dibangDirection: row.dibangDirection, sameCity: row.sameCity === 1 ? 1 : 0, contracts: row.contracts, products: row.products.map(buildStopProductPayload) })) }