同市易制毒段只校验购买证;同步 Web/H5 表单、额度统计展示与相关回归测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
981 lines
33 KiB
TypeScript
981 lines
33 KiB
TypeScript
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)
|
||
}))
|
||
}
|