feat(pl): hard-block over-limit contract qty on arrange and remind after success

Validate arranged+this plan qty against fixed contract product quantities on
transport arrange; return post-success remaining/framework cumulative reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-30 16:47:21 +08:00
1 parent d326996976
commit 3011cb9dae
10 files changed
+1424 -8

No files matched your search

+21
View File
@@ -629,6 +629,8 @@ export interface PlanQuery {
certUploadStatus?: string
/** 0=未确认齐备 1=已确认齐备 */
hazardCertReady?: number
/** CRM 合同 ID:仅返回关联该合同的计划 */
contractId?: string
page?: number
pageSize?: number
}
@@ -1099,6 +1101,25 @@ export interface TransportArrangeResult extends Transport {
siteRecords?: TransportSiteLink[]
inventoryRecords?: TransportInventoryLink[]
ioOrder?: TransportIoOrderBrief
contractQtyReminder?: ContractQtyReminder
}
export interface ContractQtyReminderItem {
contractId: string
contractNo?: string
contractType?: string
stopKind: string
productId: string
productName?: string
mode: 'remaining' | 'framework_cumulative' | string
contractQty?: number
arrangedTotalQty: number
remainingQty?: number
thisPlanQty: number
}
export interface ContractQtyReminder {
items: ContractQtyReminderItem[]
}
export interface PlIoOrderItem {
@@ -251,6 +251,7 @@
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
v-if="showSaveButton"
:type="showSaveAndArrangeButton ? 'default' : 'primary'"
:class="showSaveAndArrangeButton ? 'btn-ghost' : undefined"
:loading="submitLoading"
@@ -481,6 +482,8 @@
const loadedPlanSnapshot = ref<Plan | null>(null)
const selectedDriverId = ref('')
/** 已安排:禁止仅「保存」,须走保存并安排/调整运输,避免计划与地磅等联动字段脱节 */
const showSaveButton = computed(() => loadedTransportStatus.value !== 'arranged')
const showSaveAndArrangeButton = computed(() => {
if (!isEdit.value) return true
if (props.openForArrange && loadedTransportStatus.value === 'arranged') return false
@@ -1438,7 +1441,11 @@
getUnitLabel
})
await submitPlanArrange(planId, payload)
try {
await submitPlanArrange(planId, payload)
} catch {
return false
}
ElMessage.success(isRearrange ? '运输调整成功' : '运输安排成功')
innerVisible.value = false
emit('success', { planId, arranged: true })
@@ -10,6 +10,10 @@ import {
import { pickBxData } from '@/utils/http/bx'
import { planRequiresSiteLink } from '@/utils/pl-internal-customer'
import type { PlanDeliveryForm, PlanLoadForm } from '@/utils/pl-plan-stop-form'
import {
showContractQtyReminderIfAny,
showContractQtyViolationIfMatch
} from '@/utils/pl-contract-qty-reminder'
export interface ArrangeHistoricalFlags {
hasHistoricalDibang: boolean
@@ -202,10 +206,16 @@ export async function submitPlanArrange(
planId: string,
payload: TransportArrange
): Promise<TransportArrangeResult> {
const res = await transportApi.arrange(planId, payload)
const result = pickBxData<TransportArrangeResult>(res)
if (!result) {
throw new Error('安排失败:未返回结果')
try {
const res = await transportApi.arrange(planId, payload)
const result = pickBxData<TransportArrangeResult>(res)
if (!result) {
throw new Error('安排失败:未返回结果')
}
await showContractQtyReminderIfAny(result.contractQtyReminder)
return result
} catch (error) {
await showContractQtyViolationIfMatch(error)
throw error
}
return result
}
+7 -1
View File
@@ -149,7 +149,13 @@ bxAxios.interceptors.response.use(
await userStore.logOut()
} else {
const detail = err.response?.data?.detail || err.response?.data?.msg
ElMessage.error(detail || err.message || '网络异常')
const message =
typeof detail === 'string'
? detail
: detail && typeof detail === 'object'
? detail.message || detail.msg || JSON.stringify(detail)
: detail
ElMessage.error(message || err.message || '网络异常')
}
return Promise.reject(err)
}
@@ -0,0 +1,108 @@
import { ElMessageBox } from 'element-plus'
import type { ContractQtyReminder, ContractQtyReminderItem } from '@/api/pl'
export const CONTRACT_QTY_VIOLATION_CODE = 'CONTRACT_QTY_OVERFLOW'
function formatQty(n: number | undefined | null): string {
if (n == null || Number.isNaN(Number(n))) return '—'
return Number(n).toFixed(3)
}
function stopKindLabel(kind?: string): string {
if (kind === 'delivery') return '送货'
if (kind === 'load') return '装货'
return kind || ''
}
export function extractContractQtyViolationDetail(error: unknown): {
message: string
items: Array<Record<string, unknown>>
} | null {
const err = error as {
response?: { status?: number; data?: { detail?: unknown } }
}
const detail = err?.response?.data?.detail
if (!detail || typeof detail !== 'object') return null
const d = detail as { code?: string; message?: string; items?: unknown }
if (d.code !== CONTRACT_QTY_VIOLATION_CODE) return null
const items = Array.isArray(d.items) ? (d.items as Array<Record<string, unknown>>) : []
return {
message: d.message || '合同产品安排数量超过合同数量',
items
}
}
export function formatContractQtyViolationMessage(
items: Array<Record<string, unknown>>
): string {
if (!items.length) return '合同产品安排数量超过合同数量,请调整后再试。'
const lines = items.map((it) => {
const name = String(it.productName || it.productId || '产品')
const contractNo = String(it.contractNo || it.contractId || '')
const side = stopKindLabel(String(it.stopKind || ''))
const contractQty = formatQty(Number(it.contractQty))
const effective = formatQty(Number(it.effectiveTotal ?? it.arrangedTotalQty))
const overflow = formatQty(Number(it.overflowQty))
const meta = [contractNo, side].filter(Boolean).join(' · ')
return `「${name}」安排后合计 ${effective},超过合同数量 ${contractQty}(超 ${overflow})${meta ? `;${meta}` : ''}`
})
return `以下产品「已安排合计 + 本次安排」超过合同数量,请调整后再试:\n\n${lines.join('\n')}`
}
export function formatContractQtyReminderMessage(reminder: ContractQtyReminder): string {
const items = reminder?.items || []
if (!items.length) return ''
const remaining = items.filter((i) => i.mode === 'remaining')
const framework = items.filter((i) => i.mode === 'framework_cumulative')
const blocks: string[] = []
if (remaining.length) {
const lines = remaining.map((it: ContractQtyReminderItem) => {
const name = it.productName || it.productId
const meta = [it.contractNo, stopKindLabel(it.stopKind)].filter(Boolean).join(' · ')
return `「${name}」已安排合计(含本次)${formatQty(it.arrangedTotalQty)},剩余可安排 ${formatQty(it.remainingQty)}${meta ? `;${meta}` : ''}`
})
blocks.push(`合同数量校验通过。各产品剩余可安排量:\n${lines.join('\n')}`)
}
if (framework.length) {
const lines = framework.map((it: ContractQtyReminderItem) => {
const name = it.productName || it.productId
const meta = [it.contractNo, stopKindLabel(it.stopKind)].filter(Boolean).join(' · ')
return `「${name}」累计已安排 ${formatQty(it.arrangedTotalQty)}(本次 ${formatQty(it.thisPlanQty)})${meta ? `;${meta}` : ''}`
})
blocks.push(
`关联框架合同无固定数量上限。本次安排后累计已安排:\n${lines.join('\n')}`
)
}
return blocks.join('\n\n')
}
/** @returns true 表示已按合同超量处理(调用方勿再抛出/重复提示) */
export async function showContractQtyViolationIfMatch(error: unknown): Promise<boolean> {
const parsed = extractContractQtyViolationDetail(error)
if (!parsed) return false
await ElMessageBox.alert(formatContractQtyViolationMessage(parsed.items), '无法安排运输', {
type: 'error',
confirmButtonText: '知道了'
})
return true
}
export async function showContractQtyReminderIfAny(
reminder?: ContractQtyReminder | null
): Promise<void> {
const items = reminder?.items || []
if (!items.length) return
const message = formatContractQtyReminderMessage(reminder!)
if (!message) return
const hasFramework = items.some((i) => i.mode === 'framework_cumulative')
const title = hasFramework && !items.some((i) => i.mode === 'remaining')
? '安排成功 · 框架合同累计'
: '安排成功 · 合同剩余数量'
await ElMessageBox.alert(message, title, {
type: 'success',
confirmButtonText: '知道了'
})
}