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:
wuzilongandCursor committed 2026-09-10 18:06:52 +08:00
1 parent ded6aeaa85
commit 39161e43e4
9 files changed
+342 -37

No files matched your search

@@ -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 }