Files
shanxia2.0/web-admin/src/utils/pl-plan-stop-form.ts
T
wuzilongandCursor 522b4be705 feat(pl): 装送货段增加同市标记,并同步额度证件与计划表单。
同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:34:58 +08:00

981 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>()
const missingPurchase = new Map<string, string>()
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<number>
): 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<number>()
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<number>
): 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<number>()
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<unknown>,
previous: Array<unknown>
): boolean {
return loadProducts.length === 0 && previous.length > 0
}
/** 合并装货映射行与既有计划产品元数据(危化证、税额等) */
export function mergePlanProductsFromLoadMirror(
fromLoad: PlanFormProductRow[],
previous: PlanFormProductRow[]
): PlanFormProductRow[] {
const used = new Set<PlanFormProductRow>()
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<string, string>()
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)
}))
}