This commit is contained in:
zlwu committed 2026-08-31 08:28:36 +08:00
1 parent 19c5aaa8f5
commit 69fa5b1fa8
38 files changed
+15226 -11921

No files matched your search

@@ -180,20 +180,33 @@
<div class="pl-plan-stop-products-head">
<span class="pl-plan-stop-products-head__title">送货产品</span>
<el-button
v-if="!readonly"
class="btn-ghost"
size="small"
@click="emit('sync-products', index)"
>
<el-icon><Refresh /></el-icon>从装货同步
</el-button>
<div v-if="!readonly" class="pl-plan-stop-products-head__actions">
<el-button class="btn-ghost" size="small" @click="emit('sync-products', index)">
<el-icon><Refresh /></el-icon>从装货同步
</el-button>
<span class="pl-plan-stop-products-head__add-wrap" @click="handleSelectProductsClick(index)">
<el-button
class="btn-main"
type="primary"
size="small"
:disabled="selectProductsDisabled(delivery)"
>
<el-icon><Plus /></el-icon>选择产品
</el-button>
</span>
</div>
</div>
<p v-if="showSplitHint" class="pl-plan-delivery-section__split-hint">
多个送货段,请拆分各段数量
</p>
<p
v-if="missingFromLoadHint(delivery)"
class="pl-plan-delivery-section__missing-load-hint"
>
{{ missingFromLoadHint(delivery) }}
</p>
<p v-if="!delivery.products.length" class="pl-plan-delivery-section__empty-hint">
请先在装货区添加产品,将自动同步到送货段
可从装货同步,或点击「选择产品」从合同中添加
</p>
<PlPlanStopProductTable
:products="delivery.products"
@@ -210,7 +223,7 @@
</template>
<script setup lang="ts">
import { Bottom, Delete, Refresh, Search, Top } from '@element-plus/icons-vue'
import { Bottom, Delete, Plus, Refresh, Search, Top } from '@element-plus/icons-vue'
import { fetchCustomerOptions } from '@/api/crm/customer'
import { customerStopPrefApi, type CustomerStopPref } from '@/api/pl/index'
import PlAddressWithLibrary, {
@@ -222,8 +235,10 @@
createEmptyDeliveryForm,
resolveStopDibangDefaults,
sumStopProductsPlanQty,
type PlanDeliveryForm
type PlanDeliveryForm,
type PlanStopProductFormRow
} from '@/utils/pl-plan-stop-form'
import { findDeliveryProductsMissingFromLoad } from '@/utils/pl-plan-contract-product'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import { pickBxData } from '@/utils/http/bx'
@@ -237,6 +252,8 @@
/** 多送货段时提示拆分数量 */
showSplitHint?: boolean
excludePlanId?: string
/** 装货段产品,用于「送货产品不在装货列表」软提醒 */
loadProducts?: PlanStopProductFormRow[]
}>(),
{
tabMode: true,
@@ -244,7 +261,8 @@
compactProducts: true,
qtyUnitLabel: '',
showSplitHint: false,
excludePlanId: ''
excludePlanId: '',
loadProducts: () => []
}
)
@@ -252,6 +270,7 @@
'update:modelValue': [value: PlanDeliveryForm[]]
'open-customer-picker': [index: number]
'sync-products': [index: number]
'select-products': [index: number]
'add-delivery': []
'active-index-change': [index: number]
}>()
@@ -267,6 +286,27 @@
function deliveryContractId(delivery: PlanDeliveryForm) {
return String(delivery.contracts?.[0]?.contractId || '').trim()
}
function selectProductsDisabled(delivery: PlanDeliveryForm) {
if (isPlInternalCustomerName(delivery.customerName)) return false
return !deliveryContractId(delivery)
}
function handleSelectProductsClick(index: number) {
const delivery = props.modelValue[index]
if (!delivery) return
if (selectProductsDisabled(delivery)) {
ElMessage.warning('请先选择送货合同后再选择产品')
return
}
emit('select-products', index)
}
function missingFromLoadHint(delivery: PlanDeliveryForm) {
const names = findDeliveryProductsMissingFromLoad(delivery.products, props.loadProducts)
if (!names.length) return ''
return `注意:产品「${names.join('、')}」不在装货产品列表中,请核对(不影响保存/安排)`
}
const customerOptionsByIndex = ref<Record<number, CustomerOptionRow[]>>({})
const customerOptionsLoading = ref<Record<number, boolean>>({})
@@ -656,12 +696,29 @@
color: #64748b;
}
.pl-plan-stop-products-head__actions {
display: flex;
align-items: center;
gap: 8px;
}
.pl-plan-stop-products-head__add-wrap {
display: inline-flex;
}
.pl-plan-delivery-section__split-hint {
margin: 6px 0 0;
font-size: 12px;
color: #d97706;
}
.pl-plan-delivery-section__missing-load-hint {
margin: 6px 0 0;
font-size: 12px;
font-weight: 700;
color: #dc2626;
}
.pl-plan-delivery-section__empty-hint {
margin: 6px 0 0;
font-size: 12px;
@@ -193,7 +193,7 @@
compact-products
:exclude-plan-id="planId || ''"
@open-customer-picker="openCustomerSelector('load')"
@add-products="productSelectorVisible = true"
@add-products="openProductSelector('load')"
/>
</template>
<template #delivery-actions>
@@ -232,9 +232,11 @@
:qty-unit-label="planQtyUnitLabel"
:show-split-hint="showDeliverySplitHint"
:exclude-plan-id="planId || ''"
:load-products="loadForm.products"
@active-index-change="deliveryActiveIndex = $event"
@open-customer-picker="openCustomerSelector('delivery', $event)"
@sync-products="syncDeliveryProductsFromLoadManual"
@select-products="openProductSelector('delivery', $event)"
/>
</template>
</PlPlanStopsSplitShell>
@@ -517,18 +519,71 @@
const customerOptions = ref<CustomerOptionRow[]>([])
const customerOptionsLoading = ref(false)
const productSelectorVisible = ref(false)
/** 产品选择器目标:装货 或 某一送货段 */
const productSelectorTarget = ref<{ side: 'load' | 'delivery'; deliveryIndex: number }>({
side: 'load',
deliveryIndex: 0
})
/** 当前装货合同明细中的 CRM productId(非内部客户限制选品) */
const loadContractProductIds = ref<string[]>([])
/** 装货合同明细拉取序号,避免快速换合同时旧响应覆盖新结果 */
let loadContractFetchSeq = 0
/** 合同 id → 明细 productId(装/送货共用缓存) */
const contractProductIdsCache = ref<Record<string, string[]>>({})
/** 送货段合同明细拉取序号(按段) */
const deliveryContractFetchSeqByIndex: Record<number, number> = {}
/**
* 内部客户:undefined → ProductSelector 全量目录
* 非内部:始终传 string[](合同无明细时为 [])→ 受限模式,勿与「未传」混淆
*/
const productSelectorAllowedIds = computed((): string[] | undefined => {
if (productSelectorTarget.value.side === 'delivery') {
const delivery = deliveryForms.value[productSelectorTarget.value.deliveryIndex]
if (!delivery) return []
if (isPlInternalCustomerName(delivery.customerName)) return undefined
const cid = String(delivery.contracts?.[0]?.contractId || '').trim()
return cid ? contractProductIdsCache.value[cid] || [] : []
}
if (isPlInternalCustomerName(loadForm.value.customerName)) return undefined
return loadContractProductIds.value
})
function openProductSelector(side: 'load' | 'delivery', deliveryIndex = 0) {
productSelectorTarget.value = { side, deliveryIndex }
productSelectorVisible.value = true
}
async function fetchContractProductIds(contractId: string): Promise<string[]> {
const id = String(contractId || '').trim()
if (!id) return []
if (Object.prototype.hasOwnProperty.call(contractProductIdsCache.value, id)) {
return contractProductIdsCache.value[id] || []
}
try {
const res = await getContract(id)
const detail = pickBxData<{ products?: Array<{ productId?: string | null }> }>(res)
const allowedIds = collectContractProductIds(detail?.products || [])
contractProductIdsCache.value = { ...contractProductIdsCache.value, [id]: allowedIds }
return allowedIds
} catch {
contractProductIdsCache.value = { ...contractProductIdsCache.value, [id]: [] }
return []
}
}
function filterDeliveryProductsByContract(delivery: PlanDeliveryForm): PlanDeliveryForm {
if (isPlInternalCustomerName(delivery.customerName)) return delivery
const cid = String(delivery.contracts?.[0]?.contractId || '').trim()
if (!cid) return delivery
const allowed = contractProductIdsCache.value[cid]
// 明细尚未拉到时先不刷,避免误清空;拉完后 watch 会再滤
if (allowed === undefined) return delivery
return {
...delivery,
products: filterProductsByAllowedProductIds(delivery.products || [], allowed)
}
}
const vehicleViewVisible = ref(false)
const vehicleLibraryVisible = ref(false)
const carrierLibraryVisible = ref(false)
@@ -635,32 +690,42 @@
)
}
function applyLoadProductsToDeliveries(options?: {
async function ensureDeliveryContractProductCaches() {
const tasks = deliveryForms.value.map(async (row) => {
if (isPlInternalCustomerName(row.customerName)) return
const cid = String(row.contracts?.[0]?.contractId || '').trim()
if (cid) await fetchContractProductIds(cid)
})
await Promise.all(tasks)
}
async function applyLoadProductsToDeliveries(options?: {
showHint?: boolean
forceQtyFromLoad?: boolean
}) {
await ensureDeliveryContractProductCaches()
const result = syncDeliveriesProductsFromLoad(
deliveryForms.value,
loadForm.value.products || [],
{ forceQtyFromLoad: options?.forceQtyFromLoad }
)
deliveryForms.value = result.deliveries
deliveryForms.value = result.deliveries.map((row) => filterDeliveryProductsByContract(row))
if (options?.showHint && result.needSplitHint) {
ElMessage.info('多个送货段,请拆分各段数量')
}
}
/** 装货产品变更后:镜像计划层产品 + 同步送货段(C1) */
function syncFromLoadProducts(options?: { showHint?: boolean }) {
async function syncFromLoadProducts(options?: { showHint?: boolean }) {
if (suppressStopProductSync.value || detailLoading.value) return
mirrorPlanProductsFromLoad()
applyLoadProductsToDeliveries(options)
await applyLoadProductsToDeliveries(options)
}
function handleAddDelivery() {
deliverySectionRef.value?.addDelivery()
nextTick(() => {
applyLoadProductsToDeliveries({ showHint: true })
void applyLoadProductsToDeliveries({ showHint: true })
})
}
@@ -671,7 +736,7 @@
function handleRemoveDelivery() {
deliverySectionRef.value?.removeDelivery(deliveryActiveIndex.value)
nextTick(() => {
applyLoadProductsToDeliveries()
void applyLoadProductsToDeliveries()
})
}
const hasFreightRef = computed(() => formData.hasFreight)
@@ -993,7 +1058,7 @@
}
/** 详情加载后:无装货产品时用计划产品回填;再按 C1 同步送货 */
function bootstrapStopsFromPlanProducts() {
async function bootstrapStopsFromPlanProducts() {
if (!loadForm.value.products.length && formData.products.length) {
loadForm.value = {
...loadForm.value,
@@ -1003,11 +1068,11 @@
enrichStopFormsDisplayFromPlanProducts()
}
mirrorPlanProductsFromLoad()
applyLoadProductsToDeliveries()
await applyLoadProductsToDeliveries()
}
function syncDeliveryProductsFromLoadManual() {
applyLoadProductsToDeliveries({ forceQtyFromLoad: deliveryForms.value.length < 2 })
async function syncDeliveryProductsFromLoadManual() {
await applyLoadProductsToDeliveries({ forceQtyFromLoad: deliveryForms.value.length < 2 })
ElMessage.success('已从装货产品同步')
}
@@ -1030,16 +1095,8 @@
return
}
const seq = ++loadContractFetchSeq
let allowedIds: string[] = []
try {
const res = await getContract(id)
if (seq !== loadContractFetchSeq) return
const detail = pickBxData<{ products?: Array<{ productId?: string | null }> }>(res)
allowedIds = collectContractProductIds(detail?.products || [])
} catch {
if (seq !== loadContractFetchSeq) return
allowedIds = []
}
const allowedIds = await fetchContractProductIds(id)
if (seq !== loadContractFetchSeq) return
loadContractProductIds.value = allowedIds
// 合同 id 变化时过滤装货产品(详情首次灌入同 id / 加载中不刷)
if (
@@ -1059,6 +1116,40 @@
{ deep: true }
)
watch(
() =>
deliveryForms.value.map((row) => ({
customerName: row.customerName,
contractId: String(row.contracts?.[0]?.contractId || '').trim()
})),
async (curr, prev) => {
if (detailLoading.value || suppressStopProductSync.value) return
for (let i = 0; i < curr.length; i += 1) {
const id = curr[i]?.contractId || ''
const prevId = prev?.[i]?.contractId || ''
if (!id) continue
const seq = (deliveryContractFetchSeqByIndex[i] || 0) + 1
deliveryContractFetchSeqByIndex[i] = seq
const allowedIds = await fetchContractProductIds(id)
if (deliveryContractFetchSeqByIndex[i] !== seq) continue
const delivery = deliveryForms.value[i]
if (!delivery || isPlInternalCustomerName(delivery.customerName)) continue
const contractChanged = !prev || id !== prevId
const hasOrphan = (delivery.products || []).some((row) => {
const pid = String(row.productId || '').trim()
return !!pid && !allowedIds.includes(pid)
})
// 换合同必滤;同合同仅在有合同外产品时滤(如同步后明细刚拉到)
if (!contractChanged && !hasOrphan) continue
const nextProducts = filterProductsByAllowedProductIds(delivery.products || [], allowedIds)
deliveryForms.value = deliveryForms.value.map((row, idx) =>
idx === i ? { ...row, products: nextProducts } : row
)
}
},
{ deep: true }
)
function openCustomerSelector(side: CustomerSide, deliveryIndex = 0) {
customerSelectorSide.value = side
customerSelectorDeliveryIndex.value = deliveryIndex
@@ -1170,29 +1261,58 @@
)
async function handleProductConfirm(products: any[]) {
const nextRows = [...(loadForm.value.products || [])]
for (const product of products) {
const brandMeta = initLineBrandMetaFromProduct(product as Record<string, unknown>)
const row: PlanFormProductRow = {
productId: String(product.id || ''),
productNo: String(product.productNo || product.id || ''),
productName: String(product.productName || ''),
model: product.model as string | undefined,
categoryName: product.categoryName as string | undefined,
unit: String(product.unit || '吨'),
quantity: 1,
actualQty: 0,
packageSpec: '',
deliveryMethod: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
...brandMeta
const target = productSelectorTarget.value
const buildRows = async () => {
const rows: ReturnType<typeof createStopProductsFromPlanProducts> = []
for (const product of products) {
const brandMeta = initLineBrandMetaFromProduct(product as Record<string, unknown>)
const row: PlanFormProductRow = {
productId: String(product.id || ''),
productNo: String(product.productNo || product.id || ''),
productName: String(product.productName || ''),
model: product.model as string | undefined,
categoryName: product.categoryName as string | undefined,
unit: String(product.unit || '吨'),
quantity: 1,
actualQty: 0,
packageSpec: '',
deliveryMethod: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
...brandMeta
}
const [enriched] = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm([row])
)
rows.push(...createStopProductsFromPlanProducts([enriched]))
}
const [enriched] = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm([row])
)
nextRows.push(...createStopProductsFromPlanProducts([enriched]))
return rows
}
if (target.side === 'delivery') {
const index = target.deliveryIndex
const delivery = deliveryForms.value[index]
if (!delivery) return
const existingIds = new Set(
(delivery.products || []).map((row) => String(row.productId || '').trim()).filter(Boolean)
)
const added = (await buildRows()).filter((row) => {
const pid = String(row.productId || '').trim()
if (!pid || existingIds.has(pid)) return false
existingIds.add(pid)
return true
})
if (!added.length) {
ElMessage.info('所选产品已在送货段中')
return
}
deliveryForms.value = deliveryForms.value.map((row, idx) =>
idx === index ? { ...row, products: [...(row.products || []), ...added] } : row
)
return
}
const nextRows = [...(loadForm.value.products || []), ...(await buildRows())]
loadForm.value = { ...loadForm.value, products: nextRows }
freightCalc.resetFreightCalc()
}
@@ -1738,7 +1858,7 @@
return (contracts || []).some((item) => !!String(item.contractId || '').trim())
}
/** 非内部客户:装/送货段合同至多 1 份;已选 1 份时校验装货产品归属合同 */
/** 非内部客户:装/送货段合同至多 1 份;已选 1 份时校验装/送货产品归属合同 */
function validateSingleContractPerStop(): boolean {
if (
!isPlInternalCustomerName(loadForm.value.customerName) &&
@@ -1775,6 +1895,27 @@
}
}
}
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) continue
const products = row.products || []
if (!products.length) continue
const cid = String(row.contracts?.[0]?.contractId || '').trim()
const allowedList = cid ? contractProductIdsCache.value[cid] : undefined
const allowed = new Set((allowedList || []).map((id) => String(id).trim()).filter(Boolean))
if (!allowed.size) {
ElMessage.warning(
`送货段#${i + 1}所选合同暂无产品明细,或合同明细加载失败,请重选合同后再保存`
)
return false
}
const orphan = products.some((p) => !allowed.has(String(p.productId || '').trim()))
if (orphan) {
ElMessage.warning(`送货段#${i + 1}产品须属于所选合同`)
return false
}
}
return true
}
@@ -3,7 +3,7 @@
// 菜单样式变量
$menu-height: 40px;
$menu-icon-size: 16px;
$menu-font-size: 13px;
$menu-font-size: 15px;
$hover-bg-color: var(--mint-row-alt, #f8fafc);
$popup-menu-height: 40px;
$popup-menu-padding: 8px;
@@ -200,7 +200,7 @@ $popup-menu-radius: 6px;
}
.el-sub-menu > .el-sub-menu__title {
font-size: 12px !important;
font-size: 14px !important;
font-weight: 700;
color: var(--mint-text-muted, #64748b) !important;
letter-spacing: 0.02em;