feat(web-admin): plan form package UI keyed by product and package spec
Contract-bound stop products use package options from the contract, arrange-qty cache/API include packageSpec, and save validates required package selections. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ded6aeaa85
commit
39161e43e4
9 files changed
+342
-37
No files matched your search
@@ -901,6 +901,7 @@ export interface ContractProductArrangeQtyDetail {
|
||||
contractNo?: string | null
|
||||
customerName?: string | null
|
||||
productId: string
|
||||
packageSpec?: string | null
|
||||
productName?: string | null
|
||||
hasFixedQty: boolean
|
||||
contractQty?: number | null
|
||||
@@ -923,6 +924,7 @@ export const planApi = {
|
||||
getContractProductArrangeQty: (params: {
|
||||
contractId: string
|
||||
productId: string
|
||||
packageSpec?: string
|
||||
excludePlanId?: string
|
||||
}) =>
|
||||
request.get<ContractProductArrangeQtyDetail>(
|
||||
@@ -1165,6 +1167,7 @@ export interface ContractQtyReminderItem {
|
||||
customerName?: string
|
||||
stopKind: string
|
||||
productId: string
|
||||
packageSpec?: string | null
|
||||
productName?: string
|
||||
model?: string
|
||||
unit?: string
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
contractId?: string
|
||||
productId?: string
|
||||
productName?: string
|
||||
packageSpec?: string
|
||||
excludePlanId?: string
|
||||
}>()
|
||||
|
||||
@@ -439,6 +440,7 @@
|
||||
const res = await planApi.getContractProductArrangeQty({
|
||||
contractId,
|
||||
productId,
|
||||
packageSpec: props.packageSpec || undefined,
|
||||
excludePlanId: props.excludePlanId || undefined
|
||||
})
|
||||
const data = pickBxData<ContractProductArrangeQtyDetail>(res) || null
|
||||
@@ -470,7 +472,14 @@
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.contractId, props.productId, props.excludePlanId] as const,
|
||||
() =>
|
||||
[
|
||||
props.modelValue,
|
||||
props.contractId,
|
||||
props.productId,
|
||||
props.packageSpec,
|
||||
props.excludePlanId
|
||||
] as const,
|
||||
([visible]) => {
|
||||
if (visible) void loadDetail()
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
:allow-remove="!readonly"
|
||||
:contract-id="deliveryContractId(delivery)"
|
||||
:exclude-plan-id="excludePlanId"
|
||||
:contract-product-rows="contractProductRowsFor(delivery)"
|
||||
@remove="(productIndex) => removeStopProduct(index, productIndex)"
|
||||
/>
|
||||
</article>
|
||||
@@ -245,7 +246,10 @@
|
||||
type PlanDeliveryForm,
|
||||
type PlanStopProductFormRow
|
||||
} from '@/utils/pl-plan-stop-form'
|
||||
import { findDeliveryProductsMissingFromLoad } from '@/utils/pl-plan-contract-product'
|
||||
import {
|
||||
findDeliveryProductsMissingFromLoad,
|
||||
type ContractProductRowLite
|
||||
} from '@/utils/pl-plan-contract-product'
|
||||
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
|
||||
import { pickBxData } from '@/utils/http/bx'
|
||||
|
||||
@@ -263,6 +267,8 @@
|
||||
loadProducts?: PlanStopProductFormRow[]
|
||||
/** 当前计划车牌(主牌),用于地磅可选车例外 */
|
||||
vehiclePlate?: string
|
||||
/** 合同 id → 产品行摘要(包装下拉) */
|
||||
contractProductRowsByContract?: Record<string, ContractProductRowLite[]>
|
||||
}>(),
|
||||
{
|
||||
tabMode: true,
|
||||
@@ -272,7 +278,8 @@
|
||||
showSplitHint: false,
|
||||
excludePlanId: '',
|
||||
loadProducts: () => [],
|
||||
vehiclePlate: ''
|
||||
vehiclePlate: '',
|
||||
contractProductRowsByContract: () => ({})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -297,6 +304,11 @@
|
||||
return String(delivery.contracts?.[0]?.contractId || '').trim()
|
||||
}
|
||||
|
||||
function contractProductRowsFor(delivery: PlanDeliveryForm): ContractProductRowLite[] {
|
||||
const cid = deliveryContractId(delivery)
|
||||
return cid ? props.contractProductRowsByContract?.[cid] || [] : []
|
||||
}
|
||||
|
||||
function selectProductsDisabled(delivery: PlanDeliveryForm) {
|
||||
if (isPlInternalCustomerName(delivery.customerName)) return false
|
||||
return !deliveryContractId(delivery)
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
compact-products
|
||||
:exclude-plan-id="planId || ''"
|
||||
:vehicle-plate="currentVehiclePlate"
|
||||
:contract-product-rows="loadContractProductRows"
|
||||
@open-customer-picker="openCustomerSelector('load')"
|
||||
@add-products="openProductSelector('load')"
|
||||
/>
|
||||
@@ -235,6 +236,7 @@
|
||||
:exclude-plan-id="planId || ''"
|
||||
:load-products="loadForm.products"
|
||||
:vehicle-plate="currentVehiclePlate"
|
||||
:contract-product-rows-by-contract="contractProductRowsCache"
|
||||
@active-index-change="deliveryActiveIndex = $event"
|
||||
@open-customer-picker="openCustomerSelector('delivery', $event)"
|
||||
@sync-products="syncDeliveryProductsFromLoadManual"
|
||||
@@ -383,7 +385,8 @@
|
||||
sumStopProductsPlanQty,
|
||||
syncDeliveriesProductsFromLoad,
|
||||
type PlanDeliveryForm,
|
||||
type PlanLoadForm
|
||||
type PlanLoadForm,
|
||||
type PlanStopProductFormRow
|
||||
} from '@/utils/pl-plan-stop-form'
|
||||
import { findFirstStopProductArrangeGap } from '@/utils/pl-plan-stop-product-arrange-required'
|
||||
import {
|
||||
@@ -418,7 +421,12 @@
|
||||
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
|
||||
import {
|
||||
collectContractProductIds,
|
||||
filterProductsByAllowedProductIds
|
||||
collectContractProductRows,
|
||||
filterProductsByAllowedProductIds,
|
||||
isPackageAllowedForContractProduct,
|
||||
normalizePackageSpec,
|
||||
packageSpecsForProduct,
|
||||
type ContractProductRowLite
|
||||
} from '@/utils/pl-plan-contract-product'
|
||||
import { getPersonnelAffiliationLabel } from '@/utils/pl-personnel-cert'
|
||||
|
||||
@@ -555,6 +563,8 @@
|
||||
let loadContractFetchSeq = 0
|
||||
/** 合同 id → 明细 productId(装/送货共用缓存) */
|
||||
const contractProductIdsCache = ref<Record<string, string[]>>({})
|
||||
/** 合同 id → 明细行摘要(含包装,供段产品表下拉与保存校验) */
|
||||
const contractProductRowsCache = ref<Record<string, ContractProductRowLite[]>>({})
|
||||
/** 送货段合同明细拉取序号(按段) */
|
||||
const deliveryContractFetchSeqByIndex: Record<number, number> = {}
|
||||
/**
|
||||
@@ -573,6 +583,11 @@
|
||||
return loadContractProductIds.value
|
||||
})
|
||||
|
||||
const loadContractProductRows = computed((): ContractProductRowLite[] => {
|
||||
const cid = String(loadForm.value.contracts?.[0]?.contractId || '').trim()
|
||||
return cid ? contractProductRowsCache.value[cid] || [] : []
|
||||
})
|
||||
|
||||
function openProductSelector(side: 'load' | 'delivery', deliveryIndex = 0) {
|
||||
productSelectorTarget.value = { side, deliveryIndex }
|
||||
productSelectorVisible.value = true
|
||||
@@ -586,12 +601,15 @@
|
||||
}
|
||||
try {
|
||||
const res = await getContract(id)
|
||||
const detail = pickBxData<{ products?: Array<{ productId?: string | null }> }>(res)
|
||||
const detail = pickBxData<{ products?: ContractProductRowLite[] }>(res)
|
||||
const productRows = collectContractProductRows(detail?.products || [])
|
||||
const allowedIds = collectContractProductIds(detail?.products || [])
|
||||
contractProductIdsCache.value = { ...contractProductIdsCache.value, [id]: allowedIds }
|
||||
contractProductRowsCache.value = { ...contractProductRowsCache.value, [id]: productRows }
|
||||
return allowedIds
|
||||
} catch {
|
||||
contractProductIdsCache.value = { ...contractProductIdsCache.value, [id]: [] }
|
||||
contractProductRowsCache.value = { ...contractProductRowsCache.value, [id]: [] }
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1317,12 +1335,25 @@
|
||||
|
||||
async function handleProductConfirm(products: any[]) {
|
||||
const target = productSelectorTarget.value
|
||||
const resolveDefaultPackageSpec = (productId: string) => {
|
||||
let rows: ContractProductRowLite[] = []
|
||||
if (target.side === 'delivery') {
|
||||
const delivery = deliveryForms.value[target.deliveryIndex]
|
||||
const cid = String(delivery?.contracts?.[0]?.contractId || '').trim()
|
||||
rows = cid ? contractProductRowsCache.value[cid] || [] : []
|
||||
} else {
|
||||
rows = loadContractProductRows.value
|
||||
}
|
||||
const specs = packageSpecsForProduct(rows, productId)
|
||||
return specs.length === 1 ? specs[0] : ''
|
||||
}
|
||||
const buildRows = async () => {
|
||||
const rows: ReturnType<typeof createStopProductsFromPlanProducts> = []
|
||||
for (const product of products) {
|
||||
const brandMeta = initLineBrandMetaFromProduct(product as Record<string, unknown>)
|
||||
const productId = String(product.id || '')
|
||||
const row: PlanFormProductRow = {
|
||||
productId: String(product.id || ''),
|
||||
productId,
|
||||
productNo: String(product.productNo || product.id || ''),
|
||||
productName: String(product.productName || ''),
|
||||
model: product.model as string | undefined,
|
||||
@@ -1330,7 +1361,7 @@
|
||||
unit: String(product.unit || '吨'),
|
||||
quantity: 1,
|
||||
actualQty: 0,
|
||||
packageSpec: '',
|
||||
packageSpec: resolveDefaultPackageSpec(productId),
|
||||
deliveryMethod: '',
|
||||
isToxic: Number(product.isToxic || 0),
|
||||
isExplosive: Number(product.isExplosive || 0),
|
||||
@@ -2009,6 +2040,59 @@
|
||||
return true
|
||||
}
|
||||
|
||||
/** 绑合同时:合同产品有包装选项则包装必填且须在选项内 */
|
||||
function validateContractPackageSpecs(): boolean {
|
||||
const checkProducts = (
|
||||
products: PlanStopProductFormRow[] | undefined,
|
||||
contractId: string,
|
||||
stopLabel: string
|
||||
): boolean => {
|
||||
const cid = String(contractId || '').trim()
|
||||
if (!cid) return true
|
||||
const contractRows = contractProductRowsCache.value[cid] || []
|
||||
for (const product of products || []) {
|
||||
const pid = String(product.productId || '').trim()
|
||||
if (!pid) continue
|
||||
const allowed = packageSpecsForProduct(contractRows, pid)
|
||||
if (!allowed.length) continue
|
||||
const pkg = normalizePackageSpec(product.packageSpec)
|
||||
const name = String(product.productName || product.standardProductName || pid).trim() || pid
|
||||
if (!pkg) {
|
||||
ElMessage.error(`请选择包装规格:${stopLabel}${name}`)
|
||||
return false
|
||||
}
|
||||
if (!isPackageAllowedForContractProduct(contractRows, pid, pkg)) {
|
||||
ElMessage.error(`包装规格不在合同范围内:${stopLabel}${name}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
!checkProducts(
|
||||
loadForm.value.products,
|
||||
String(loadForm.value.contracts?.[0]?.contractId || ''),
|
||||
'装货 '
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < deliveryForms.value.length; i += 1) {
|
||||
const row = deliveryForms.value[i]
|
||||
if (
|
||||
!checkProducts(
|
||||
row.products,
|
||||
String(row.contracts?.[0]?.contractId || ''),
|
||||
`送货段#${i + 1} `
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 非内部客户(山峡/云栋)的装/送货段必须至少选一份合同(保存与安排均校验) */
|
||||
function validateStopContractsRequired() {
|
||||
if (
|
||||
@@ -2106,6 +2190,7 @@
|
||||
const valid = await basicFormRef.value?.validate().catch(() => false)
|
||||
if (!valid || !validateBusinessRules()) return
|
||||
if (!validateSingleContractPerStop()) return
|
||||
if (!validateContractPackageSpecs()) return
|
||||
if (!validateStopContractsRequired()) return
|
||||
|
||||
if (mode === 'saveAndArrange' || mode === 'rearrange') {
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
:compact="compactProducts"
|
||||
:contract-id="stopContractId"
|
||||
:exclude-plan-id="excludePlanId"
|
||||
:contract-product-rows="contractProductRows"
|
||||
@remove="removeStopProduct"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,6 +166,7 @@
|
||||
import PlPlanContractMultiSelect from '@/components/business/pl-plan-contract-multi-select/index.vue'
|
||||
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
|
||||
import { resolveAddressNotesFill } from '@/utils/pl-address-notes-fill'
|
||||
import type { ContractProductRowLite } from '@/utils/pl-plan-contract-product'
|
||||
import {
|
||||
canManualToggleStopDibang,
|
||||
resolveStopDibangDefaults,
|
||||
@@ -182,12 +184,15 @@
|
||||
excludePlanId?: string
|
||||
/** 当前计划车牌(主牌),用于地磅可选车例外 */
|
||||
vehiclePlate?: string
|
||||
/** 装货合同产品行摘要(包装下拉) */
|
||||
contractProductRows?: ContractProductRowLite[]
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
compactProducts: true,
|
||||
excludePlanId: '',
|
||||
vehiclePlate: ''
|
||||
vehiclePlate: '',
|
||||
contractProductRows: () => []
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -45,15 +45,32 @@
|
||||
<span class="pl-stop-product-dual-item__label"
|
||||
>包装规格<span class="pl-stop-field-required" aria-hidden="true">*</span></span
|
||||
>
|
||||
<el-select
|
||||
v-if="sharedEditable && useContractPackageSelect(row)"
|
||||
v-model="row.packageSpec"
|
||||
class="pl-plan-product-cell-control pl-stop-product-dual-dict"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="包装"
|
||||
@change="() => onPackageChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in packageOptionsFor(row)"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
<DictSelect
|
||||
v-if="sharedEditable"
|
||||
v-else-if="sharedEditable"
|
||||
v-model="row.packageSpec"
|
||||
class="pl-plan-product-cell-control pl-stop-product-dual-dict"
|
||||
dict-type="crm_product_gg"
|
||||
placeholder="包装"
|
||||
@change="() => onPackageChange(row)"
|
||||
/>
|
||||
<span v-else class="pl-stop-product-dual-item__readonly">{{
|
||||
resolveDictLabel('crm_product_gg', row.packageSpec)
|
||||
resolvePackageDisplay(row)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="pl-stop-product-dual-item__field">
|
||||
@@ -295,17 +312,39 @@
|
||||
<span>包装规格<span class="pl-stop-field-required" aria-hidden="true">*</span></span>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="sharedEditable && useContractPackageSelect(row)"
|
||||
v-model="row.packageSpec"
|
||||
class="pl-plan-product-cell-control"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择包装规格"
|
||||
style="width: 100%"
|
||||
@change="() => onPackageChange(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in packageOptionsFor(row)"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
<DictSelect
|
||||
v-if="sharedEditable"
|
||||
v-else-if="sharedEditable"
|
||||
v-model="row.packageSpec"
|
||||
class="pl-plan-product-cell-control"
|
||||
dict-type="crm_product_gg"
|
||||
placeholder="请选择包装规格"
|
||||
style="width: 100%"
|
||||
@change="() => onPackageChange(row)"
|
||||
/>
|
||||
<template v-else>
|
||||
<DictTag v-if="row.packageSpec" dict-type="crm_product_gg" :value="row.packageSpec" />
|
||||
<span v-else>—</span>
|
||||
<DictTag
|
||||
v-if="row.packageSpec && !useContractPackageSelect(row)"
|
||||
dict-type="crm_product_gg"
|
||||
:value="row.packageSpec"
|
||||
/>
|
||||
<span v-else>{{ resolvePackageDisplay(row) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -453,6 +492,7 @@
|
||||
:contract-id="arrangeQtyContractId"
|
||||
:product-id="arrangeQtyProductId"
|
||||
:product-name="arrangeQtyProductName"
|
||||
:package-spec="arrangeQtyPackageSpec"
|
||||
:exclude-plan-id="excludePlanId"
|
||||
/>
|
||||
</div>
|
||||
@@ -467,6 +507,11 @@
|
||||
resolveStopActualQty,
|
||||
resolveStopUnitPrice
|
||||
} from '@/utils/pl-plan-stop-form'
|
||||
import {
|
||||
normalizePackageSpec,
|
||||
packageSpecsForProduct,
|
||||
type ContractProductRowLite
|
||||
} from '@/utils/pl-plan-contract-product'
|
||||
import { getDeliveryMethodLabel } from '@/composables/usePlContractToPlan'
|
||||
import {
|
||||
applyBrandSelectChange,
|
||||
@@ -502,6 +547,8 @@
|
||||
contractId?: string
|
||||
/** 当前编辑计划 id,计算可安排时排除 */
|
||||
excludePlanId?: string
|
||||
/** 本合同产品行摘要(有包装选项时包装列用合同下拉) */
|
||||
contractProductRows?: ContractProductRowLite[]
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
@@ -511,7 +558,8 @@
|
||||
showStopArchivePrice: false,
|
||||
stopArchiveMode: 'load',
|
||||
contractId: '',
|
||||
excludePlanId: ''
|
||||
excludePlanId: '',
|
||||
contractProductRows: () => []
|
||||
}
|
||||
)
|
||||
|
||||
@@ -540,21 +588,44 @@
|
||||
const arrangeQtyContractId = ref('')
|
||||
const arrangeQtyProductId = ref('')
|
||||
const arrangeQtyProductName = ref('')
|
||||
const arrangeQtyPackageSpec = ref('')
|
||||
|
||||
function cacheKey(productId: string) {
|
||||
return `${props.contractId || ''}::${productId}::${props.excludePlanId || ''}`
|
||||
function packageOptionsFor(row: PlanStopProductFormRow) {
|
||||
return packageSpecsForProduct(props.contractProductRows || [], String(row.productId || ''))
|
||||
}
|
||||
|
||||
async function ensureArrangeQty(productId: string) {
|
||||
function useContractPackageSelect(row: PlanStopProductFormRow) {
|
||||
return Boolean(String(props.contractId || '').trim() && packageOptionsFor(row).length > 0)
|
||||
}
|
||||
|
||||
function resolvePackageDisplay(row: PlanStopProductFormRow) {
|
||||
const raw = normalizePackageSpec(row.packageSpec)
|
||||
if (!raw) return '—'
|
||||
if (useContractPackageSelect(row)) return raw
|
||||
return resolveDictLabel('crm_product_gg', raw)
|
||||
}
|
||||
|
||||
function requiresPackageBeforeArrange(row: PlanStopProductFormRow) {
|
||||
return useContractPackageSelect(row) && !normalizePackageSpec(row.packageSpec)
|
||||
}
|
||||
|
||||
function cacheKey(productId: string, packageSpec?: string | null) {
|
||||
return `${props.contractId || ''}::${productId}::${normalizePackageSpec(packageSpec)}::${props.excludePlanId || ''}`
|
||||
}
|
||||
|
||||
async function ensureArrangeQty(row: PlanStopProductFormRow) {
|
||||
const cid = String(props.contractId || '').trim()
|
||||
const pid = String(productId || '').trim()
|
||||
const pid = String(row.productId || '').trim()
|
||||
if (!cid || !pid) return null
|
||||
const key = cacheKey(pid)
|
||||
if (requiresPackageBeforeArrange(row)) return null
|
||||
const pkg = normalizePackageSpec(row.packageSpec)
|
||||
const key = cacheKey(pid, pkg)
|
||||
if (key in arrangeQtyCache.value) return arrangeQtyCache.value[key]
|
||||
try {
|
||||
const res = await planApi.getContractProductArrangeQty({
|
||||
contractId: cid,
|
||||
productId: pid,
|
||||
packageSpec: pkg || undefined,
|
||||
excludePlanId: props.excludePlanId || undefined
|
||||
})
|
||||
const data = pickBxData<ContractProductArrangeQtyDetail>(res) || null
|
||||
@@ -566,12 +637,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onPackageChange(row: PlanStopProductFormRow) {
|
||||
const pid = String(row.productId || '').trim()
|
||||
if (!pid) return
|
||||
const prefix = `${props.contractId || ''}::${pid}::`
|
||||
const next: Record<string, ContractProductArrangeQtyDetail | null> = {}
|
||||
for (const [key, value] of Object.entries(arrangeQtyCache.value)) {
|
||||
if (!key.startsWith(prefix)) next[key] = value
|
||||
}
|
||||
arrangeQtyCache.value = next
|
||||
void ensureArrangeQty(row)
|
||||
}
|
||||
|
||||
function formatAvailableDisplay(row: PlanStopProductFormRow) {
|
||||
const cid = String(props.contractId || '').trim()
|
||||
if (!cid) return '—'
|
||||
const pid = String(row.productId || '').trim()
|
||||
if (!pid) return '—'
|
||||
const data = arrangeQtyCache.value[cacheKey(pid)]
|
||||
if (requiresPackageBeforeArrange(row)) return '请先选包装'
|
||||
const data = arrangeQtyCache.value[cacheKey(pid, row.packageSpec)]
|
||||
if (!data) return '…'
|
||||
if (!data.hasFixedQty) return '—'
|
||||
if (data.availableQty == null) return '—'
|
||||
@@ -581,8 +665,8 @@
|
||||
function isPlanQtyOverAvailable(row: PlanStopProductFormRow) {
|
||||
const cid = String(props.contractId || '').trim()
|
||||
const pid = String(row.productId || '').trim()
|
||||
if (!cid || !pid) return false
|
||||
const data = arrangeQtyCache.value[cacheKey(pid)]
|
||||
if (!cid || !pid || requiresPackageBeforeArrange(row)) return false
|
||||
const data = arrangeQtyCache.value[cacheKey(pid, row.packageSpec)]
|
||||
if (!data?.hasFixedQty || data.availableQty == null) return false
|
||||
const planQty = Number(row.planQty)
|
||||
if (!Number.isFinite(planQty)) return false
|
||||
@@ -590,7 +674,11 @@
|
||||
}
|
||||
|
||||
function canOpenArrangeQty(row: PlanStopProductFormRow) {
|
||||
return Boolean(String(props.contractId || '').trim() && String(row.productId || '').trim())
|
||||
return Boolean(
|
||||
String(props.contractId || '').trim() &&
|
||||
String(row.productId || '').trim() &&
|
||||
!requiresPackageBeforeArrange(row)
|
||||
)
|
||||
}
|
||||
|
||||
function openArrangeQtyDialog(row: PlanStopProductFormRow) {
|
||||
@@ -599,9 +687,14 @@
|
||||
return
|
||||
}
|
||||
if (!row.productId) return
|
||||
if (requiresPackageBeforeArrange(row)) {
|
||||
ElMessage.warning('请先选择包装规格')
|
||||
return
|
||||
}
|
||||
arrangeQtyContractId.value = String(props.contractId)
|
||||
arrangeQtyProductId.value = String(row.productId)
|
||||
arrangeQtyProductName.value = String(row.productName || row.standardProductName || '')
|
||||
arrangeQtyPackageSpec.value = normalizePackageSpec(row.packageSpec)
|
||||
arrangeQtyDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -611,7 +704,9 @@
|
||||
props.editable,
|
||||
props.contractId,
|
||||
props.excludePlanId,
|
||||
(props.products || []).map((p) => p.productId).join(',')
|
||||
(props.products || [])
|
||||
.map((p) => `${p.productId}:${normalizePackageSpec(p.packageSpec)}`)
|
||||
.join(',')
|
||||
] as const,
|
||||
() => {
|
||||
if (!props.editable) return
|
||||
@@ -619,7 +714,7 @@
|
||||
if (!cid) return
|
||||
for (const row of props.products || []) {
|
||||
const pid = String(row.productId || '').trim()
|
||||
if (pid) void ensureArrangeQty(pid)
|
||||
if (pid) void ensureArrangeQty(row)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ContractQtyDialogRow = {
|
||||
customerName?: string
|
||||
stopKind?: string
|
||||
productId?: string
|
||||
packageSpec?: string
|
||||
productName?: string
|
||||
model?: string
|
||||
unit?: string
|
||||
@@ -86,6 +87,7 @@ function normalizeDialogRows(
|
||||
customerName: it.customerName != null ? String(it.customerName) : '',
|
||||
stopKind: it.stopKind != null ? String(it.stopKind) : '',
|
||||
productId: it.productId != null ? String(it.productId) : '',
|
||||
packageSpec: it.packageSpec != null ? String(it.packageSpec).trim() : '',
|
||||
productName: it.productName != null ? String(it.productName) : '',
|
||||
model: it.model != null ? String(it.model) : '',
|
||||
unit: it.unit != null ? String(it.unit) : '',
|
||||
@@ -141,7 +143,10 @@ export function buildContractQtyDialogHtml(
|
||||
const title = groupTitle(list[0])
|
||||
const body = list
|
||||
.map((row) => {
|
||||
const name = escapeHtml(row.productName || row.productId || '—')
|
||||
const baseName = row.productName || row.productId || '—'
|
||||
const name = escapeHtml(
|
||||
row.packageSpec ? `${baseName}(${row.packageSpec})` : baseName
|
||||
)
|
||||
const model = escapeHtml(row.model || '—')
|
||||
const unit = escapeHtml(resolveUnitLabel(row.unit))
|
||||
const contractQty = row.framework
|
||||
|
||||
@@ -13,6 +13,72 @@ export function collectContractProductIds(
|
||||
return ids
|
||||
}
|
||||
|
||||
export type ContractProductRowLite = {
|
||||
productId?: string | null
|
||||
packageSpec?: string | null
|
||||
productName?: string | null
|
||||
productNo?: string | null
|
||||
quantity?: number | null
|
||||
quantityMode?: string | null
|
||||
}
|
||||
|
||||
export function normalizePackageSpec(value?: string | null): string {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
/** 稳定复合键:productId + '\\0' + 归一化包装 */
|
||||
export function productPackageKey(
|
||||
productId?: string | null,
|
||||
packageSpec?: string | null
|
||||
): string {
|
||||
const pid = String(productId || '').trim()
|
||||
if (!pid) return ''
|
||||
return `${pid}\0${normalizePackageSpec(packageSpec)}`
|
||||
}
|
||||
|
||||
export function collectContractProductRows(
|
||||
products?: ContractProductRowLite[] | null
|
||||
): ContractProductRowLite[] {
|
||||
return (products || [])
|
||||
.map((row) => ({
|
||||
productId: String(row?.productId || '').trim(),
|
||||
packageSpec: normalizePackageSpec(row?.packageSpec),
|
||||
productName: row?.productName,
|
||||
productNo: row?.productNo,
|
||||
quantity: row?.quantity,
|
||||
quantityMode: row?.quantityMode
|
||||
}))
|
||||
.filter((row) => row.productId)
|
||||
}
|
||||
|
||||
/** 本合同该产品的包装列表(去重保序,仅非空) */
|
||||
export function packageSpecsForProduct(
|
||||
rows: ContractProductRowLite[] | null | undefined,
|
||||
productId: string
|
||||
): string[] {
|
||||
const pid = String(productId || '').trim()
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const row of rows || []) {
|
||||
if (String(row.productId || '').trim() !== pid) continue
|
||||
const pkg = normalizePackageSpec(row.packageSpec)
|
||||
if (!pkg || seen.has(pkg)) continue
|
||||
seen.add(pkg)
|
||||
out.push(pkg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function isPackageAllowedForContractProduct(
|
||||
rows: ContractProductRowLite[] | null | undefined,
|
||||
productId: string,
|
||||
packageSpec?: string | null
|
||||
): boolean {
|
||||
const allowed = packageSpecsForProduct(rows, productId)
|
||||
if (!allowed.length) return true
|
||||
return allowed.includes(normalizePackageSpec(packageSpec))
|
||||
}
|
||||
|
||||
/** 保留 productId 落在 allowed 内的装货行;allowed 为空则全部移除(换合同且新合同无产品) */
|
||||
export function filterProductsByAllowedProductIds<T extends { productId?: string | null }>(
|
||||
products: T[],
|
||||
@@ -27,23 +93,36 @@ export function filterProductsByAllowedProductIds<T extends { productId?: string
|
||||
|
||||
/**
|
||||
* 找出不在装货产品列表中的送货产品(仅提醒,不拦截)。
|
||||
* 返回展示名列表(去重)。
|
||||
* 按 productId + packageSpec 比对;返回展示名列表(去重)。
|
||||
*/
|
||||
export function findDeliveryProductsMissingFromLoad(
|
||||
deliveryProducts: Array<{ productId?: string | null; productName?: string | null }> | null | undefined,
|
||||
loadProducts: Array<{ productId?: string | null }> | null | undefined
|
||||
deliveryProducts:
|
||||
| Array<{
|
||||
productId?: string | null
|
||||
productName?: string | null
|
||||
packageSpec?: string | null
|
||||
}>
|
||||
| null
|
||||
| undefined,
|
||||
loadProducts:
|
||||
| Array<{ productId?: string | null; packageSpec?: string | null }>
|
||||
| null
|
||||
| undefined
|
||||
): string[] {
|
||||
const loadIds = new Set(
|
||||
(loadProducts || []).map((row) => String(row?.productId || '').trim()).filter(Boolean)
|
||||
const loadKeys = new Set(
|
||||
(loadProducts || [])
|
||||
.map((row) => productPackageKey(row?.productId, row?.packageSpec))
|
||||
.filter(Boolean)
|
||||
)
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const row of deliveryProducts || []) {
|
||||
const pid = String(row?.productId || '').trim()
|
||||
if (!pid || loadIds.has(pid) || seen.has(pid)) continue
|
||||
seen.add(pid)
|
||||
const name = String(row?.productName || '').trim() || pid
|
||||
names.push(name)
|
||||
const key = productPackageKey(row?.productId, row?.packageSpec)
|
||||
if (!key || loadKeys.has(key) || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const name = String(row?.productName || '').trim() || String(row?.productId || '').trim()
|
||||
const pkg = normalizePackageSpec(row?.packageSpec)
|
||||
names.push(pkg ? `${name}(${pkg})` : name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isPlDibangOptionalVehiclePlate,
|
||||
isPlInternalCustomerName
|
||||
} from '@/utils/pl-internal-customer'
|
||||
import { normalizePackageSpec } from '@/utils/pl-plan-contract-product'
|
||||
|
||||
export interface PlanStopProductFormRow {
|
||||
id?: string
|
||||
@@ -214,6 +215,17 @@ function matchPrevStopProduct(
|
||||
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)
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user