Files
shanxia2.0/web-admin/src/components/business/pl-plan-form-dialog/index.vue
T

2867 lines
92 KiB
Vue

<!-- eslint-disable vue/no-v-model-argument -->
<template>
<component
:is="isPageVariant ? 'div' : ElDialog"
v-bind="formShellBind"
@closed="handleClosed"
@opened="handleOpened"
@update:model-value="onFormShellVisible"
>
<h2 v-if="isPageVariant" class="mp-plan-form-title">{{ dialogTitle }}</h2>
<div class="pl-plan-form-body">
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<el-form
ref="basicFormRef"
:model="formData"
:rules="basicRules"
:label-position="formLabelPosition"
label-width="100px"
class="pl-dialog-form"
>
<div class="form-grid-5">
<el-form-item label="创建日期" prop="createDate">
<el-date-picker
v-model="formData.createDate"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
:disabled="!isEdit"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="计划日期" prop="planDate">
<el-date-picker
v-model="formData.planDate"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
clearable
style="width: 100%"
@change="handlePlanDateChange"
/>
</el-form-item>
<el-form-item label="归属公司" prop="belongsCompany">
<el-select v-model="formData.belongsCompany" placeholder="请选择归属公司">
<el-option label="安庆山峡" value="shanxia" />
<el-option label="安徽云栋" value="yundong" />
</el-select>
</el-form-item>
<el-form-item label="单据类型" prop="planType">
<el-select
v-model="formData.planType"
placeholder="请选择单据类型"
clearable
style="width: 100%"
>
<el-option label="直销单" value="direct" />
<el-option label="入库单" value="inbound" />
<el-option label="出库单" value="outbound" />
</el-select>
</el-form-item>
<el-form-item label="跟进人">
<el-input v-model="formData.followUserName" readonly />
</el-form-item>
</div>
</el-form>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">运输信息</h3>
<el-form
:model="formData"
:label-position="formLabelPosition"
label-width="100px"
class="pl-dialog-form"
>
<div class="pl-plan-transport-row pl-plan-transport-row--four">
<el-form-item label="车牌号" class="pl-plan-transport-row__vehicle">
<div class="pl-plan-vehicle-with-library">
<el-select
v-model="formData.vehicleId"
class="pl-plan-vehicle-with-library__select"
placeholder="请选择车牌号"
filterable
remote
clearable
:remote-method="searchVehicles"
:loading="vehicleSelectLoading || vehicleLoading"
@focus="onVehicleSelectFocus"
@change="handleVehicleChange"
>
<el-option
v-for="item in vehicleList"
:key="item.id"
:label="formatPlVehiclePlateOptionLabel(item.licensePlate, item.trailerNo)"
:value="item.id"
/>
</el-select>
<el-button
class="pl-plan-vehicle-with-library__btn btn-ghost"
@click="vehicleLibraryVisible = true"
>
<el-icon><FolderOpened /></el-icon>从车辆库选择
</el-button>
<el-button
class="pl-plan-vehicle-with-library__btn btn-ghost"
@click="pasteRecognizeVisible = true"
>
<el-icon><DocumentCopy /></el-icon>三方人车快速创建
</el-button>
</div>
</el-form-item>
<el-form-item label="选择司机" class="pl-plan-transport-row__driver">
<el-select
v-model="selectedDriverId"
placeholder="请选择司机(可选)"
filterable
remote
clearable
:remote-method="searchDrivers"
:loading="driverSelectLoading"
:disabled="!formData.vehicleId"
@focus="onDriverSelectFocus"
>
<el-option
v-for="d in driverList"
:key="d.id"
:label="formatDriverOptionLabel(d)"
:value="d.id"
/>
</el-select>
</el-form-item>
<el-form-item label="选择押运员" class="pl-plan-transport-row__escort">
<el-select
v-model="selectedEscortId"
placeholder="请选择押运员(可选)"
filterable
remote
clearable
:remote-method="searchEscorts"
:loading="escortSelectLoading"
:disabled="!formData.vehicleId"
@focus="onEscortSelectFocus"
>
<el-option
v-for="d in escortList"
:key="d.id"
:label="formatDriverOptionLabel(d)"
:value="d.id"
/>
</el-select>
</el-form-item>
<el-form-item label="运输公司" class="pl-plan-transport-row__carrier">
<div class="pl-plan-carrier-with-library">
<el-select
v-model="formData.carrierId"
class="pl-plan-carrier-with-library__select"
placeholder="请选择运输公司"
filterable
remote
clearable
:remote-method="searchCarriers"
:loading="carrierSelectLoading"
@focus="onCarrierSelectFocus"
>
<el-option
v-for="c in carrierList"
:key="c.id"
:label="c.carrierName"
:value="c.id"
/>
</el-select>
<el-button
class="pl-plan-carrier-with-library__btn btn-ghost"
@click="carrierLibraryVisible = true"
>
<el-icon><FolderOpened /></el-icon>从承运商库选择
</el-button>
</div>
</el-form-item>
</div>
<PlVehicleSummaryStrip
v-if="vehicleBundle"
:detail="vehicleBundle.detail"
:personnel-list="vehicleBundle.personnelList"
:carrier-name="summaryCarrierName"
@view="vehicleViewVisible = true"
/>
</el-form>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">装送货信息</h3>
<el-form :label-position="formLabelPosition" label-width="100px" class="pl-dialog-form">
<PlPlanStopsSplitShell
:plan-total-qty="planTotalQty"
:load-total-qty="loadTotalQty"
:delivery-total-qty="deliveryTotalQty"
:unit-label="planQtyUnitLabel"
:delivery-breakdown="deliveryQtyBreakdown"
>
<template #load>
<PlPlanLoadSection
ref="loadSectionRef"
v-model="loadForm"
compact-products
:exclude-plan-id="planId || ''"
:vehicle-plate="currentVehiclePlate"
:contract-product-rows="loadContractProductRows"
@open-customer-picker="openCustomerSelector('load')"
@add-products="openProductSelector('load')"
/>
</template>
<template #delivery-actions>
<el-button class="btn-main" type="primary" @click="handleAddDelivery">
<el-icon><Plus /></el-icon>添加送货段
</el-button>
<el-button
v-if="deliveryActiveIndex > 0"
class="btn-ghost"
@click="handleMoveDelivery(-1)"
>
<el-icon><Top /></el-icon>上移
</el-button>
<el-button
v-if="deliveryActiveIndex < deliveryForms.length - 1"
class="btn-ghost"
@click="handleMoveDelivery(1)"
>
<el-icon><Bottom /></el-icon>下移
</el-button>
<el-button
v-if="deliveryForms.length > 1"
class="btn-ghost"
type="danger"
@click="handleRemoveDelivery"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
<template #delivery>
<PlPlanDeliverySection
ref="deliverySectionRef"
v-model="deliveryForms"
tab-mode
compact-products
:qty-unit-label="planQtyUnitLabel"
:show-split-hint="showDeliverySplitHint"
: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"
@select-products="openProductSelector('delivery', $event)"
/>
</template>
</PlPlanStopsSplitShell>
</el-form>
</section>
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">备注</h3>
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</section>
</div>
<template v-if="!isPageVariant" #footer>
<div class="dialog-footer">
<el-button @click="handleCancel">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button v-if="showDraftButton" :loading="submitLoading" @click="handleSubmit('draft')">
<el-icon><Document /></el-icon>暂存
</el-button>
<el-button
v-if="showSaveButton"
:type="showSaveAndArrangeButton ? 'default' : 'primary'"
:class="showSaveAndArrangeButton ? 'btn-ghost' : undefined"
:loading="submitLoading"
@click="handleSubmit('save')"
>
<el-icon><Check /></el-icon>{{ saveButtonLabel }}
</el-button>
<el-button
v-if="showSaveAndArrangeButton"
type="primary"
:loading="submitLoading"
@click="handleSubmit('saveAndArrange')"
>
<el-icon><Van /></el-icon>{{ saveAndArrangeButtonLabel }}
</el-button>
<el-button
v-if="showRearrangeButton"
type="primary"
:loading="submitLoading"
@click="handleSubmit('rearrange')"
>
<el-icon><Van /></el-icon>调整运输
</el-button>
</div>
</template>
<div v-else class="mp-plan-form-footer dialog-footer">
<el-button @click="handleCancel">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button v-if="showDraftButton" :loading="submitLoading" @click="handleSubmit('draft')">
<el-icon><Document /></el-icon>暂存
</el-button>
<el-button
v-if="showSaveButton"
:type="showSaveAndArrangeButton ? 'default' : 'primary'"
:class="showSaveAndArrangeButton ? 'btn-ghost' : undefined"
:loading="submitLoading"
@click="handleSubmit('save')"
>
<el-icon><Check /></el-icon>{{ saveButtonLabel }}
</el-button>
<el-button
v-if="showSaveAndArrangeButton"
type="primary"
:loading="submitLoading"
@click="handleSubmit('saveAndArrange')"
>
<el-icon><Van /></el-icon>{{ saveAndArrangeButtonLabel }}
</el-button>
<el-button
v-if="showRearrangeButton"
type="primary"
:loading="submitLoading"
@click="handleSubmit('rearrange')"
>
<el-icon><Van /></el-icon>调整运输
</el-button>
</div>
<CustomerSelector
:visible="customerSelectorVisible"
@update:visible="customerSelectorVisible = $event"
@confirm="handleCustomerConfirm"
/>
<ProductSelector
:visible="productSelectorVisible"
:allowed-product-ids="productSelectorAllowedIds"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductConfirm"
/>
<PlVehicleViewDialog
v-model:visible="vehicleViewVisible"
:vehicle-id="formData.vehicleId"
:bundle="vehicleBundle"
/>
<PlVehicleLibrarySelectDialog
v-model="vehicleLibraryVisible"
@select="handleVehicleLibrarySelect"
/>
<PlCarrierLibrarySelectDialog
v-model="carrierLibraryVisible"
@select="handleCarrierLibrarySelect"
/>
<PlPasteRecognizeDialog
v-model:visible="pasteRecognizeVisible"
mode="vehicle-bundle"
@done="handlePasteRecognizeDone"
/>
</component>
</template>
<script setup lang="ts">
import { ElDialog } from 'element-plus'
import {
Bottom,
Check,
Close,
Delete,
Document,
DocumentCopy,
FolderOpened,
Plus,
Top,
Van
} from '@element-plus/icons-vue'
import {
planApi,
transportApi,
showPlanSaveWarnings,
type Plan,
type PlanContractLink,
type PlanProduct,
type PlanUpdatePayload,
type Carrier,
type PasteCommitResult,
type Personnel,
type Vehicle,
type VehiclePersonnelRow
} from '@/api/pl'
import {
usePlRemoteCarrierOptions,
usePlRemotePersonnelOptions,
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'
import PlPlanDeliverySection from '@/components/business/pl-plan-delivery-section/index.vue'
import PlPlanStopsSplitShell from '@/components/business/pl-plan-stops-split-shell/index.vue'
import PlVehicleSummaryStrip from '@/components/business/pl-vehicle-summary-strip/index.vue'
import PlVehicleViewDialog from '@/components/business/pl-vehicle-view-dialog/index.vue'
import PlVehicleLibrarySelectDialog from '@/components/business/pl-vehicle-library-select-dialog/index.vue'
import PlCarrierLibrarySelectDialog from '@/components/business/pl-carrier-library-select-dialog/index.vue'
import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
import {
buildDeliveriesPayload,
buildLoadPayload,
createEmptyDeliveryForm,
createEmptyLoadForm,
createStopProductsFromPlanProducts,
mapDeliveriesFromApi,
mapLegacyPlanToStops,
mapLoadFromApi,
enrichStopProductsDisplayFromPlan,
mapLoadProductsToPlanFormRows,
mergePlanProductsFromLoadMirror,
resolveStopDibangDefaults,
sumStopProductsPlanQty,
syncDeliveriesProductsFromLoad,
type PlanDeliveryForm,
type PlanLoadForm,
type PlanStopProductFormRow
} from '@/utils/pl-plan-stop-form'
import { findFirstStopProductArrangeGap } from '@/utils/pl-plan-stop-product-arrange-required'
import {
enrichPlanProductLinesAlias,
type PlanFormProductRow
} from '@/composables/usePlContractToPlan'
import {
initLineBrandMetaFromProduct,
prepareLineAliasAndBrandForSubmit
} from '@/utils/crm-product-brand'
import { formatPlVehiclePlateOptionLabel } from '@/utils/pl-display'
import {
buildMissingHazardCertReadyMessage,
enrichPlanProductsHazardFromCrm
} from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import {
buildArrangePayloadFromPlanForm,
confirmBeforePlanArrange,
loadArrangeHistoricalFlags,
planHasAnyDibangSelected,
submitPlanArrange
} from '@/composables/usePlPlanArrangeSubmit'
import {
loadVehicleViewBundle,
type VehicleViewBundle
} from '@/composables/usePlVehicleViewDetail'
import { useDictStore } from '@/store/modules/dict'
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,
collectContractProductRows,
filterProductsByAllowedProductIds,
isPackageAllowedForContractProduct,
normalizePackageSpec,
packageSpecsForProduct,
productPackageKey,
type ContractProductRowLite
} from '@/utils/pl-plan-contract-product'
import { getPersonnelAffiliationLabel } from '@/utils/pl-personnel-cert'
const props = defineProps<{
visible: boolean
planId?: string
/** 有值且无 planId 时:新增模式 + 从该计划预填(复制) */
copyFromPlanId?: string
/** 从列表/详情「安排运输」「调整运输」进入时展开安排区块 */
openForArrange?: boolean
/** 归档等场景:仅保存计划,不展示暂存/安排运输(已安排计划也显示「保存」) */
planEditOnly?: boolean
/** 小程序 WebView 全屏页:不使用桌面弹窗遮罩 */
variant?: 'dialog' | 'page'
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [payload?: { planId: string; arranged?: boolean }]
close: []
}>()
const userStore = useUserStore()
const dictStore = useDictStore()
type CustomerSide = 'load' | 'delivery'
interface CustomerConfirmRow {
id: string | number
customerName?: string
contactName?: string
mobile?: string
}
/** 当前登录用户 ID(后端 current-user 返回 id,兼容历史 userId 字段) */
function resolveCurrentUserId() {
const info = userStore.info || ({} as Api.Auth.UserInfo)
const fromInfo = String(info.id || info.userId || '')
if (fromInfo) return fromInfo
const token = userStore.accessToken
if (!token || token.split('.').length < 2) return ''
try {
const payload = token.split('.')[1]
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const pad = '='.repeat((4 - (b64.length % 4)) % 4)
const obj = JSON.parse(atob(b64 + pad))
return String(obj?.sub || '')
} catch {
return ''
}
}
function resolveCurrentUserName() {
const info = userStore.info || ({} as Api.Auth.UserInfo & Record<string, string>)
return info.realName || info.userName || info.username || info.nickName || ''
}
function todayYmd(): string {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
const isEdit = computed(() => !!props.planId)
const isCopy = computed(() => !props.planId && !!props.copyFromPlanId)
const isPageVariant = computed(() => props.variant === 'page')
const formLabelPosition = computed(() => (isPageVariant.value ? 'top' : 'right'))
const dialogTitle = computed(() => (isEdit.value ? '编辑运输计划' : '新增运输计划'))
const innerVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
function onFormShellVisible(value: boolean) {
innerVisible.value = value
}
const formShellBind = computed(() => {
if (isPageVariant.value) {
return { class: 'mp-plan-form-page pl-plan-form-dialog' }
}
return {
modelValue: innerVisible.value,
title: dialogTitle.value,
width: '100%',
alignCenter: true,
destroyOnClose: true,
appendToBody: true,
class: 'dialog-form-layout pl-plan-form-dialog',
modalClass: 'order-module-dialog-mask',
closeOnClickModal: false,
style: 'backdrop-filter: none'
}
})
const submitLoading = ref(false)
const detailLoading = ref(false)
/** 加载详情 / 重置时禁止计划产品 → 段产品自动对齐 */
const suppressStopProductSync = ref(false)
const loadedTransportStatus = ref('pending')
const loadedFollowStatus = ref('')
const loadForm = ref<PlanLoadForm>(createEmptyLoadForm())
const deliveryForms = ref<PlanDeliveryForm[]>([createEmptyDeliveryForm(1)])
const loadedPlanSnapshot = ref<Plan | null>(null)
const selectedDriverId = ref('')
const selectedEscortId = ref('')
/** 已安排:禁止仅「保存」,须走保存并安排/调整运输,避免计划与地磅等联动字段脱节 */
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'
})
const isRearrangeMode = computed(
() => loadedTransportStatus.value === 'arranged' && Boolean(props.openForArrange)
)
const showRearrangeButton = computed(() => !props.planEditOnly && isRearrangeMode.value)
const saveButtonLabel = computed(() => (isEdit.value ? '保存' : '创建'))
const saveAndArrangeButtonLabel = computed(() => {
if (!isEdit.value) return '创建并安排运输'
return '保存并安排'
})
const customerSelectorVisible = ref(false)
const customerSelectorSide = ref<CustomerSide>('load')
const customerSelectorDeliveryIndex = ref(0)
const loadSectionRef = ref<InstanceType<typeof PlPlanLoadSection>>()
const deliverySectionRef = ref<InstanceType<typeof PlPlanDeliverySection>>()
const deliveryActiveIndex = ref(0)
const stopFormsReady = ref(false)
interface CustomerOptionRow {
id: string
customerName: string
customerNo?: string
}
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[]>>({})
/** 合同 id → 明细行摘要(含包装,供段产品表下拉与保存校验) */
const contractProductRowsCache = ref<Record<string, ContractProductRowLite[]>>({})
/** 送货段合同明细拉取序号(按段) */
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
})
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
}
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?: 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 []
}
}
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)
const pasteRecognizeVisible = ref(false)
const vehicleLoading = ref(false)
const basicFormRef = ref()
const {
list: vehicleList,
loading: vehicleSelectLoading,
search: searchVehicles,
onFocus: onVehicleSelectFocus,
ensureById: ensureSelectedVehicleOption
} = usePlRemoteVehicleOptions({
getSelectedId: () => formData.vehicleId,
listParams: { vehicleStatus: 'available' }
})
const {
list: carrierList,
loading: carrierSelectLoading,
search: searchCarriers,
onFocus: onCarrierSelectFocus,
ensureById: ensureSelectedCarrierOption
} = usePlRemoteCarrierOptions({
getSelectedId: () => formData.carrierId,
listParams: { status: '1' }
})
const {
list: driverList,
loading: driverSelectLoading,
search: searchDrivers,
onFocus: onDriverSelectFocus,
ensureById: ensureSelectedDriverOption
} = usePlRemotePersonnelOptions({
getSelectedId: () => selectedDriverId.value,
listParams: { status: '1', personnelType: 'driver' }
})
const {
list: escortList,
loading: escortSelectLoading,
search: searchEscorts,
onFocus: onEscortSelectFocus,
ensureById: ensureSelectedEscortOption
} = usePlRemotePersonnelOptions({
getSelectedId: () => selectedEscortId.value,
listParams: { status: '1', personnelTypeIn: 'escort,both' }
})
const vehicleBundle = ref<VehicleViewBundle | null>(null)
const formData = reactive({
createDate: todayYmd(),
planDate: '',
belongsCompany: 'shanxia',
planType: '',
followUserId: '',
followUserName: '',
supplierId: '',
supplierName: '',
supplierContracts: [] as PlanContractLink[],
destinationId: '',
destinationName: '',
customerId: '',
customerName: '',
demanderContracts: [] as PlanContractLink[],
contactName: '',
contactPhone: '',
hasFreight: 0,
pickupAddress: '',
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
vehicleId: '',
carrierId: '',
distanceKm: null as number | null,
freightType: 'by_weight',
remark: '',
products: [] as PlanFormProductRow[]
})
const basicRules = {
createDate: [{ required: true, message: '请选择创建日期', trigger: 'change' }],
belongsCompany: [{ required: true, message: '请选择归属公司', trigger: 'change' }]
}
const productsRef = computed(() => formData.products)
const planTotalQty = computed(() => sumStopProductsPlanQty(loadForm.value.products || []))
const loadTotalQty = computed(() => sumStopProductsPlanQty(loadForm.value.products || []))
const deliveryTotalQty = computed(() =>
deliveryForms.value.reduce((sum, row) => sum + sumStopProductsPlanQty(row.products || []), 0)
)
const planQtyUnitLabel = computed(() => resolveFreightUnitLabel(formData.products))
const showDeliverySplitHint = computed(() => deliveryForms.value.length >= 2)
const deliveryQtyBreakdown = computed(() => {
const parts = deliveryForms.value
.map((row) => parseFloat(sumStopProductsPlanQty(row.products || []).toFixed(3)))
.filter((qty) => qty > 0)
if (parts.length < 2) return ''
const total = parseFloat(parts.reduce((a, b) => a + b, 0).toFixed(3))
return `${parts.join(' + ')} = ${total}`
})
function mirrorPlanProductsFromLoad() {
formData.products = mergePlanProductsFromLoadMirror(
mapLoadProductsToPlanFormRows(loadForm.value.products || []),
formData.products
)
}
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.map((row) => filterDeliveryProductsByContract(row))
if (options?.showHint && result.needSplitHint) {
ElMessage.info('多个送货段,请拆分各段数量')
}
}
/** 装货产品变更后:镜像计划层产品 + 同步送货段(C1) */
async function syncFromLoadProducts(options?: { showHint?: boolean }) {
if (suppressStopProductSync.value || detailLoading.value) return
mirrorPlanProductsFromLoad()
await applyLoadProductsToDeliveries(options)
}
function handleAddDelivery() {
deliverySectionRef.value?.addDelivery()
nextTick(() => {
void applyLoadProductsToDeliveries({ showHint: true })
})
}
function handleMoveDelivery(delta: number) {
deliverySectionRef.value?.moveDelivery(deliveryActiveIndex.value, delta)
}
function handleRemoveDelivery() {
deliverySectionRef.value?.removeDelivery(deliveryActiveIndex.value)
nextTick(() => {
void applyLoadProductsToDeliveries()
})
}
const hasFreightRef = computed(() => formData.hasFreight)
const freightTypeRef = computed(() => formData.freightType)
const freightCalc = usePlFreightCalc({
products: productsRef,
hasFreight: hasFreightRef,
freightType: freightTypeRef
})
const selectedVehicle = computed(() =>
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
return vehicleBundle.value?.detail?.carrierName || ''
})
function mapPlanContractsFromApi(links?: PlanContractLink[]): PlanContractLink[] {
return (links || [])
.map((item) => ({
contractId: String(item.contractId || ''),
contractNo: item.contractNo || undefined
}))
.filter((item) => item.contractId)
}
function extractCustomerOptions(res: unknown): CustomerOptionRow[] {
const unwrapped = (res as { data?: unknown })?.data ?? res
if (Array.isArray(unwrapped)) return unwrapped as CustomerOptionRow[]
const payload = (unwrapped as { list?: CustomerOptionRow[]; rows?: CustomerOptionRow[] }) || {}
return payload.list || payload.rows || []
}
function ensureCustomerOption(customer?: Partial<CustomerOptionRow> | null) {
if (!customer?.id) return
const id = String(customer.id)
const exists = customerOptions.value.some((item) => String(item.id) === id)
if (exists) {
customerOptions.value = customerOptions.value.map((item) =>
String(item.id) === id
? {
...item,
customerName: customer.customerName || item.customerName,
customerNo: customer.customerNo || item.customerNo
}
: item
)
return
}
customerOptions.value = [
{
id,
customerName: customer.customerName || '',
customerNo: customer.customerNo
},
...customerOptions.value
]
}
function syncSelectedCustomerOptions() {
if (formData.supplierId) {
ensureCustomerOption({
id: formData.supplierId,
customerName: formData.supplierName
})
}
if (formData.customerId) {
ensureCustomerOption({
id: formData.customerId,
customerName: formData.customerName
})
}
}
async function loadDefaultCustomerOptions() {
customerOptionsLoading.value = true
try {
const res = await fetchCustomerOptions({ limit: 20 })
customerOptions.value = extractCustomerOptions(res)
syncSelectedCustomerOptions()
} catch (error) {
console.error('加载客户选项失败:', error)
customerOptions.value = []
syncSelectedCustomerOptions()
} finally {
customerOptionsLoading.value = false
}
}
async function searchCustomerOptions(query: string) {
const keyword = query?.trim()
if (!keyword) {
await loadDefaultCustomerOptions()
return
}
customerOptionsLoading.value = true
try {
const res = await fetchCustomerOptions({
keyword,
limit: 50
})
customerOptions.value = extractCustomerOptions(res)
syncSelectedCustomerOptions()
} catch (error) {
console.error('搜索客户失败:', error)
customerOptions.value = []
syncSelectedCustomerOptions()
} finally {
customerOptionsLoading.value = false
}
}
function handleCustomerSelectFocus() {
if (customerOptions.value.length === 0) {
loadDefaultCustomerOptions()
}
}
const MSG_SAME_CUSTOMER = '装货客户与送货客户不能为同一客户'
const MSG_SAME_ADDRESS = '取货地址与送货地址不能相同'
/** 已确认的客户 id,用于冲突时回滚 el-select */
const committedSupplierId = ref('')
const committedCustomerId = ref('')
/** 加载/重置表单时跳过地址互斥回滚 */
const suppressAddressMutex = ref(false)
function isSamePartyCustomer(supplierId?: string, customerId?: string) {
const s = String(supplierId || '').trim()
const c = String(customerId || '').trim()
return Boolean(s && c && s === c)
}
function normalizeAddressText(value?: string | null) {
return String(value || '').trim()
}
function isSameCoordPair(
lng1: number | null | undefined,
lat1: number | null | undefined,
lng2: number | null | undefined,
lat2: number | null | undefined
) {
if (lng1 == null || lat1 == null || lng2 == null || lat2 == null) return false
const aLng = Number(lng1)
const aLat = Number(lat1)
const bLng = Number(lng2)
const bLat = Number(lat2)
if ([aLng, aLat, bLng, bLat].some((n) => Number.isNaN(n))) return false
return aLng === bLng && aLat === bLat
}
function isSameLogisticsAddress(
addressA?: string | null,
lngA?: number | null,
latA?: number | null,
addressB?: string | null,
lngB?: number | null,
latB?: number | null
) {
const ta = normalizeAddressText(addressA)
const tb = normalizeAddressText(addressB)
if (ta && tb && ta === tb) return true
if (isSameCoordPair(lngA, latA, lngB, latB)) return true
return false
}
function syncCommittedPartyIds() {
committedSupplierId.value = String(formData.supplierId || '')
committedCustomerId.value = String(formData.customerId || '')
}
/** 供需方互换:仅交换本区字段,勿走 select change(会清空合同) */
function handleSwapParties() {
const temp = {
supplierId: formData.supplierId,
supplierName: formData.supplierName,
supplierContracts: [...formData.supplierContracts],
customerId: formData.customerId,
customerName: formData.customerName,
demanderContracts: [...formData.demanderContracts]
}
formData.supplierId = temp.customerId
formData.supplierName = temp.customerName
formData.supplierContracts = temp.demanderContracts
formData.customerId = temp.supplierId
formData.customerName = temp.supplierName
formData.demanderContracts = temp.supplierContracts
formData.destinationId = temp.supplierId
formData.destinationName = temp.supplierName
// 联系人仅绑定需方;互换后无供方联系人可对调,清空以免挂在错误客户上
formData.contactName = ''
formData.contactPhone = ''
syncCommittedPartyIds()
ElMessage.success('供需方已互换')
}
/** 取货信息 ↔ 送货信息 */
function handleSwapPickupDelivery() {
suppressAddressMutex.value = true
const temp = {
pickupAddress: formData.pickupAddress,
pickupLng: formData.pickupLng,
pickupLat: formData.pickupLat,
pickupTime: formData.pickupTime,
pickupContact: formData.pickupContact,
pickupNotes: formData.pickupNotes
}
formData.pickupAddress = formData.destinationAddress
formData.pickupLng = formData.destinationLng
formData.pickupLat = formData.destinationLat
formData.pickupTime = formData.deliveryTime
formData.pickupContact = formData.deliveryContact
formData.pickupNotes = formData.destinationNotes
formData.destinationAddress = temp.pickupAddress
formData.destinationLng = temp.pickupLng
formData.destinationLat = temp.pickupLat
formData.deliveryTime = temp.pickupTime
formData.deliveryContact = temp.pickupContact
formData.destinationNotes = temp.pickupNotes
nextTick(() => {
suppressAddressMutex.value = false
})
ElMessage.success('取送货信息已互换')
}
function handleSupplierSelectChange(customerId?: string) {
if (!customerId) {
formData.supplierId = ''
formData.supplierName = ''
formData.supplierContracts = []
committedSupplierId.value = ''
return
}
const nextId = String(customerId)
if (isSamePartyCustomer(nextId, formData.customerId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
nextTick(() => {
formData.supplierId = committedSupplierId.value
})
return
}
const customer = customerOptions.value.find((item) => String(item.id) === nextId)
formData.supplierId = nextId
formData.supplierName = customer?.customerName || formData.supplierName || ''
formData.supplierContracts = []
committedSupplierId.value = nextId
}
function handleDemanderSelectChange(customerId?: string) {
if (!customerId) {
formData.customerId = ''
formData.customerName = ''
formData.destinationId = ''
formData.destinationName = ''
formData.demanderContracts = []
formData.contactName = ''
formData.contactPhone = ''
committedCustomerId.value = ''
return
}
const nextId = String(customerId)
if (isSamePartyCustomer(formData.supplierId, nextId)) {
ElMessage.warning(MSG_SAME_CUSTOMER)
nextTick(() => {
formData.customerId = committedCustomerId.value
})
return
}
const customer = customerOptions.value.find((item) => String(item.id) === nextId)
const name = customer?.customerName || formData.customerName || ''
formData.customerId = nextId
formData.customerName = name
formData.destinationId = nextId
formData.destinationName = name
formData.demanderContracts = []
committedCustomerId.value = nextId
}
function resetStopForms() {
stopFormsReady.value = false
loadForm.value = createEmptyLoadForm()
deliveryForms.value = [createEmptyDeliveryForm(1)]
deliveryActiveIndex.value = 0
stopFormsReady.value = true
}
function applyStopsFromPlan(data: Plan) {
stopFormsReady.value = false
const plate = data.licensePlate || ''
if (data.load || data.deliveries?.length) {
loadForm.value = mapLoadFromApi(data.load, plate)
deliveryForms.value = mapDeliveriesFromApi(data.deliveries, plate)
} else {
const legacy = mapLegacyPlanToStops(data)
loadForm.value = legacy.load
deliveryForms.value = legacy.deliveries
}
stopFormsReady.value = true
}
function enrichStopFormsDisplayFromPlanProducts() {
loadForm.value = {
...loadForm.value,
products: enrichStopProductsDisplayFromPlan(loadForm.value.products, formData.products)
}
deliveryForms.value = deliveryForms.value.map((row) => ({
...row,
products: enrichStopProductsDisplayFromPlan(row.products, formData.products)
}))
}
/** 详情加载后:无装货产品时用计划产品回填;再按 C1 同步送货 */
async function bootstrapStopsFromPlanProducts() {
if (!loadForm.value.products.length && formData.products.length) {
loadForm.value = {
...loadForm.value,
products: createStopProductsFromPlanProducts(formData.products)
}
} else {
enrichStopFormsDisplayFromPlanProducts()
}
mirrorPlanProductsFromLoad()
await applyLoadProductsToDeliveries()
}
async function syncDeliveryProductsFromLoadManual() {
await applyLoadProductsToDeliveries({ forceQtyFromLoad: deliveryForms.value.length < 2 })
ElMessage.success('已从装货产品同步')
}
watch(
() => loadForm.value.products,
() => {
syncFromLoadProducts()
},
{ 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) {
loadContractFetchSeq += 1
loadContractProductIds.value = []
return
}
const seq = ++loadContractFetchSeq
const allowedIds = await fetchContractProductIds(id)
if (seq !== loadContractFetchSeq) return
loadContractProductIds.value = allowedIds
// 合同 id 变化时过滤装货产品(详情首次灌入同 id / 加载中不刷)
if (prev && id !== prevId && !detailLoading.value && !suppressStopProductSync.value) {
const nextProducts = filterProductsByAllowedProductIds(
loadForm.value.products || [],
allowedIds
)
loadForm.value = { ...loadForm.value, products: nextProducts }
syncFromLoadProducts()
}
},
{ 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
customerSelectorVisible.value = true
}
function handleCustomerConfirm(customer: CustomerConfirmRow) {
if (!customer?.id) return
if (customerSelectorSide.value === 'load') {
loadSectionRef.value?.applyCustomerFromPicker({
id: customer.id,
customerName: customer.customerName
})
return
}
deliverySectionRef.value?.applyCustomerFromPicker(customerSelectorDeliveryIndex.value, {
id: customer.id,
customerName: customer.customerName
})
}
watch(
() =>
[formData.pickupAddress, formData.pickupLng, formData.pickupLat] as [
string,
number | null,
number | null
],
(curr, prev) => {
if (suppressAddressMutex.value || !prev) return
if (
!isSameLogisticsAddress(
curr[0],
curr[1],
curr[2],
formData.destinationAddress,
formData.destinationLng,
formData.destinationLat
)
) {
return
}
// 仅当「取货侧」变化导致冲突时回滚取货
if (
isSameLogisticsAddress(
prev[0],
prev[1],
prev[2],
formData.destinationAddress,
formData.destinationLng,
formData.destinationLat
)
) {
return
}
ElMessage.warning(MSG_SAME_ADDRESS)
suppressAddressMutex.value = true
formData.pickupAddress = prev[0] || ''
formData.pickupLng = prev[1]
formData.pickupLat = prev[2]
nextTick(() => {
suppressAddressMutex.value = false
})
}
)
watch(
() =>
[formData.destinationAddress, formData.destinationLng, formData.destinationLat] as [
string,
number | null,
number | null
],
(curr, prev) => {
if (suppressAddressMutex.value || !prev) return
if (
!isSameLogisticsAddress(
formData.pickupAddress,
formData.pickupLng,
formData.pickupLat,
curr[0],
curr[1],
curr[2]
)
) {
return
}
if (
isSameLogisticsAddress(
formData.pickupAddress,
formData.pickupLng,
formData.pickupLat,
prev[0],
prev[1],
prev[2]
)
) {
return
}
ElMessage.warning(MSG_SAME_ADDRESS)
suppressAddressMutex.value = true
formData.destinationAddress = prev[0] || ''
formData.destinationLng = prev[1]
formData.destinationLat = prev[2]
nextTick(() => {
suppressAddressMutex.value = false
})
}
)
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,
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: resolveDefaultPackageSpec(productId),
deliveryMethod: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
...brandMeta
}
const [enriched] = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm([row])
)
rows.push(...createStopProductsFromPlanProducts([enriched]))
}
return rows
}
if (target.side === 'delivery') {
const index = target.deliveryIndex
const delivery = deliveryForms.value[index]
if (!delivery) return
const cid = String(delivery?.contracts?.[0]?.contractId || '').trim()
const contractRows = cid ? contractProductRowsCache.value[cid] || [] : []
const existing = delivery.products || []
const existingKeys = new Set(
existing.map((row) => productPackageKey(row.productId, row.packageSpec)).filter(Boolean)
)
const countByPid = new Map<string, number>()
for (const row of existing) {
const pid = String(row.productId || '').trim()
if (!pid) continue
countByPid.set(pid, (countByPid.get(pid) || 0) + 1)
}
const added = (await buildRows()).filter((row) => {
const pid = String(row.productId || '').trim()
if (!pid) return false
const key = productPackageKey(row.productId, row.packageSpec)
const specs = packageSpecsForProduct(contractRows, pid)
// 同品多包装:可按包装份数追加空包装行,便于分别选包装
if (!normalizePackageSpec(row.packageSpec) && specs.length > 1) {
const count = countByPid.get(pid) || 0
if (count >= specs.length) return false
countByPid.set(pid, count + 1)
return true
}
if (key && existingKeys.has(key)) return false
if (key) existingKeys.add(key)
countByPid.set(pid, (countByPid.get(pid) || 0) + 1)
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()
}
function mapPlanProductToFormRow(item: PlanProduct, index: number): PlanFormProductRow {
return {
id: item.id,
productId: String(item.productId || index),
productNo: item.productNo || String(item.productId || index),
productName: item.productName || '',
brandMode: item.brandMode || undefined,
brandId: item.brandId || undefined,
brandName: item.brandName || undefined,
model: item.model,
categoryName: item.categoryName,
unit: item.unit || '吨',
quantity: Number(item.quantity || item.inboundEstimateQty || item.outboundEstimateQty || 0),
actualQty: 0,
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
taxRate: undefined,
taxPrice: item.taxPrice ?? item.inboundPrice ?? item.outboundPrice,
taxSubtotal: item.taxSubtotal ?? item.inboundAmount ?? item.outboundAmount,
taxAmount: item.taxAmount,
remark: item.remark,
isExplosive: Number(
item.isExplosive ?? (item as { is_explosive?: number }).is_explosive ?? 0
),
isToxic: Number(item.isToxic ?? (item as { is_toxic?: number }).is_toxic ?? 0),
purchaseCertUrl: item.purchaseCertUrl,
transportCertUrl: item.transportCertUrl,
legalUseCertUrl: item.legalUseCertUrl
}
}
function mapPlanProductToCopyFormRow(item: PlanProduct, index: number): PlanFormProductRow {
const row = mapPlanProductToFormRow(item, index)
return {
...row,
id: undefined,
actualQty: 0,
purchaseCertUrl: undefined,
transportCertUrl: undefined,
legalUseCertUrl: undefined
}
}
function mapProductsToSubmitPayload() {
return formData.products.map((item) => {
prepareLineAliasAndBrandForSubmit(item)
return {
...(item.id ? { id: item.id } : {}),
productId: item.productId,
productNo: item.productNo,
productName: item.productName,
brandMode: item.brandMode || undefined,
brandId: item.brandMode === 'required' ? item.brandId || undefined : undefined,
brandName: item.brandMode === 'required' ? item.brandName || undefined : undefined,
model: item.model,
unit: item.unit,
categoryName: item.categoryName,
packageSpec: item.packageSpec,
deliveryMethod: item.deliveryMethod,
quantity: item.quantity,
actualQty: 0,
taxPrice: item.taxPrice,
taxAmount: item.taxAmount,
taxSubtotal: item.taxSubtotal,
remark: item.remark,
isExplosive: Number(item.isExplosive || 0),
isToxic: Number(item.isToxic || 0),
purchaseCertUrl: item.purchaseCertUrl || undefined,
transportCertUrl: item.transportCertUrl || undefined,
legalUseCertUrl: item.legalUseCertUrl || undefined
}
})
}
function buildTransportBlock(options?: { allowClear?: boolean }) {
if (!formData.vehicleId) {
if (!options?.allowClear) return undefined
return {
vehicleId: '',
freightType: formData.freightType
}
}
return {
vehicleId: formData.vehicleId,
driverId: selectedDriverId.value || undefined,
escortId: selectedEscortId.value || undefined,
carrierId: formData.carrierId || undefined,
distanceKm: formData.distanceKm ?? undefined,
freightType: formData.freightType,
transportQty:
formData.freightType === 'by_weight'
? (freightCalc.transportQty.value ?? undefined)
: undefined,
freightPrice:
formData.freightType === 'by_weight'
? (freightCalc.freightPrice.value ?? undefined)
: undefined,
freightAmount: freightCalc.freightAmount.value ?? undefined
}
}
function buildPlanCommonPayload(options?: { allowClearTransport?: boolean }): PlanUpdatePayload {
const firstDelivery = deliveryForms.value[0]
return {
createDate: formData.createDate,
planDate: formData.planDate || null,
belongsCompany: formData.belongsCompany,
planType: formData.planType || '',
supplierId: loadForm.value.customerId || undefined,
supplierName: loadForm.value.customerName || undefined,
destinationId: firstDelivery?.customerId || undefined,
destinationName: firstDelivery?.customerName || undefined,
customerId: firstDelivery?.customerId || undefined,
customerName: firstDelivery?.customerName || undefined,
contactName: formData.contactName || undefined,
contactPhone: formData.contactPhone || undefined,
hasFreight: formData.hasFreight,
pickupAddress: loadForm.value.address || undefined,
pickupLng: loadForm.value.lng,
pickupLat: loadForm.value.lat,
pickupTime: loadForm.value.loadTime || undefined,
pickupContact: loadForm.value.contact || undefined,
pickupNotes: loadForm.value.notes || undefined,
destinationAddress: firstDelivery?.address || undefined,
destinationLng: firstDelivery?.lng,
destinationLat: firstDelivery?.lat,
deliveryTime: firstDelivery?.deliveryTime || undefined,
deliveryContact: firstDelivery?.contact || undefined,
destinationNotes: firstDelivery?.notes || undefined,
followUserId: formData.followUserId || undefined,
followUserName: formData.followUserName || undefined,
remark: formData.remark || undefined,
load: buildLoadPayload(loadForm.value),
deliveries: buildDeliveriesPayload(deliveryForms.value),
products: mapProductsToSubmitPayload(),
transport: buildTransportBlock({ allowClear: options?.allowClearTransport })
}
}
async function restoreFreightFromTransport(plan: Plan) {
if (!plan.transportId) {
if (plan.freightAmount) {
freightCalc.freightAmount.value = Number(plan.freightAmount)
}
return
}
try {
const transportRes = await transportApi.get(plan.transportId)
const transport = (transportRes.data ?? transportRes) as {
distanceKm?: number
freightType?: string
freightPrice?: number
transportQty?: number
freightAmount?: number
carrierId?: string
driverId?: string
escortId?: string
}
formData.distanceKm = transport.distanceKm != null ? Number(transport.distanceKm) : null
formData.freightType = transport.freightType || 'by_weight'
if (transport.carrierId) {
formData.carrierId = transport.carrierId
}
if (transport.driverId) {
selectedDriverId.value = transport.driverId
}
if (transport.escortId) {
selectedEscortId.value = transport.escortId
}
freightCalc.freightPrice.value =
transport.freightPrice != null ? Number(transport.freightPrice) : null
freightCalc.freightAmount.value =
transport.freightAmount != null ? Number(transport.freightAmount) : null
freightCalc.freightAmountTouched.value = false
} catch {
if (plan.freightAmount) {
freightCalc.freightAmount.value = Number(plan.freightAmount)
}
}
}
function getUnitLabel(unit: string) {
return dictStore.getLabel('crm_product_unit', unit) || unit
}
function formatDriverOptionLabel(driver: Personnel) {
const phone = driver.phoneNo ? ` (${driver.phoneNo})` : ''
const affiliation = getPersonnelAffiliationLabel(driver.personnelAffiliation)
const suffix = affiliation ? ` · ${affiliation}` : ''
return `${driver.personnelName}${phone}${suffix}`
}
async function performArrangeAfterSave(planId: string): Promise<boolean> {
const detailRes = await planApi.get(planId)
const plan = pickBxData<Plan>(detailRes)
if (!plan?.id) {
ElMessage.error('获取运输计划失败,无法安排运输')
return false
}
loadedPlanSnapshot.value = plan
if (!formData.vehicleId) {
ElMessage.warning('安排运输前请选择车辆')
return false
}
const rawProducts = Array.isArray(plan.products) ? plan.products : []
const enrichedProducts = await enrichPlanProductsHazardFromCrm(rawProducts)
const certMessage = buildMissingHazardCertReadyMessage(plan, enrichedProducts)
if (certMessage) {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法安排运输', {
type: 'warning'
})
return false
}
const isRearrange = plan.transportStatus === 'arranged'
const dibangSelected = planHasAnyDibangSelected(plan, loadForm.value, deliveryForms.value)
let historical = { hasHistoricalDibang: false, hasHistoricalSiteLinks: false }
if (isRearrange) {
historical = await loadArrangeHistoricalFlags(planId)
}
try {
await confirmBeforePlanArrange({
isRearrange,
plan,
hasHistoricalDibang: historical.hasHistoricalDibang,
hasHistoricalSiteLinks: historical.hasHistoricalSiteLinks,
hasAnyDibangSelected: dibangSelected
})
} catch (error) {
if (error === 'cancel') return false
throw error
}
const payload = buildArrangePayloadFromPlanForm({
plan,
vehicleId: formData.vehicleId,
driverId: selectedDriverId.value,
escortId: selectedEscortId.value,
carrierId: formData.carrierId,
loadForm: loadForm.value,
deliveryForms: deliveryForms.value,
products: rawProducts,
remark: formData.remark,
distanceKm: formData.distanceKm,
freightType: formData.freightType,
hasFreight: formData.hasFreight,
freightCalc,
getUnitLabel
})
try {
await submitPlanArrange(planId, payload)
} catch {
return false
}
ElMessage.success(isRearrange ? '运输调整成功' : '运输安排成功')
innerVisible.value = false
emit('success', { planId, arranged: true })
return true
}
function resetForm() {
loadedTransportStatus.value = 'pending'
loadedFollowStatus.value = ''
loadedPlanSnapshot.value = null
vehicleBundle.value = null
selectedDriverId.value = ''
selectedEscortId.value = ''
const d = todayYmd()
Object.assign(formData, {
createDate: d,
planDate: '',
belongsCompany: 'shanxia',
planType: '',
followUserId: resolveCurrentUserId(),
followUserName: resolveCurrentUserName(),
supplierId: '',
supplierName: '',
supplierContracts: [],
destinationId: '',
destinationName: '',
customerId: '',
customerName: '',
demanderContracts: [],
contactName: '',
contactPhone: '',
hasFreight: 0,
pickupAddress: '',
pickupLng: null as number | null,
pickupLat: null as number | null,
pickupTime: '',
pickupContact: '',
pickupNotes: '',
destinationAddress: '',
destinationLng: null as number | null,
destinationLat: null as number | null,
deliveryTime: '',
deliveryContact: '',
destinationNotes: '',
vehicleId: '',
carrierId: '',
distanceKm: null,
freightType: 'by_weight',
remark: '',
products: []
})
syncCommittedPartyIds()
resetStopForms()
freightCalc.resetFreightCalc()
basicFormRef.value?.clearValidate()
}
/** 计划日期变更时,装货/各送货时间跟随;仍可单独调整 */
function handlePlanDateChange(val: string | null) {
const nextVal = val || ''
loadForm.value.loadTime = nextVal
deliveryForms.value = deliveryForms.value.map((row) => ({
...row,
deliveryTime: nextVal
}))
}
function handleOpened() {
loadMasterData().then(async () => {
if (props.planId) {
await loadPlanForEdit(props.planId)
} else if (props.copyFromPlanId) {
await loadPlanForCopy(props.copyFromPlanId)
} else {
resetForm()
}
})
}
function handleCancel() {
if (isPageVariant.value) {
emit('close')
return
}
innerVisible.value = false
}
onMounted(() => {
if (isPageVariant.value) handleOpened()
})
async function loadPlanForEdit(planId: string) {
detailLoading.value = true
suppressAddressMutex.value = true
suppressStopProductSync.value = true
try {
resetForm()
const res = await planApi.get(planId)
const data = (res.data ?? res) as Plan
Object.assign(formData, {
createDate: data.createDate || formatDate(data.createTime) || todayYmd(),
planDate: data.planDate || '',
belongsCompany: data.belongsCompany,
planType: data.planType || '',
followUserId: data.followUserId || resolveCurrentUserId(),
followUserName: data.followUserName || resolveCurrentUserName(),
contactName: data.contactName || '',
contactPhone: data.contactPhone || '',
hasFreight: data.hasFreight ?? 0,
vehicleId: data.vehicleId || '',
remark: data.remark || ''
})
applyStopsFromPlan(data)
loadedTransportStatus.value = data.transportStatus || 'pending'
loadedFollowStatus.value = data.followStatus || ''
loadedPlanSnapshot.value = data
syncCommittedPartyIds()
formData.products = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm((data.products || []).map(mapPlanProductToFormRow))
)
bootstrapStopsFromPlanProducts()
if (data.vehicleId) {
await ensureSelectedVehicleOption(data.vehicleId)
await handleVehicleChange(data.vehicleId, { syncStopDibang: false })
}
if (data.driverId) {
selectedDriverId.value = data.driverId
}
if (data.escortId) {
selectedEscortId.value = data.escortId
}
freightCalc.resetFreightCalc()
await restoreFreightFromTransport(data)
if (selectedDriverId.value) {
await ensureSelectedDriverOption(selectedDriverId.value)
}
if (selectedEscortId.value) {
await ensureSelectedEscortOption(selectedEscortId.value)
}
if (formData.carrierId) {
await ensureSelectedCarrierOption(formData.carrierId)
}
syncSelectedCustomerOptions()
} finally {
await nextTick()
suppressAddressMutex.value = false
suppressStopProductSync.value = false
detailLoading.value = false
}
}
async function loadPlanForCopy(planId: string) {
detailLoading.value = true
suppressAddressMutex.value = true
suppressStopProductSync.value = true
try {
resetForm()
const res = await planApi.get(planId)
const data = (res.data ?? res) as Plan
Object.assign(formData, {
createDate: todayYmd(),
planDate: data.planDate || '',
belongsCompany: data.belongsCompany,
planType: data.planType || '',
followUserId: resolveCurrentUserId(),
followUserName: resolveCurrentUserName(),
contactName: data.contactName || '',
contactPhone: data.contactPhone || '',
hasFreight: data.hasFreight ?? 0,
remark: data.remark || ''
})
applyStopsFromPlan(data)
// 复制为新计划:不回传源计划段/段产品 id,避免误 upsert
loadForm.value = {
...loadForm.value,
id: undefined,
products: loadForm.value.products.map((row) => ({
...row,
id: undefined,
planProductId: undefined
}))
}
deliveryForms.value = deliveryForms.value.map((row) => ({
...row,
id: undefined,
products: row.products.map((product) => ({
...product,
id: undefined,
planProductId: undefined
}))
}))
loadedTransportStatus.value = 'pending'
loadedFollowStatus.value = ''
syncCommittedPartyIds()
formData.products = await enrichPlanProductLinesAlias(
await enrichPlanProductsHazardFromCrm(
(data.products || []).map(mapPlanProductToCopyFormRow)
)
)
bootstrapStopsFromPlanProducts()
formData.vehicleId = ''
formData.carrierId = ''
selectedDriverId.value = ''
selectedEscortId.value = ''
vehicleBundle.value = null
reapplyStopDibangByVehiclePlate('')
freightCalc.resetFreightCalc()
syncSelectedCustomerOptions()
} catch (e) {
const message = e instanceof Error ? e.message : '加载源计划失败'
ElMessage.error(message.includes('加载') ? message : `加载源计划失败:${message}`)
emit('update:visible', false)
} finally {
await nextTick()
suppressAddressMutex.value = false
suppressStopProductSync.value = false
detailLoading.value = false
}
}
function handleClosed() {
resetForm()
}
async function loadMasterData() {
await Promise.all([
searchVehicles(''),
searchCarriers(''),
searchDrivers(''),
searchEscorts(''),
loadDefaultCustomerOptions()
])
}
function resolveDefaultDriver(personnelList: VehiclePersonnelRow[]) {
const drivers = personnelList.filter((item) => item.role === 'driver' && item.personnelId)
const defaultDriver = drivers.find((item) => item.isDefault === 1) || drivers[0]
selectedDriverId.value = defaultDriver?.personnelId || ''
}
function resolveDefaultEscort(personnelList: VehiclePersonnelRow[]) {
const escorts = personnelList.filter((item) => item.role === 'escort' && item.personnelId)
const defaultEscort = escorts.find((item) => item.isDefault === 1) || escorts[0]
selectedEscortId.value = defaultEscort?.personnelId || ''
}
async function handleVehicleChange(vehicleId: string, opts?: { syncStopDibang?: boolean }) {
const syncStopDibang = opts?.syncStopDibang !== false
vehicleBundle.value = null
selectedDriverId.value = ''
selectedEscortId.value = ''
if (!vehicleId) {
formData.carrierId = ''
if (syncStopDibang) reapplyStopDibangByVehiclePlate('')
return
}
const vehicle = vehicleList.value.find((item) => item.id === vehicleId)
if (vehicle?.carrierId) {
formData.carrierId = vehicle.carrierId
} else {
formData.carrierId = ''
}
vehicleLoading.value = true
try {
vehicleBundle.value = await loadVehicleViewBundle(vehicleId)
if (vehicleBundle.value.detail?.carrierId) {
formData.carrierId = vehicleBundle.value.detail.carrierId
}
resolveDefaultDriver(vehicleBundle.value.personnelList || [])
resolveDefaultEscort(vehicleBundle.value.personnelList || [])
if (selectedDriverId.value) {
await ensureSelectedDriverOption(selectedDriverId.value)
}
if (selectedEscortId.value) {
await ensureSelectedEscortOption(selectedEscortId.value)
}
} finally {
vehicleLoading.value = false
}
if (syncStopDibang) {
const plate =
(vehicle?.licensePlate || '').trim() ||
(vehicleBundle.value?.detail?.licensePlate || '').trim()
reapplyStopDibangByVehiclePlate(plate)
}
}
async function handleVehicleLibrarySelect(vehicle: Vehicle) {
if (!vehicle?.id) return
await ensureSelectedVehicleOption(vehicle.id)
formData.vehicleId = vehicle.id
await handleVehicleChange(vehicle.id)
if (vehicle.carrierId) {
await ensureSelectedCarrierOption(vehicle.carrierId)
}
}
async function handlePasteRecognizeDone(result?: PasteCommitResult) {
if (!result?.vehicleId) return
await handleVehicleLibrarySelect({ id: result.vehicleId } as Vehicle)
}
async function handleCarrierLibrarySelect(carrier: Carrier) {
if (!carrier?.id) return
await ensureSelectedCarrierOption(carrier.id)
formData.carrierId = carrier.id
}
function validateBusinessRules() {
if (!loadForm.value.customerId || !String(loadForm.value.customerName || '').trim()) {
ElMessage.warning('请选择装货客户')
return false
}
if (!String(loadForm.value.loadTime || '').trim()) {
ElMessage.warning('请选择装货时间')
return false
}
if (!deliveryForms.value.length) {
ElMessage.warning('请至少添加一段送货信息')
return false
}
for (let i = 0; i < deliveryForms.value.length; i += 1) {
const row = deliveryForms.value[i]
if (!row.customerId || !String(row.customerName || '').trim()) {
ElMessage.warning(`送货段#${i + 1}请选择客户`)
return false
}
if (!String(row.deliveryTime || '').trim()) {
ElMessage.warning(`送货段#${i + 1}请选择送货时间`)
return false
}
if (
loadForm.value.customerId &&
row.customerId &&
String(loadForm.value.customerId) === String(row.customerId)
) {
ElMessage.warning(MSG_SAME_CUSTOMER)
return false
}
}
if (!loadForm.value.products.length) {
ElMessage.warning('请至少添加一条装货产品')
return false
}
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
) {
const products = loadForm.value.products || []
if (products.length) {
const allowed = new Set(
loadContractProductIds.value.map((id) => String(id).trim()).filter(Boolean)
)
if (!allowed.size) {
ElMessage.warning('所选合同暂无产品明细,或合同明细加载失败,请重选合同后再保存')
return false
}
const orphan = products.some((row) => !allowed.has(String(row.productId || '').trim()))
if (orphan) {
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 ((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
}
/** 绑合同时:合同产品有包装选项则包装必填且须在选项内 */
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 (
!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 validateStopProductsForArrange() {
const gap = findFirstStopProductArrangeGap({
loadProducts: loadForm.value.products,
deliveries: deliveryForms.value
})
if (gap) {
ElMessage.warning(gap)
return false
}
return true
}
/** 安排运输前必填:创建日期、计划日期、归属公司、单据类型、跟进人 */
function getArrangePrerequisiteGaps(): string[] {
const missing: string[] = []
if (!String(formData.createDate || '').trim()) missing.push('创建日期')
if (!String(formData.planDate || '').trim()) missing.push('计划日期')
if (!String(formData.belongsCompany || '').trim()) missing.push('归属公司')
if (!String(formData.planType || '').trim()) missing.push('单据类型')
if (
!String(formData.followUserId || '').trim() &&
!String(formData.followUserName || '').trim()
) {
missing.push('跟进人')
}
return missing
}
async function handleSubmit(mode: 'draft' | 'save' | 'saveAndArrange' | 'rearrange' = 'save') {
// 必须在 await validate 之前置位,避免连点并发多次 createFull
if (submitLoading.value) return
submitLoading.value = true
try {
if (mode === 'draft') {
const companyOk = !!(formData.belongsCompany || '').trim()
const dateOk = !!(formData.createDate || '').trim()
if (!companyOk || !dateOk) {
ElMessage.warning('暂存请至少填写归属公司和创建日期')
await basicFormRef.value
?.validateField?.(['belongsCompany', 'createDate'])
.catch(() => undefined)
return
}
mirrorPlanProductsFromLoad()
const draftPayload = {
...buildPlanCommonPayload({ allowClearTransport: isEdit.value }),
saveMode: 'draft' as const,
hasFreight: formData.hasFreight
}
let planId = props.planId || ''
if (isEdit.value) {
if (!props.planId) return
await planApi.update(props.planId, draftPayload)
planId = props.planId
} else {
const res = await planApi.createFull(
draftPayload as Parameters<typeof planApi.createFull>[0]
)
const created = pickBxData<Plan>(res)
planId = String(created?.id || '')
if (!planId) {
ElMessage.error('暂存成功但未返回计划 ID')
return
}
}
ElMessage.success('暂存成功')
innerVisible.value = false
emit('success', { planId, arranged: false })
return
}
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') {
const missing = getArrangePrerequisiteGaps()
if (missing.length) {
ElMessage.warning(`安排运输前请先填写:${missing.join('、')}`)
return
}
if (!formData.vehicleId) {
ElMessage.warning('安排运输前请选择车辆')
return
}
if (!validateStopProductsForArrange()) return
}
mirrorPlanProductsFromLoad()
const commonPayload = {
...buildPlanCommonPayload({ allowClearTransport: isEdit.value }),
saveMode: 'formal' as const
}
let planId = props.planId || ''
const wasCreate = !isEdit.value
if (isEdit.value) {
if (!props.planId) return
const res = await planApi.update(props.planId, commonPayload)
planId = props.planId
showPlanSaveWarnings(pickBxData<Plan>(res))
if (mode === 'save') {
ElMessage.success('保存成功')
}
} else {
const res = await planApi.createFull({
...commonPayload,
hasFreight: formData.hasFreight
} as Parameters<typeof planApi.createFull>[0])
const created = pickBxData<Plan>(res)
planId = String(created?.id || '')
showPlanSaveWarnings(created)
if (!planId) {
ElMessage.error('创建成功但未返回计划 ID')
return
}
if (mode === 'save') {
ElMessage.success('创建成功')
}
}
if ((mode === 'saveAndArrange' || mode === 'rearrange') && planId) {
const arranged = await performArrangeAfterSave(planId)
if (!arranged) {
// 计划已落库;关闭新建窗,避免停留在创建态再次点击又生成一张
if (wasCreate) {
ElMessage.warning(
'计划已创建成功,但未能安排运输。请至列表查看;若因危化证未就绪,请先完成【危化品证上传】后再安排。'
)
} else {
ElMessage.warning('计划已保存,但未能安排运输。请稍后在列表中重试安排。')
}
innerVisible.value = false
emit('success', { planId, arranged: false })
}
return
}
innerVisible.value = false
emit('success', { planId, arranged: false })
} finally {
submitLoading.value = false
}
}
</script>
<style scoped lang="scss">
.mp-plan-form-title {
margin: 0 0 12px;
font-size: 18px;
font-weight: 700;
color: #087f57;
}
.mp-plan-form-page {
:deep(.form-grid-5),
:deep(.form-grid-4),
:deep(.form-grid-3),
:deep(.form-grid-2) {
grid-template-columns: 1fr;
}
.pl-plan-transport-row--two,
.pl-plan-transport-row--three,
.pl-plan-transport-row--four,
.pl-plan-kv-grid-party {
grid-template-columns: 1fr;
}
.pl-plan-transport-row__vehicle,
.pl-plan-transport-row__driver,
.pl-plan-transport-row__escort {
width: auto;
max-width: none;
}
.pl-plan-freight-form {
min-height: 0;
}
}
.mp-plan-form-footer {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 0 calc(8px + env(safe-area-inset-bottom));
background: #f5f8f7;
}
.pl-plan-transport-row {
align-items: start;
:deep(.el-select) {
width: 100%;
}
}
.pl-plan-transport-row--two {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 12px 20px;
}
.pl-plan-transport-row--three {
display: grid;
grid-template-columns: max-content minmax(240px, 340px) minmax(280px, 1fr);
gap: 12px 16px;
}
.pl-plan-transport-row--four {
display: grid;
grid-template-columns: max-content minmax(180px, 240px) minmax(180px, 240px) minmax(240px, 1fr);
gap: 12px 16px;
}
.pl-plan-transport-row__vehicle {
width: max-content;
max-width: 100%;
}
.pl-plan-transport-row__driver,
.pl-plan-transport-row__escort {
min-width: 0;
max-width: 280px;
}
.pl-plan-transport-row__carrier {
min-width: 0;
}
.pl-plan-vehicle-with-library,
.pl-plan-carrier-with-library {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.pl-plan-vehicle-with-library__select {
flex: 0 0 220px;
width: 220px;
}
.pl-plan-carrier-with-library__select {
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: none;
}
.pl-plan-vehicle-with-library__btn,
.pl-plan-carrier-with-library__btn {
flex: 0 0 auto;
white-space: nowrap;
}
.pl-plan-form-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.pl-plan-form-section {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.pl-plan-form-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.pl-plan-form-section-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.pl-plan-form-section-title {
margin: 0 0 12px;
padding-left: 10px;
font-size: 14px;
font-weight: 600;
color: var(--section-bar-title-color, var(--primary-dark, #059669));
border-left: 3px solid var(--section-bar-border-color, var(--primary, #10b981));
line-height: 1;
}
.pl-plan-form-section-head .pl-plan-form-section-title {
margin-bottom: 0;
}
.customer-select-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.customer-select-row__select {
flex: 1 1 auto;
min-width: 0;
}
.customer-select-row__search {
flex: 0 0 auto;
padding: 0 6px;
}
.customer-option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.customer-option-row__meta {
font-size: 12px;
color: #94a3b8;
}
.pl-plan-subtitle {
margin: 16px 0 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.pl-plan-party-block .pl-plan-subtitle {
margin: 0 0 10px;
}
.pl-plan-party-select-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.pl-plan-party-select-row__customer {
flex: 1.4 1 0;
min-width: 0;
margin-bottom: 0 !important;
}
.pl-plan-party-select-row__contract {
flex: 1 1 0;
min-width: 160px;
margin-bottom: 0 !important;
}
.pl-plan-contract-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
}
.pl-plan-contract-trigger__hint {
font-size: 12px;
color: #64748b;
white-space: nowrap;
&.is-warn {
color: #d97706;
}
}
.pl-plan-form-empty-hint,
.text-muted {
margin: 0 0 8px;
font-size: 13px;
color: #64748b;
}
.pl-plan-kv-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px 16px;
}
.pl-plan-kv-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pl-plan-kv {
min-width: 0;
}
.pl-plan-kv-span {
grid-column: 1 / -1;
}
.pl-plan-party-row,
.pl-plan-logistics-row {
display: flex;
align-items: stretch;
gap: 0;
margin-top: 8px;
}
.pl-plan-logistics-row {
margin-top: 34px;
}
.pl-plan-party-row .pl-plan-party-block,
.pl-plan-logistics-row .pl-plan-logistics-block,
.pl-plan-logistics-row .pl-plan-logistics-block-wrap {
flex: 1 1 0;
min-width: 0;
}
.pl-plan-logistics-block-wrap {
position: relative;
}
.pl-plan-logistics-swap-btn {
position: absolute;
top: -32px;
right: 0;
z-index: 1;
height: 26px;
padding: 0 4px;
}
.pl-plan-flow-bridge {
flex: 0 0 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 8px;
}
.pl-plan-flow-bridge__label {
font-size: 12px;
font-weight: 600;
color: var(--primary-dark, #168f45);
letter-spacing: 0.06em;
padding: 4px 10px;
border-radius: 999px;
background: #e6f8ed;
border: 1px solid #a8e0b8;
line-height: 1.2;
}
.pl-plan-flow-bridge__arrow {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
color: var(--primary-dark, #168f45);
background: linear-gradient(145deg, #e6f8ed 0%, #d4f0de 100%);
border: 1px solid #a8e0b8;
box-shadow: 0 2px 8px rgba(28, 168, 81, 0.14);
svg {
width: 22px;
height: 22px;
display: block;
}
}
.pl-plan-kv-grid-party {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.pl-plan-logistics-block {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
> .pl-plan-subtitle {
margin: 0 0 8px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
.form-grid-2 {
gap: 8px 12px;
}
}
.pl-plan-party-block {
min-width: 0;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
overflow: hidden;
}
.pl-plan-party-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
@media (max-width: 1200px) {
.pl-plan-kv-grid,
.pl-plan-kv-grid-party {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.pl-plan-party-row,
.pl-plan-logistics-row {
flex-direction: column;
}
.pl-plan-flow-bridge {
flex: 0 0 auto;
flex-direction: row;
gap: 12px;
padding: 8px 0;
}
.pl-plan-flow-bridge__arrow {
transform: rotate(90deg);
}
}
@media (max-width: 768px) {
.pl-plan-kv-grid,
.pl-plan-kv-grid-party {
grid-template-columns: 1fr;
}
}
.pl-plan-freight-section-head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
margin-bottom: 10px;
}
.pl-plan-freight-section-subtitle {
font-size: 13px;
font-weight: 600;
color: #334155;
}
.pl-plan-freight-switch {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
cursor: pointer;
}
.pl-plan-freight-switch-label {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pl-plan-freight-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-select),
:deep(.el-input-number) {
width: 100%;
}
}
.pl-plan-freight-form {
min-height: min(720px, calc(100vh - 180px));
}
</style>
<style lang="scss">
.el-dialog.pl-plan-form-dialog.dialog-form-layout {
width: min(2000px, calc(100vw - 32px)) !important;
max-width: min(2000px, calc(100vw - 32px));
height: var(--dialog-max-height, calc(100vh - 40px));
max-height: var(--dialog-max-height, calc(100vh - 40px));
}
.el-dialog.pl-plan-form-dialog.dialog-form-layout .el-dialog__body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
max-height: none;
}
.el-dialog.pl-plan-form-dialog.dialog-form-layout .pl-plan-form-body {
flex: 1;
min-height: 0;
}
</style>