feat(web-admin): gate load products by selected contract

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-30 11:02:58 +08:00
1 parent bbab39e6b7
commit 9b8848ea8c
2 files changed
+159 -14

No files matched your search

@@ -284,6 +284,7 @@
/>
<ProductSelector
:visible="productSelectorVisible"
:allowed-product-ids="productSelectorAllowedIds"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductConfirm"
/>
@@ -340,6 +341,7 @@
usePlRemoteVehicleOptions
} from '@/composables/usePlRemoteOptions'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { getContract } from '@/api/crm/contract/index'
import CustomerSelector from '@/components/CustomerSelector.vue'
import ProductSelector from '@/components/business/product-selector/index.vue'
import PlPlanLoadSection from '@/components/business/pl-plan-load-section/index.vue'
@@ -396,6 +398,11 @@
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/format'
import { pickBxData } from '@/utils/http/bx'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import {
collectContractProductIds,
filterProductsByAllowedProductIds
} from '@/utils/pl-plan-contract-product'
import { getPersonnelAffiliationLabel } from '@/utils/pl-personnel-cert'
const props = defineProps<{
@@ -504,6 +511,12 @@
const customerOptions = ref<CustomerOptionRow[]>([])
const customerOptionsLoading = ref(false)
const productSelectorVisible = ref(false)
/** 当前装货合同明细中的 CRM productId(非内部客户限制选品) */
const loadContractProductIds = ref<string[]>([])
const productSelectorAllowedIds = computed(() => {
if (isPlInternalCustomerName(loadForm.value.customerName)) return undefined
return loadContractProductIds.value
})
const vehicleViewVisible = ref(false)
const vehicleLibraryVisible = ref(false)
const carrierLibraryVisible = ref(false)
@@ -994,6 +1007,40 @@
{ deep: true }
)
watch(
() => loadForm.value.contracts,
async (contracts, prev) => {
const id = String(contracts?.[0]?.contractId || '').trim()
const prevId = String(prev?.[0]?.contractId || '').trim()
if (!id) {
loadContractProductIds.value = []
return
}
try {
const res = await getContract(id)
const detail = pickBxData<{ products?: Array<{ productId?: string | null }> }>(res)
loadContractProductIds.value = collectContractProductIds(detail?.products || [])
} catch {
loadContractProductIds.value = []
}
// 合同 id 变化时过滤装货产品(详情首次灌入同 id / 加载中不刷)
if (
prev &&
id !== prevId &&
!detailLoading.value &&
!suppressStopProductSync.value
) {
const nextProducts = filterProductsByAllowedProductIds(
loadForm.value.products || [],
loadContractProductIds.value
)
loadForm.value = { ...loadForm.value, products: nextProducts }
syncFromLoadProducts()
}
},
{ deep: true }
)
function openCustomerSelector(side: CustomerSide, deliveryIndex = 0) {
customerSelectorSide.value = side
customerSelectorDeliveryIndex.value = deliveryIndex
@@ -1665,6 +1712,64 @@
return true
}
function stopHasSelectedContract(contracts?: { contractId?: string }[] | null) {
return (contracts || []).some((item) => !!String(item.contractId || '').trim())
}
/** 非内部客户:装/送货段合同至多 1 份;已选 1 份时校验装货产品归属合同 */
function validateSingleContractPerStop(): boolean {
if (
!isPlInternalCustomerName(loadForm.value.customerName) &&
(loadForm.value.contracts || []).length > 1
) {
ElMessage.warning('装货合同只能保留 1 份,请移除多余合同后再保存')
return false
}
for (let i = 0; i < deliveryForms.value.length; i += 1) {
const row = deliveryForms.value[i]
if (isPlInternalCustomerName(row.customerName)) continue
if ((row.contracts || []).length > 1) {
ElMessage.warning(`送货段#${i + 1}合同只能保留 1 份,请移除多余合同后再保存`)
return false
}
}
if (
!isPlInternalCustomerName(loadForm.value.customerName) &&
(loadForm.value.contracts || []).length === 1 &&
loadContractProductIds.value.length
) {
const allowed = new Set(loadContractProductIds.value.map((id) => String(id).trim()))
const orphan = (loadForm.value.products || []).some(
(row) => !allowed.has(String(row.productId || '').trim())
)
if (orphan) {
ElMessage.warning('装货产品须属于所选合同')
return false
}
}
return true
}
/** 安排运输前:非内部客户(山峡/云栋)的装/送货段必须至少选一份合同 */
function validateContractsForArrange() {
if (
!isPlInternalCustomerName(loadForm.value.customerName) &&
!stopHasSelectedContract(loadForm.value.contracts)
) {
ElMessage.warning('安排运输前请为装货客户选择合同')
return false
}
for (let i = 0; i < deliveryForms.value.length; i += 1) {
const row = deliveryForms.value[i]
if (isPlInternalCustomerName(row.customerName)) continue
if (!stopHasSelectedContract(row.contracts)) {
ElMessage.warning(`安排运输前请为送货段#${i + 1}选择合同`)
return false
}
}
return true
}
/** 安排运输前必填:创建日期、计划日期、归属公司、单据类型、跟进人 */
function getArrangePrerequisiteGaps(): string[] {
const missing: string[] = []
@@ -1688,6 +1793,7 @@
try {
const valid = await basicFormRef.value?.validate().catch(() => false)
if (!valid || !validateBusinessRules()) return
if (!validateSingleContractPerStop()) return
if (mode === 'saveAndArrange' || mode === 'rearrange') {
const missing = getArrangePrerequisiteGaps()
@@ -1699,6 +1805,7 @@
ElMessage.warning('安排运输前请选择车辆')
return
}
if (!validateContractsForArrange()) return
}
mirrorPlanProductsFromLoad()
@@ -4,8 +4,8 @@
<PlPlanContractMultiSelect
v-model="contractsModel"
:customer-id="modelValue.customerId"
:disabled="!modelValue.customerId || contractDisabled"
:required="false"
:disabled="!modelValue.customerId || isInternal"
:required="!!modelValue.customerId && !isInternal"
compact-summary
>
<template #toolbar="{ open, disabled, hint, hintWarn }">
@@ -47,7 +47,11 @@
</el-button>
</div>
</el-form-item>
<el-form-item label="装货合同" class="pl-plan-party-select-row__contract">
<el-form-item
v-if="!isInternal"
label="装货合同"
class="pl-plan-party-select-row__contract"
>
<div class="pl-plan-contract-trigger">
<el-button class="btn-ghost" :disabled="disabled" @click="open">
<el-icon><Search /></el-icon>选择合同
@@ -104,27 +108,35 @@
<div class="pl-plan-dibang-row">
<el-checkbox
:model-value="modelValue.generateDibang === 1"
:disabled="isInternal"
disabled
@update:model-value="handleDibangToggle"
>
生成地磅单({{ dibangDirectionLabel }})
</el-checkbox>
<span v-if="isInternal" class="pl-plan-dibang-row__hint">本段客户为内部客户时默认勾选</span>
<span v-else-if="modelValue.customerId" class="pl-plan-dibang-row__hint">
仅安庆山峡/安徽云栋可生成地磅单
</span>
</div>
</el-form-item>
</div>
<div class="pl-plan-stop-products-head">
<span class="pl-plan-stop-products-head__title">装货产品</span>
<el-button
<span
v-if="!readonly"
class="btn-main"
type="primary"
size="small"
@click="emit('add-products')"
class="pl-plan-stop-products-head__add-wrap"
@click="handleAddProductsClick"
>
<el-icon><Plus /></el-icon>添加产品
</el-button>
<el-button
class="btn-main"
type="primary"
size="small"
:disabled="addProductsDisabled"
>
<el-icon><Plus /></el-icon>添加产品
</el-button>
</span>
</div>
<p v-if="!modelValue.products.length" class="pl-plan-load-section__empty-hint">
请添加至少一条装货产品
@@ -180,7 +192,20 @@
const customerOptionsLoading = ref(false)
const isInternal = computed(() => isPlInternalCustomerName(props.modelValue.customerName))
const contractDisabled = computed(() => isInternal.value)
/** 非内部客户须先选装货合同才能添加产品;内部客户不限制 */
const addProductsDisabled = computed(() => {
if (isInternal.value) return false
return !(props.modelValue.contracts || []).some((c) => String(c.contractId || '').trim())
})
function handleAddProductsClick() {
if (addProductsDisabled.value) {
ElMessage.warning('请先选择装货合同后再添加产品')
return
}
emit('add-products')
}
const dibangDirectionLabel = computed(() => {
if (props.modelValue.dibangDirection === 'outbound') return '出库'
@@ -363,10 +388,14 @@
}
function handleDibangToggle(checked: boolean) {
const direction = isInternal.value ? 'outbound' : props.modelValue.dibangDirection || 'none'
// 仅山峡/云栋可生成地磅单,且由客户类型自动决定,不允许手动勾选
if (!isInternal.value) {
patch({ generateDibang: 0, dibangDirection: 'none' })
return
}
patch({
generateDibang: checked ? 1 : 0,
dibangDirection: checked ? direction : 'none'
dibangDirection: checked ? 'outbound' : 'none'
})
}
@@ -400,6 +429,15 @@
color: #64748b;
}
.pl-plan-stop-products-head__add-wrap {
display: inline-flex;
cursor: pointer;
:deep(.el-button.is-disabled) {
pointer-events: none;
}
}
.pl-plan-load-section__empty-hint {
margin: 8px 0 0;
font-size: 12px;