This commit is contained in:
zlwu committed 2026-09-03 13:50:25 +08:00
1 parent 67d37a1f0e
commit 54a1ba0c5c
21 files changed
+8646 -7577

No files matched your search

@@ -205,11 +205,25 @@
:bundle="vehicleBundle"
/>
<PlPlanFormDialog
v-model:visible="planEditFormVisible"
:plan-id="planId"
plan-edit-only
@success="handlePlanEditSuccess"
/>
<template #footer>
<div class="dialog-footer pl-arrange-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
type="danger"
:disabled="loading || !planId || plan.followStatus === 'cancelled'"
@click="handleOpenPlanEdit"
>
<el-icon><Edit /></el-icon>编辑运输计划
</el-button>
<el-button
v-if="!isAdjustMode"
:loading="draftLoading"
@@ -226,7 +240,7 @@
</template>
<script setup lang="ts">
import { Check, Close, Document } from '@element-plus/icons-vue'
import { Check, Close, Document, Edit } from '@element-plus/icons-vue'
import { fetchProductDetail, type ProductDetail } from '@/api/crm/product'
import {
inboundOrderApi,
@@ -244,6 +258,7 @@
import PlPlanArchiveSegmentBlock, {
type ArchiveSegmentMeta
} from '@/components/business/pl-plan-archive-segment-block/index.vue'
import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
import PlPlanStopsSplitShell from '@/components/business/pl-plan-stops-split-shell/index.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
@@ -292,6 +307,7 @@
const activeTab = ref<'archive' | 'overview'>('archive')
const tabsRef = ref<InstanceType<typeof PlPlanViewTabs> | null>(null)
const vehicleViewVisible = ref(false)
const planEditFormVisible = ref(false)
const plan = ref<Partial<Plan>>({})
const {
@@ -1193,7 +1209,7 @@
}
}
async function handleOpened() {
async function reloadArchiveContent() {
if (!props.planId) return
loading.value = true
resetState()
@@ -1239,14 +1255,44 @@
await applyFreightPrefill(data)
initFeedbackAttachmentEdits()
} catch (error) {
innerVisible.value = false
const message = error instanceof Error ? error.message : '加载运输计划失败'
ElMessage.error(message.includes('加载') ? message : `加载运输计划失败:${message}`)
throw error
} finally {
loading.value = false
}
}
function handleOpenPlanEdit() {
if (!props.planId || plan.value.followStatus === 'cancelled') return
ElMessage.info({
message: '修改计划信息后,归档填写区将自动刷新,请核对装卸货数量与品牌归档。',
duration: 4000
})
planEditFormVisible.value = true
}
async function handlePlanEditSuccess() {
planEditFormVisible.value = false
activeTab.value = 'archive'
try {
await reloadArchiveContent()
tabsRef.value?.reload()
emit('success')
} catch {
// reloadArchiveContent 已提示错误
}
}
async function handleOpened() {
if (!props.planId) return
try {
await reloadArchiveContent()
} catch {
innerVisible.value = false
}
}
function handleClose() {
// destroy-on-close 由 el-dialog 处理
}
@@ -1450,7 +1496,7 @@
.pl-archive-tabs {
:deep(> .el-tabs__header) {
margin-bottom: 16px;
margin-bottom: 0;
}
/* 仅用顶部导航,内容区由下方面板接管 */
@@ -1461,6 +1507,7 @@
.pl-archive-tab-panel {
min-height: 360px;
margin-top: 20px;
}
.pl-arrange-form-scroll {
@@ -160,13 +160,19 @@
<div class="pl-plan-dibang-row">
<el-checkbox
:model-value="delivery.generateDibang === 1"
disabled
:disabled="readonly || !canManualDibang(delivery.customerName)"
@update:model-value="(checked) => handleDibangToggle(index, checked)"
>
生成地磅单({{ directionLabel(delivery.dibangDirection) }})
</el-checkbox>
<span
v-if="isPlInternalCustomerName(delivery.customerName)"
v-if="canManualDibang(delivery.customerName)"
class="pl-plan-dibang-row__hint"
>
该车辆地磅单可选,请自行勾选
</span>
<span
v-else-if="isPlInternalCustomerName(delivery.customerName)"
class="pl-plan-dibang-row__hint"
>
本段客户为内部客户时默认勾选
@@ -232,6 +238,7 @@
import PlPlanStopProductTable from '@/components/business/pl-plan-stop-product-table/index.vue'
import { resolveAddressNotesFill } from '@/utils/pl-address-notes-fill'
import {
canManualToggleStopDibang,
createEmptyDeliveryForm,
resolveStopDibangDefaults,
sumStopProductsPlanQty,
@@ -254,6 +261,8 @@
excludePlanId?: string
/** 装货段产品,用于「送货产品不在装货列表」软提醒 */
loadProducts?: PlanStopProductFormRow[]
/** 当前计划车牌(主牌),用于地磅可选车例外 */
vehiclePlate?: string
}>(),
{
tabMode: true,
@@ -262,7 +271,8 @@
qtyUnitLabel: '',
showSplitHint: false,
excludePlanId: '',
loadProducts: () => []
loadProducts: () => [],
vehiclePlate: ''
}
)
@@ -535,7 +545,7 @@
(item) => String(item.id) === String(customerId)
)
const customerName = customer?.customerName || ''
const defaults = resolveStopDibangDefaults(customerName, 'delivery')
const defaults = resolveStopDibangDefaults(customerName, 'delivery', props.vehiclePlate)
patchDelivery(index, {
customerId: String(customerId),
customerName,
@@ -573,11 +583,16 @@
}
}
function canManualDibang(customerName?: string | null) {
return canManualToggleStopDibang(customerName, props.vehiclePlate)
}
function handleDibangToggle(index: number, checked: boolean) {
const delivery = props.modelValue[index]
// 仅山峡/云栋可生成地磅单,且由客户类型自动决定,不允许手动勾选
if (!isPlInternalCustomerName(delivery?.customerName)) {
patchDelivery(index, { generateDibang: 0, dibangDirection: 'none' })
if (!canManualDibang(delivery?.customerName)) {
if (!isPlInternalCustomerName(delivery?.customerName)) {
patchDelivery(index, { generateDibang: 0, dibangDirection: 'none' })
}
return
}
patchDelivery(index, {
@@ -192,6 +192,7 @@
v-model="loadForm"
compact-products
:exclude-plan-id="planId || ''"
:vehicle-plate="currentVehiclePlate"
@open-customer-picker="openCustomerSelector('load')"
@add-products="openProductSelector('load')"
/>
@@ -233,6 +234,7 @@
:show-split-hint="showDeliverySplitHint"
:exclude-plan-id="planId || ''"
:load-products="loadForm.products"
:vehicle-plate="currentVehiclePlate"
@active-index-change="deliveryActiveIndex = $event"
@open-customer-picker="openCustomerSelector('delivery', $event)"
@sync-products="syncDeliveryProductsFromLoadManual"
@@ -377,6 +379,7 @@
enrichStopProductsDisplayFromPlan,
mapLoadProductsToPlanFormRows,
mergePlanProductsFromLoadMirror,
resolveStopDibangDefaults,
sumStopProductsPlanQty,
syncDeliveriesProductsFromLoad,
type PlanDeliveryForm,
@@ -426,6 +429,8 @@
copyFromPlanId?: string
/** 从列表/详情「安排运输」「调整运输」进入时展开安排区块 */
openForArrange?: boolean
/** 归档等场景:仅保存计划,不展示暂存/安排运输(已安排计划也显示「保存」) */
planEditOnly?: boolean
}>()
const emit = defineEmits<{
@@ -497,14 +502,19 @@
const selectedDriverId = ref('')
/** 已安排:禁止仅「保存」,须走保存并安排/调整运输,避免计划与地磅等联动字段脱节 */
const showSaveButton = computed(() => loadedTransportStatus.value !== 'arranged')
const showSaveButton = computed(() => {
if (props.planEditOnly && isEdit.value) return true
return loadedTransportStatus.value !== 'arranged'
})
const showDraftButton = computed(() => {
if (props.planEditOnly) return false
if (!isEdit.value) return true
return (
loadedFollowStatus.value === 'drafting' || loadedFollowStatus.value === 'pending_arrange'
)
})
const showSaveAndArrangeButton = computed(() => {
if (props.planEditOnly) return false
if (!isEdit.value) return true
if (props.openForArrange && loadedTransportStatus.value === 'arranged') return false
return loadedTransportStatus.value === 'pending' || loadedTransportStatus.value === 'arranged'
@@ -512,7 +522,7 @@
const isRearrangeMode = computed(
() => loadedTransportStatus.value === 'arranged' && Boolean(props.openForArrange)
)
const showRearrangeButton = computed(() => isRearrangeMode.value)
const showRearrangeButton = computed(() => !props.planEditOnly && isRearrangeMode.value)
const saveButtonLabel = computed(() => (isEdit.value ? '保存' : '创建'))
const saveAndArrangeButtonLabel = computed(() => {
if (!isEdit.value) return '创建并安排运输'
@@ -766,6 +776,35 @@
vehicleList.value.find((item) => item.id === formData.vehicleId)
)
const currentVehiclePlate = computed(() => {
const fromList = (selectedVehicle.value?.licensePlate || '').trim()
if (fromList) return fromList
return (vehicleBundle.value?.detail?.licensePlate || '').trim()
})
/** 按当前车牌重算各段地磅默认勾选(换车时调用;编辑回填勿调用以免覆盖已存值) */
function reapplyStopDibangByVehiclePlate(plate?: string | null) {
const vehiclePlate = (plate ?? currentVehiclePlate.value) || ''
const loadDefaults = resolveStopDibangDefaults(
loadForm.value.customerName,
'load',
vehiclePlate
)
loadForm.value = {
...loadForm.value,
generateDibang: loadDefaults.generateDibang,
dibangDirection: loadDefaults.dibangDirection
}
deliveryForms.value = deliveryForms.value.map((row) => {
const defaults = resolveStopDibangDefaults(row.customerName, 'delivery', vehiclePlate)
return {
...row,
generateDibang: defaults.generateDibang,
dibangDirection: defaults.dibangDirection
}
})
}
const summaryCarrierName = computed(() => {
const carrier = carrierList.value.find((item) => item.id === formData.carrierId)
if (carrier?.carrierName) return carrier.carrierName
@@ -1050,9 +1089,10 @@
function applyStopsFromPlan(data: Plan) {
stopFormsReady.value = false
const plate = data.licensePlate || ''
if (data.load || data.deliveries?.length) {
loadForm.value = mapLoadFromApi(data.load)
deliveryForms.value = mapDeliveriesFromApi(data.deliveries)
loadForm.value = mapLoadFromApi(data.load, plate)
deliveryForms.value = mapDeliveriesFromApi(data.deliveries, plate)
} else {
const legacy = mapLegacyPlanToStops(data)
loadForm.value = legacy.load
@@ -1696,7 +1736,7 @@
if (data.vehicleId) {
await ensureSelectedVehicleOption(data.vehicleId)
await handleVehicleChange(data.vehicleId)
await handleVehicleChange(data.vehicleId, { syncStopDibang: false })
}
if (data.driverId) {
selectedDriverId.value = data.driverId
@@ -1762,6 +1802,7 @@
formData.carrierId = ''
selectedDriverId.value = ''
vehicleBundle.value = null
reapplyStopDibangByVehiclePlate('')
freightCalc.resetFreightCalc()
syncSelectedCustomerOptions()
} catch (e) {
@@ -1790,11 +1831,16 @@
selectedDriverId.value = defaultDriver?.personnelId || ''
}
async function handleVehicleChange(vehicleId: string) {
async function handleVehicleChange(
vehicleId: string,
opts?: { syncStopDibang?: boolean }
) {
const syncStopDibang = opts?.syncStopDibang !== false
vehicleBundle.value = null
selectedDriverId.value = ''
if (!vehicleId) {
formData.carrierId = ''
if (syncStopDibang) reapplyStopDibangByVehiclePlate('')
return
}
@@ -1818,6 +1864,12 @@
} finally {
vehicleLoading.value = false
}
if (syncStopDibang) {
const plate =
(vehicle?.licensePlate || '').trim() ||
(vehicleBundle.value?.detail?.licensePlate || '').trim()
reapplyStopDibangByVehiclePlate(plate)
}
}
async function handleVehicleLibrarySelect(vehicle: Vehicle) {
@@ -108,12 +108,15 @@
<div class="pl-plan-dibang-row">
<el-checkbox
:model-value="modelValue.generateDibang === 1"
disabled
:disabled="readonly || !canManualDibang"
@update:model-value="handleDibangToggle"
>
生成地磅单({{ dibangDirectionLabel }})
</el-checkbox>
<span v-if="isInternal" class="pl-plan-dibang-row__hint">本段客户为内部客户时默认勾选</span>
<span v-if="canManualDibang" class="pl-plan-dibang-row__hint">
该车辆地磅单可选,请自行勾选
</span>
<span v-else-if="isInternal" class="pl-plan-dibang-row__hint">本段客户为内部客户时默认勾选</span>
<span v-else-if="modelValue.customerId" class="pl-plan-dibang-row__hint">
仅安庆山峡/安徽云栋可生成地磅单
</span>
@@ -162,7 +165,11 @@
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 { resolveStopDibangDefaults, type PlanLoadForm } from '@/utils/pl-plan-stop-form'
import {
canManualToggleStopDibang,
resolveStopDibangDefaults,
type PlanLoadForm
} from '@/utils/pl-plan-stop-form'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import { pickBxData } from '@/utils/http/bx'
@@ -173,11 +180,14 @@
compactProducts?: boolean
/** 编辑计划时排除本计划占用 */
excludePlanId?: string
/** 当前计划车牌(主牌),用于地磅可选车例外 */
vehiclePlate?: string
}>(),
{
readonly: false,
compactProducts: true,
excludePlanId: ''
excludePlanId: '',
vehiclePlate: ''
}
)
@@ -198,6 +208,9 @@
const customerOptionsLoading = ref(false)
const isInternal = computed(() => isPlInternalCustomerName(props.modelValue.customerName))
const canManualDibang = computed(() =>
canManualToggleStopDibang(props.modelValue.customerName, props.vehiclePlate)
)
const stopContractId = computed(() => {
const id = String(props.modelValue.contracts?.[0]?.contractId || '').trim()
@@ -365,7 +378,7 @@
}
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
const customerName = customer?.customerName || ''
const defaults = resolveStopDibangDefaults(customerName, 'load')
const defaults = resolveStopDibangDefaults(customerName, 'load', props.vehiclePlate)
patch({
customerId: String(customerId),
customerName,
@@ -403,9 +416,11 @@
}
function handleDibangToggle(checked: boolean) {
// 仅山峡/云栋可生成地磅单,且由客户类型自动决定,不允许手动勾选
if (!isInternal.value) {
patch({ generateDibang: 0, dibangDirection: 'none' })
// 非可手动场景:外部客户强制不勾;内部非可选车由默认值锁定,忽略手动切换
if (!canManualDibang.value) {
if (!isInternal.value) {
patch({ generateDibang: 0, dibangDirection: 'none' })
}
return
}
patch({
@@ -2,7 +2,7 @@
<el-dialog
v-model="innerVisible"
title="从地址库选择"
width="1200px"
width="1320px"
align-center
destroy-on-close
append-to-body
@@ -74,6 +74,12 @@
align="center"
header-align="center"
/>
<el-table-column
prop="notes"
label="注意事项"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
label="操作"
min-width="160"
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
canManualToggleStopDibang,
resolveStopDibangDefaults
} from './pl-plan-stop-form'
import { isPlDibangOptionalVehiclePlate } from './pl-internal-customer'
const INTERNAL = '安庆市山峡化工有限公司'
const YUNDONG = '安徽云栋化工有限公司'
const EXTERNAL = '某某贸易有限公司'
const OPTIONAL_PLATE = '皖H21522'
describe('isPlDibangOptionalVehiclePlate', () => {
it('matches exact main plate', () => {
assert.equal(isPlDibangOptionalVehiclePlate(OPTIONAL_PLATE), true)
assert.equal(isPlDibangOptionalVehiclePlate(` ${OPTIONAL_PLATE} `), true)
})
it('rejects other plates', () => {
assert.equal(isPlDibangOptionalVehiclePlate('皖A12345'), false)
assert.equal(isPlDibangOptionalVehiclePlate(''), false)
})
})
describe('resolveStopDibangDefaults + canManualToggleStopDibang', () => {
it('internal + normal vehicle: auto-check, not manual', () => {
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', '皖A11111'), {
generateDibang: 1,
dibangDirection: 'outbound'
})
assert.deepEqual(resolveStopDibangDefaults(YUNDONG, 'delivery', '皖A11111'), {
generateDibang: 1,
dibangDirection: 'inbound'
})
assert.equal(canManualToggleStopDibang(INTERNAL, '皖A11111'), false)
})
it('internal + optional vehicle: unchecked, manual allowed', () => {
assert.deepEqual(resolveStopDibangDefaults(INTERNAL, 'load', OPTIONAL_PLATE), {
generateDibang: 0,
dibangDirection: 'none'
})
assert.deepEqual(resolveStopDibangDefaults(YUNDONG, 'delivery', OPTIONAL_PLATE), {
generateDibang: 0,
dibangDirection: 'none'
})
assert.equal(canManualToggleStopDibang(INTERNAL, OPTIONAL_PLATE), true)
assert.equal(canManualToggleStopDibang(YUNDONG, OPTIONAL_PLATE), true)
})
it('external + optional vehicle: still locked off', () => {
assert.deepEqual(resolveStopDibangDefaults(EXTERNAL, 'load', OPTIONAL_PLATE), {
generateDibang: 0,
dibangDirection: 'none'
})
assert.equal(canManualToggleStopDibang(EXTERNAL, OPTIONAL_PLATE), false)
})
})
@@ -4,11 +4,22 @@ export const PL_INTERNAL_CUSTOMER_NAMES = [
'安徽云栋化工有限公司'
] as const
/**
* 地磅单「可选」车辆主牌:内部客户时默认不勾且允许手动勾选。
* 匹配主牌精确值(不含挂车拼接)。
*/
export const PL_DIBANG_OPTIONAL_VEHICLE_PLATES = ['皖H21522'] as const
export function isPlInternalCustomerName(name?: string | null): boolean {
const n = (name || '').trim()
return (PL_INTERNAL_CUSTOMER_NAMES as readonly string[]).includes(n)
}
export function isPlDibangOptionalVehiclePlate(plate?: string | null): boolean {
const p = (plate || '').trim()
return (PL_DIBANG_OPTIONAL_VEHICLE_PLATES as readonly string[]).includes(p)
}
/** 装货或送货段任一段客户为山峡/云栋时,需要现场链接。 */
export function planRequiresSiteLink(
plan?: {
+43 -12
View File
@@ -2,7 +2,10 @@ import type { Plan, PlanContractLink, PlanDelivery, PlanLoad, PlanStopProduct }
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import type { BrandOption } from '@/utils/crm-product-brand'
import { formatBrandConstraintLabel } from '@/utils/crm-product-brand'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import {
isPlDibangOptionalVehiclePlate,
isPlInternalCustomerName
} from '@/utils/pl-internal-customer'
export interface PlanStopProductFormRow {
id?: string
@@ -111,27 +114,45 @@ export interface PlanDeliveryForm {
products: PlanStopProductFormRow[]
}
/** 地磅勾选仅对内部客户(山峡/云栋)生效:默认勾选且不可改;其他客户默认不勾且不可勾。 */
/**
* 地磅勾选规则:
* - 内部客户(山峡/云栋):默认勾选且不可改
* - 地磅可选车(如皖H21522)+ 内部客户:默认不勾,可手动勾选
* - 其他客户:默认不勾且不可勾
*/
export function resolveStopDibangDefaults(
customerName?: string | null,
stopType: 'load' | 'delivery' = 'load'
stopType: 'load' | 'delivery' = 'load',
vehiclePlate?: string | null
): { generateDibang: number; dibangDirection: string } {
if (!isPlInternalCustomerName(customerName)) {
return { generateDibang: 0, dibangDirection: 'none' }
}
if (isPlDibangOptionalVehiclePlate(vehiclePlate)) {
return { generateDibang: 0, dibangDirection: 'none' }
}
if (stopType === 'load') {
return { generateDibang: 1, dibangDirection: 'outbound' }
}
return { generateDibang: 1, dibangDirection: 'inbound' }
}
/** 仅「内部客户 + 地磅可选车」允许用户手动勾选地磅单 */
export function canManualToggleStopDibang(
customerName?: string | null,
vehiclePlate?: string | null
): boolean {
return isPlInternalCustomerName(customerName) && isPlDibangOptionalVehiclePlate(vehiclePlate)
}
function normalizeStopDibangFieldsFromApi(
customerName: string | null | undefined,
stopType: 'load' | 'delivery',
generateDibang?: number | null,
dibangDirection?: string | null
dibangDirection?: string | null,
vehiclePlate?: string | null
): { generateDibang: number; dibangDirection: string } {
const defaults = resolveStopDibangDefaults(customerName, stopType)
const defaults = resolveStopDibangDefaults(customerName, stopType, vehiclePlate)
// 非山峡/云栋:禁止生成地磅单(编辑回填也强制清掉历史勾选)
if (!isPlInternalCustomerName(customerName)) {
return { generateDibang: 0, dibangDirection: 'none' }
@@ -140,7 +161,8 @@ function normalizeStopDibangFieldsFromApi(
let direction = (dibangDirection || '').trim()
if (gen) {
if (direction !== 'outbound' && direction !== 'inbound') {
direction = defaults.dibangDirection
// 可选车默认 direction 为 none,勾选回填时按段类型补方向
direction = stopType === 'load' ? 'outbound' : 'inbound'
}
} else {
direction = 'none'
@@ -409,13 +431,17 @@ export function resolveDeliveryViewAddress(
return ''
}
export function mapLoadFromApi(load?: PlanLoad | null): PlanLoadForm {
export function mapLoadFromApi(
load?: PlanLoad | null,
vehiclePlate?: string | null
): PlanLoadForm {
if (!load) return createEmptyLoadForm()
const dibang = normalizeStopDibangFieldsFromApi(
load.customerName,
'load',
load.generateDibang,
load.dibangDirection
load.dibangDirection,
vehiclePlate
)
return {
customerId: load.customerId || '',
@@ -436,14 +462,18 @@ export function mapLoadFromApi(load?: PlanLoad | null): PlanLoadForm {
}
}
export function mapDeliveriesFromApi(deliveries?: PlanDelivery[]): PlanDeliveryForm[] {
export function mapDeliveriesFromApi(
deliveries?: PlanDelivery[],
vehiclePlate?: string | null
): PlanDeliveryForm[] {
if (!deliveries?.length) return [createEmptyDeliveryForm(1)]
return deliveries.map((row, index) => {
const dibang = normalizeStopDibangFieldsFromApi(
row.customerName,
'delivery',
row.generateDibang,
row.dibangDirection
row.dibangDirection,
vehiclePlate
)
return {
seq: row.seq ?? index + 1,
@@ -471,8 +501,9 @@ export function mapLegacyPlanToStops(plan: Plan): {
load: PlanLoadForm
deliveries: PlanDeliveryForm[]
} {
const loadDefaults = resolveStopDibangDefaults(plan.supplierName, 'load')
const deliveryDefaults = resolveStopDibangDefaults(plan.customerName, 'delivery')
const plate = plan.licensePlate
const loadDefaults = resolveStopDibangDefaults(plan.supplierName, 'load', plate)
const deliveryDefaults = resolveStopDibangDefaults(plan.customerName, 'delivery', plate)
const stopProducts = createStopProductsFromPlanProducts(
(plan.products || []).map((item, index) => ({
id: item.id,