ce1ce02551
变更记录详情统一为居中分页弹窗,并去掉经办人等信息行。 客商评分支持删除、提交前自评校验、复评后才能审批,以及 0 分提交。 车辆外廓尺寸和载质量单位改到输入框内,认证审核标签按四字换行,使用部门必填并默认当前部门。 临时额度的 -1 占位金额显示为空。新增项目编号可留空由系统生成。 修复缓存总单页在打开新增项目时误请求缺少 id 的详情接口。 详情页状态与运输方式标签样式统一。 Co-authored-by: Cursor <cursoragent@cursor.com>
1045 lines
60 KiB
Vue
1045 lines
60 KiB
Vue
<template>
|
||
<div v-loading="loading" class="master-dispatch">
|
||
<section v-if="master" class="master-overview">
|
||
<div class="overview-heading">
|
||
<div>
|
||
<h2>多联总单调度 <span>|</span> {{ master.masterNo }}</h2>
|
||
<el-tag :type="statusType(master.businessStatus)" class="status-text">{{ statusName(master.businessStatus) }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="overview-content">
|
||
<dl class="overview-meta">
|
||
<div><dt>客户</dt><dd>{{ master.customerName || '-' }}</dd></div>
|
||
<div><dt>合同编号</dt><dd>{{ master.contractNo || '-' }}</dd></div>
|
||
<div><dt>项目</dt><dd>{{ master.projectName || '-' }}</dd></div>
|
||
<div><dt>货物名称</dt><dd class="overview-meta__plain-value">{{ goodsNames }}</dd></div>
|
||
<div><dt>货物类型</dt><dd class="overview-meta__plain-value">{{ goodsTypes }}</dd></div>
|
||
<div><dt>运输周期</dt><dd>{{ dateRange }}</dd></div>
|
||
</dl>
|
||
<div class="route-overview">
|
||
<template v-for="(route, index) in routes" :key="route.segmentNo">
|
||
<div class="route-overview__node">
|
||
<span :class="['route-badge', index === 0 ? 'start' : 'middle']">{{ index === 0 ? '起' : '经' }}</span>
|
||
<el-tooltip :content="route.departureName || '-'" placement="top" :show-after="120">
|
||
<strong>{{ route.departureName || '-' }}</strong>
|
||
</el-tooltip>
|
||
<el-tooltip :content="route.departureAddress || '-'" placement="top" :show-after="120">
|
||
<small>{{ route.departureAddress || '-' }}</small>
|
||
</el-tooltip>
|
||
<el-tooltip :content="contactText(route.departureContact, route.departurePhone)" placement="top" :show-after="120">
|
||
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
|
||
</el-tooltip>
|
||
</div>
|
||
<div class="route-overview__line"></div>
|
||
<div v-if="index === routes.length - 1" class="route-overview__node">
|
||
<span class="route-badge end">终</span>
|
||
<el-tooltip :content="route.arrivalName || '-'" placement="top" :show-after="120">
|
||
<strong>{{ route.arrivalName || '-' }}</strong>
|
||
</el-tooltip>
|
||
<el-tooltip :content="route.arrivalAddress || '-'" placement="top" :show-after="120">
|
||
<small>{{ route.arrivalAddress || '-' }}</small>
|
||
</el-tooltip>
|
||
<el-tooltip :content="contactText(route.arrivalContact, route.arrivalPhone)" placement="top" :show-after="120">
|
||
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-for="(route, index) in routes" :key="route.segmentNo" class="dispatch-section" :data-segment="route.segmentNo">
|
||
<header class="segment-header">
|
||
<div>
|
||
<el-checkbox v-model="route.selected">
|
||
<span class="segment-checkbox-label">
|
||
<span>{{ route.segmentNo }}:</span>
|
||
<el-tooltip
|
||
:content="`${dispatchLocationName(route, 'departure')} → ${dispatchLocationName(route, 'arrival')}`"
|
||
placement="top"
|
||
>
|
||
<span class="segment-checkbox-path"
|
||
>{{ dispatchLocationName(route, 'departure') }} → {{ dispatchLocationName(route, 'arrival') }}</span
|
||
>
|
||
</el-tooltip>
|
||
</span>
|
||
</el-checkbox>
|
||
</div>
|
||
<div>总量 {{ formatQuantity(totalQuantity) }} {{ quantityUnitLabel }},已调度 <em>{{ formatQuantity(dispatchedQuantity(route)) }} {{ quantityUnitLabel }}</em>,剩余 <em>{{ formatQuantity(remaining(route)) }} {{ quantityUnitLabel }}</em></div>
|
||
</header>
|
||
<div v-show="route.selected" class="segment-content">
|
||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form">
|
||
<el-row :gutter="16">
|
||
<el-col :span="8">
|
||
<el-form-item label="运输方式" required class="transport-type-field"><el-input :model-value="route.transportType" disabled /></el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="发货地址" required><el-input :model-value="addressText(route.departureName, route.departureAddress)" disabled /></el-form-item>
|
||
</el-col>
|
||
<el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.departurePhone" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="4"><el-form-item :label="dateStartLabel(route)"><el-date-picker v-model="route.estimatedStartTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="收货地址" required><el-input :model-value="addressText(route.arrivalName, route.arrivalAddress)" disabled /></el-form-item>
|
||
</el-col>
|
||
<el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.arrivalContact" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.arrivalPhone" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="4"><el-form-item :label="dateEndLabel(route)"><el-date-picker v-model="route.estimatedEndTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col>
|
||
</el-row>
|
||
</el-form>
|
||
|
||
<div class="goods-heading"><h3>货物信息</h3><div><el-link type="primary" @click="addGoods(route)">新增货物</el-link></div></div>
|
||
<el-table :data="route.goods" border class="goods-table">
|
||
<el-table-column type="index" label="序号" width="64" />
|
||
<el-table-column min-width="180"
|
||
><template #header
|
||
><span>货物类型<span class="goods-required-mark">*</span></span></template
|
||
><template #default="{ row }"
|
||
><el-autocomplete
|
||
v-model="row.cargoType"
|
||
class="goods-table__cargo-type"
|
||
clearable
|
||
:debounce="300"
|
||
placeholder="请输入货物类型"
|
||
:fetch-suggestions="fetchCargoTypeSuggestions"
|
||
@select="item => handleCargoTypeSelect(route, row, item)"
|
||
@change="value => handleCargoTypeChange(route, row, value)"
|
||
/></template>
|
||
</el-table-column>
|
||
<el-table-column min-width="180"
|
||
><template #header
|
||
><span>货物名称<span class="goods-required-mark">*</span></span></template
|
||
><template #default="{ row }"
|
||
><el-select
|
||
v-model="row.sourceIndex"
|
||
class="goods-table__cargo-name"
|
||
placeholder="请选择总单货物"
|
||
@change="selectGoods(route, row)"
|
||
><el-option
|
||
v-for="item in masterGoodsByCargoType(row)"
|
||
:key="item.sourceIndex"
|
||
:label="item.cargoName"
|
||
:value="item.sourceIndex" /></el-select
|
||
></template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="dispatchQuantity"
|
||
column-key="dispatchQuantity"
|
||
width="200"
|
||
class-name="goods-table__dispatch"
|
||
><template #header
|
||
><span>本次数量<span class="goods-required-mark">*</span></span></template
|
||
><template #default="{ row }"
|
||
><el-input
|
||
:model-value="row.dispatchQuantity"
|
||
inputmode="decimal"
|
||
placeholder="请输入"
|
||
@input="value => handleDispatchQuantityInput(route, row, value)" /></template
|
||
></el-table-column>
|
||
<el-table-column prop="quantityUnit" width="110"><template #header><span>数量单位<span class="goods-required-mark">*</span></span></template></el-table-column>
|
||
<el-table-column prop="packageType" label="包装" min-width="110" />
|
||
<el-table-column prop="brand" label="品牌" min-width="110" />
|
||
<el-table-column prop="specification" label="规格" min-width="110" />
|
||
<el-table-column prop="model" label="型号" min-width="110" />
|
||
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
|
||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ $index }"><el-link type="danger" @click="removeGoods(route, $index)">删除</el-link></template></el-table-column>
|
||
</el-table>
|
||
|
||
<template v-if="route.documentType === '运单'">
|
||
<div class="goods-heading"><h3>任务信息</h3></div>
|
||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-type-form"><el-form-item label="承运类型" required><el-radio-group v-model="route.carrierType" @change="value => handleCarrierTypeChange(route, value)"><el-radio-button label="承运商" /><el-radio-button label="自运" /></el-radio-group></el-form-item></el-form>
|
||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-form">
|
||
<el-row :gutter="16">
|
||
<template v-if="isRoad(route)">
|
||
<el-col :span="6"><el-form-item label="承运商" :required="route.carrierType !== '自运'"><el-select :model-value="route.carrierType !== '自运' ? route.carrierContractId : route.carrierName" filterable clearable placeholder="请选择" :loading="carrierLoading" @change="value => handleCarrierContractChange(route, value)"><el-option v-for="item in carrierOptions" :key="carrierOptionValue(item)" :label="carrierOptionLabel(item)" :value="route.carrierType !== '自运' ? carrierOptionValue(item) : item.carrierName" /></el-select></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="司机" :required="route.carrierType !== '承运商'"><el-autocomplete :ref="element => setDriverInput(route.segmentNo, element)" v-model="route.driverName" :debounce="300" :fetch-suggestions="fetchDriverSuggestions" clearable placeholder="请输入司机" :loading="driverLoading" @select="item => handleDriverSuggestionSelect(route, item)" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item :label="route.carrierType === '承运商' ? '手机号' : '司机手机号'" :required="route.carrierType !== '承运商'"><el-input v-model="route.driverPhone" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="车牌号" required><el-input v-model="route.vehicleNo" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="挂车车牌号"><el-input v-model="route.trailerVehicleNo" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人"><el-autocomplete v-model="route.escortName" :debounce="300" :fetch-suggestions="fetchEscortSuggestions" clearable placeholder="请输入" :loading="escortLoading" @select="item => handleEscortSuggestionSelect(route, item)" /></el-form-item></el-col>
|
||
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人手机号"><el-input v-model="route.escortPhone" placeholder="请输入" /></el-form-item></el-col>
|
||
</template>
|
||
<template v-else>
|
||
<el-col :span="6"><el-form-item label="承运商" :required="route.carrierType !== '自运'"><el-select :model-value="route.carrierType !== '自运' ? route.carrierContractId : route.carrierName" filterable clearable placeholder="请选择" :loading="carrierLoading" @change="value => handleCarrierContractChange(route, value)"><el-option v-for="item in carrierOptions" :key="carrierOptionValue(item)" :label="carrierOptionLabel(item)" :value="route.carrierType !== '自运' ? carrierOptionValue(item) : item.carrierName" /></el-select></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="船/航/班列号" required><el-input v-model="route.vehicleNo" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="船长"><el-input v-model="route.captainName" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="联系电话"><el-input v-model="route.driverPhone" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="箱号"><el-input v-model="route.containerNo" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="舱位"><el-input v-model="route.cabinNo" placeholder="请输入" /></el-form-item></el-col>
|
||
</template>
|
||
<el-col :span="6"><el-form-item label="里程(km)"><el-input :model-value="route.mileage" inputmode="numeric" maxlength="10" placeholder="请输入" @input="value => handleMileageInput(route, value)" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="备注"><el-input v-model="route.remark" maxlength="200" show-word-limit placeholder="请输入" /></el-form-item></el-col>
|
||
</el-row>
|
||
</el-form>
|
||
</template>
|
||
|
||
<div class="freight-heading"><h3>运费信息</h3></div>
|
||
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form freight-form">
|
||
<el-row v-for="(group, index) in freightGroups(route)" :key="group.key" :gutter="16">
|
||
<el-col :span="6">
|
||
<el-form-item label="单价">
|
||
<el-input :model-value="freightGroupUnitPrice(group)" inputmode="decimal" placeholder="请输入" @input="value => handleFreightGroupUnitPriceInput(group, value)" @clear="() => handleFreightGroupUnitPriceInput(group, '')">
|
||
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option label="元/吨" value="元/吨" /><el-option label="元/件" value="元/件" /><el-option label="元/方" value="元/方" /></el-select></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="数量">
|
||
<el-input :model-value="formatQuantity(freightGroupQuantity(group))" readonly>
|
||
<template #append><el-select :model-value="group.quantityUnit" class="freight-unit-select" @change="value => handleFreightGroupQuantityUnitChange(route, group, value)"><el-option v-for="unit in quantityUnitOptions" :key="unit" :label="unit" :value="unit" /></el-select></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="运费">
|
||
<el-input :model-value="formatAmount(freightGroupAmount(group))" readonly><template #suffix>{{ currencyLabel(route.currency) }}</template></el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row v-if="route.freightItems.length" :gutter="16">
|
||
<el-col :span="6"><el-form-item label="运费合计"><el-input :model-value="formatAmount(freightTotal(route))" readonly><template #suffix>{{ currencyLabel(route.currency) }}</template></el-input></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="其他费用合计"><el-input :model-value="route.otherFeeTotal" inputmode="decimal" placeholder="请输入" @input="value => handleOtherFeeTotalInput(route, value)" /></el-form-item></el-col>
|
||
</el-row>
|
||
</el-form>
|
||
<div class="segment-actions"><el-button plain @click="addToPending(route, true)">加入并继续调度</el-button><el-button plain @click="addToPending(route, false)">加入调度清单</el-button></div>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="dispatch-bottom">
|
||
<section class="pending-panel">
|
||
<div class="pending-bar">
|
||
<div class="pending-summary">
|
||
<strong>待提交 <em>{{ pendingBatchCount }}</em> 条 <span class="pending-summary-text">({{ pendingSegmentSummary }})</span></strong>
|
||
<el-link type="primary" @click="pendingExpanded = !pendingExpanded">{{ pendingExpanded ? '收起待调度列表' : '展开待调度列表' }}</el-link>
|
||
</div>
|
||
<div class="pending-actions"><el-button @click="$emit('back')">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">确认调度</el-button></div>
|
||
</div>
|
||
<div v-show="pendingExpanded" class="pending-list">
|
||
<template v-for="(items, segmentNo) in pendingGroups" :key="segmentNo">
|
||
<h3>{{ segmentNo }}:{{ dispatchLocationName(items[0], 'departure') }} → {{ dispatchLocationName(items[0], 'arrival') }}</h3>
|
||
<el-table :data="items" border><el-table-column type="index" label="序号" width="70" /><el-table-column label="货物" min-width="220"><template #default="{ row }">{{ pendingGoodsText(row) }}</template></el-table-column><el-table-column label="本次数量" min-width="150"><template #default="{ row }">{{ pendingQuantityText(row) }}</template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="180" /><el-table-column prop="driverName" label="司机" width="130" /><el-table-column prop="vehicleNo" label="车牌" width="130" /><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><div class="table-actions"><el-link type="primary" @click="editPending(row)">编辑</el-link><el-link type="danger" @click="removePendingBatch(row)">删除</el-link></div></template></el-table-column></el-table>
|
||
</template>
|
||
<el-empty v-if="!pending.length" description="暂无待提交调度条目" :image-size="60" />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div v-if="pendingExpanded" class="pending-list-spacer" aria-hidden="true"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import * as api from '@/api/business/master-order';
|
||
import { getList as getDriverList } from '@/api/transportCapacity/driver';
|
||
import { isMobile } from '@/utils/validate';
|
||
|
||
const unwrapRecords = res => {
|
||
const data = res?.data || res;
|
||
if (Array.isArray(data)) return data;
|
||
if (Array.isArray(data?.records)) return data.records;
|
||
if (Array.isArray(data?.data)) return data.data;
|
||
if (Array.isArray(data?.data?.records)) return data.data.records;
|
||
return [];
|
||
};
|
||
|
||
export default {
|
||
props: { id: [String, Number] },
|
||
emits: ['back'],
|
||
data() {
|
||
return { loading: false, submitting: false, master: null, routes: [], pending: [], pendingExpanded: false, editingId: null, driverInputs: {}, carrierOptions: [], driverOptions: [], carrierLoading: false, driverLoading: false, escortLoading: false, quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'] };
|
||
},
|
||
computed: {
|
||
totalQuantity() { return (this.master?.goods || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
|
||
quantityUnitLabel() { return this.master?.goods?.[0]?.quantityUnit || '吨'; },
|
||
goodsNames() { return (this.master?.goods || []).map(item => item.cargoName).filter(Boolean).join('、') || '-'; },
|
||
goodsTypes() { return [...new Set((this.master?.goods || []).map(item => item.cargoType).filter(Boolean))].join('、') || '-'; },
|
||
masterGoods() { return (this.master?.goods || []).map((item, sourceIndex) => ({ ...item, sourceIndex, label: [item.cargoName, item.cargoType].filter(Boolean).join(' / ') })); },
|
||
cargoTypeSelectOptions() {
|
||
return [...new Set((this.master?.goods || []).map(item => item.cargoType).filter(Boolean))];
|
||
},
|
||
dateRange() { return this.master?.planStartTime && this.master?.planEndTime ? `${String(this.master.planStartTime).slice(0, 10)} ~ ${String(this.master.planEndTime).slice(0, 10)}` : '-'; },
|
||
pendingGroups() {
|
||
return this.pending.reduce((groups, item, index) => {
|
||
const segmentRows = (groups[item.segmentNo] ||= []);
|
||
const batchKey = item.batchNo || item.id || `${item.segmentNo}-${index}`;
|
||
let batch = segmentRows.find(row => row.batchKey === batchKey);
|
||
if (!batch) {
|
||
batch = { ...item, batchKey, items: [] };
|
||
segmentRows.push(batch);
|
||
}
|
||
batch.items.push(item);
|
||
return groups;
|
||
}, {});
|
||
},
|
||
pendingBatchCount() {
|
||
return Object.values(this.pendingGroups).reduce((count, rows) => count + rows.length, 0);
|
||
},
|
||
pendingSegmentSummary() {
|
||
return this.routes.map(route => {
|
||
const batches = this.pendingGroups[route.segmentNo] || [];
|
||
if (!batches.length) return `${route.segmentNo}:无`;
|
||
const documentSummary = batches.reduce((result, batch) => {
|
||
result[batch.documentType] = (result[batch.documentType] || 0) + 1;
|
||
return result;
|
||
}, {});
|
||
return `${route.segmentNo}:${Object.entries(documentSummary).map(([type, count]) => `${type} x ${count}`).join('、')}`;
|
||
}).join(' | ');
|
||
},
|
||
},
|
||
async mounted() {
|
||
await Promise.all([this.load(), this.loadCarrierOptions(), this.loadDriverOptions()]);
|
||
this.applySelfOperatedCarrierDefaults();
|
||
},
|
||
methods: {
|
||
async load() {
|
||
if (!this.id) return;
|
||
this.loading = true;
|
||
try {
|
||
const res = await api.getDetail(this.id);
|
||
this.master = res.data?.data || res.data || res;
|
||
await this.loadContractCurrency();
|
||
this.routes = this.dispatchRouteNodes().map((node, index) => this.createRoute(node, index));
|
||
} finally { this.loading = false; }
|
||
},
|
||
normalizeCurrencyValue(value) {
|
||
const raw = typeof value === 'object'
|
||
? value.dictKey || value.value || value.code || value.dictValue || ''
|
||
: value;
|
||
const normalized = String(raw || 'RMB').trim().toUpperCase();
|
||
return normalized.includes(' - ') ? normalized.split(' - ')[0].trim() : normalized;
|
||
},
|
||
async loadContractCurrency() {
|
||
const directCurrency = this.master?.settlementCurrency || this.master?.settlementCurrencyCode || this.master?.currency;
|
||
if (!this.master?.projectId || !this.master?.contractId) {
|
||
this.master.settlementCurrency = this.normalizeCurrencyValue(directCurrency || 'RMB');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await api.getProjectContracts(this.master.projectId);
|
||
const contract = unwrapRecords(response).find(item => String(item.id) === String(this.master.contractId));
|
||
this.master.customerContractPartyB = contract?.partyB || this.master.customerContractPartyB || '';
|
||
this.master.settlementCurrency = this.normalizeCurrencyValue(
|
||
directCurrency || contract?.settlementCurrency || contract?.settlementCurrencyCode || contract?.currency || 'RMB'
|
||
);
|
||
} catch {
|
||
this.master.customerContractPartyB = '';
|
||
this.master.settlementCurrency = 'RMB';
|
||
}
|
||
},
|
||
dispatchRouteNodes() {
|
||
const routes = Array.isArray(this.master?.routes) ? this.master.routes : [];
|
||
if (routes.length) return routes;
|
||
return [{
|
||
segmentNo: '段1',
|
||
transportType: this.master?.transportType || '',
|
||
departureName: this.master?.arrivalName || '',
|
||
departureAddress: this.master?.arrivalAddress || '',
|
||
departureContact: this.master?.arrivalContact || '',
|
||
departurePhone: this.master?.arrivalPhone || '',
|
||
}];
|
||
},
|
||
createRoute(node, index) {
|
||
const routeNodes = this.dispatchRouteNodes();
|
||
const previous = index ? routeNodes[index - 1] : this.master;
|
||
const route = {
|
||
...node,
|
||
selected: false,
|
||
documentType: '运单',
|
||
carrierType: node.carrierType || this.master?.carrierType || '承运商',
|
||
carrierContractId: '',
|
||
currency: this.normalizeCurrencyValue(this.master?.settlementCurrency || 'RMB'),
|
||
otherFeeTotal: '',
|
||
freightItems: [],
|
||
departureName: previous.departureName || '', departureAddress: previous.departureAddress || '', departureContact: previous.departureContact || '', departurePhone: previous.departurePhone || '',
|
||
departureCityName: previous.departureCityName || previous.arrivalCityName || '', departureDistrictName: previous.departureDistrictName || previous.arrivalDistrictName || '', departureSiteCode: previous.departureSiteCode || previous.arrivalSiteCode || '',
|
||
arrivalName: node.departureName || this.master.arrivalName || '', arrivalAddress: node.departureAddress || this.master.arrivalAddress || '', arrivalContact: node.departureContact || '', arrivalPhone: node.departurePhone || '',
|
||
arrivalCityName: node.departureCityName || node.arrivalCityName || this.master.arrivalCityName || '', arrivalDistrictName: node.departureDistrictName || node.arrivalDistrictName || this.master.arrivalDistrictName || '', arrivalSiteCode: node.departureSiteCode || node.arrivalSiteCode || this.master.arrivalSiteCode || '',
|
||
goods: (this.master.goods || []).map((goods, sourceIndex) => this.createGoodsRow(goods, sourceIndex)),
|
||
};
|
||
this.syncFreightItems(route);
|
||
return route;
|
||
},
|
||
masterGoodsByCargoType(row = {}) {
|
||
const cargoType = String(row.cargoType || '').trim();
|
||
if (!cargoType) return this.masterGoods;
|
||
const matched = this.masterGoods.filter(item => item.cargoType === cargoType);
|
||
return matched.length ? matched : this.masterGoods;
|
||
},
|
||
fetchCargoTypeSuggestions(queryString, callback) {
|
||
const keyword = String(queryString || '').trim().toLowerCase();
|
||
const options = this.cargoTypeSelectOptions.map(item => ({ value: item }));
|
||
callback(
|
||
keyword ? options.filter(item => String(item.value).toLowerCase().includes(keyword)) : options
|
||
);
|
||
},
|
||
handleCargoTypeSelect(route, row, item = {}) {
|
||
this.handleCargoTypeChange(route, row, item.value || '');
|
||
},
|
||
handleCargoTypeChange(route, row, value) {
|
||
row.cargoType = String(value ?? row.cargoType ?? '').trim();
|
||
const matched = this.masterGoods.filter(item => item.cargoType === row.cargoType);
|
||
const firstGoods = matched[0];
|
||
if (firstGoods) {
|
||
row.sourceIndex = firstGoods.sourceIndex;
|
||
this.selectGoods(route, row);
|
||
} else {
|
||
Object.assign(row, {
|
||
sourceIndex: undefined,
|
||
cargoName: '',
|
||
quantity: '',
|
||
quantityUnit: '',
|
||
packageType: '',
|
||
brand: '',
|
||
specification: '',
|
||
model: '',
|
||
materialCode: '',
|
||
});
|
||
}
|
||
this.syncFreightItems(route);
|
||
},
|
||
isRoad(route) {
|
||
const values = [
|
||
route.transportTypeName,
|
||
route.transportTypeLabel,
|
||
route.transportModeName,
|
||
route.transportMode,
|
||
route.transportType,
|
||
];
|
||
const type = values
|
||
.map(value => {
|
||
if (value && typeof value === 'object') {
|
||
return value.label || value.name || value.dictValue || value.value || value.code || '';
|
||
}
|
||
return value || '';
|
||
})
|
||
.join(' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
return type.includes('公路') || type.includes('道路') || type.includes('陆运') || type.includes('汽车') || type === 'road';
|
||
},
|
||
formatRoadLocation(value) {
|
||
const text = String(value || '').replace(/\s+/g, '');
|
||
if (!text) return '';
|
||
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
|
||
const cityMatch = withoutProvince.match(/.+?(?:市|州(?!市)|盟(?!市))/);
|
||
if (!cityMatch) return text;
|
||
const city = cityMatch[0];
|
||
const districtSource = withoutProvince.slice(city.length);
|
||
const districtMatch = districtSource.match(/^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/);
|
||
if (!districtMatch) return city;
|
||
const district = `${districtMatch[0]}${districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''}`;
|
||
return `${city} ${district}`;
|
||
},
|
||
dispatchLocationName(route = {}, field) {
|
||
const name = String(route[`${field}Name`] || '').trim();
|
||
const address = String(route[`${field}Address`] || '').trim();
|
||
const formattedName = this.formatRoadLocation(name);
|
||
if (/(?:市|州|盟)/.test(formattedName)) return formattedName;
|
||
const regionalName = /(?:省|自治区|特别行政区|市|州|盟|区|县|旗)/.test(name);
|
||
if (name && !regionalName) return name;
|
||
const city = String(route[`${field}CityName`] || '').trim();
|
||
const district = String(route[`${field}DistrictName`] || '').trim();
|
||
const formattedAddress = this.formatRoadLocation(address);
|
||
if (/(?:市|州|盟)/.test(formattedAddress)) return formattedAddress;
|
||
return [city, district].filter(Boolean).join(' ') || formattedName || formattedAddress || '-';
|
||
},
|
||
isWater(route) {
|
||
const type = String(route.transportType || '').trim().toLowerCase();
|
||
return type === 'river' || type === 'water' || type === 'sl' || type.includes('水路') || type.includes('水运');
|
||
},
|
||
dispatchedQuantity(route) {
|
||
return this.quantityNumber(route.dispatchedQuantity) + this.pendingSegmentQuantity(route);
|
||
},
|
||
remaining(route) {
|
||
return Math.max(
|
||
0,
|
||
this.totalQuantity -
|
||
this.dispatchedQuantity(route)
|
||
);
|
||
},
|
||
formatQuantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
|
||
formatAmount(value) { return Number(value || 0).toFixed(2); },
|
||
dispatchWeight(route) { return (route.goods || []).reduce((sum, item) => sum + Number(item.dispatchQuantity || 0), 0); },
|
||
freightAmount(item = {}) { return Number(item.unitPrice || 0) * Number(item.quantity || 0); },
|
||
freightGroups(route = {}) {
|
||
const groups = [];
|
||
const groupMap = new Map();
|
||
(route.freightItems || []).forEach((item, index) => {
|
||
const quantityUnit = String(item.quantityUnit || '').trim();
|
||
const key = quantityUnit || `__empty_${index}`;
|
||
let group = groupMap.get(key);
|
||
if (!group) {
|
||
group = { key, quantityUnit, rows: [] };
|
||
groupMap.set(key, group);
|
||
groups.push(group);
|
||
}
|
||
group.rows.push(item);
|
||
});
|
||
return groups;
|
||
},
|
||
freightGroupQuantity(group = {}) { return (group.rows || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
|
||
freightGroupUnitPrice(group = {}) {
|
||
const prices = (group.rows || []).map(item => String(item.unitPrice ?? '').trim()).filter(Boolean);
|
||
return !prices.length || prices.some(price => price !== prices[0]) ? '' : prices[0];
|
||
},
|
||
freightGroupPriceUnit(group = {}) { return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : ''); },
|
||
freightGroupAmount(group = {}) { return (group.rows || []).reduce((sum, item) => sum + this.freightAmount(item), 0); },
|
||
handleFreightGroupUnitPriceInput(group, value) {
|
||
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
|
||
const normalized = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
|
||
(group.rows || []).forEach(item => { item.unitPrice = normalized; });
|
||
},
|
||
handleFreightGroupPriceUnitChange(group, value) { (group.rows || []).forEach(item => { item.priceUnit = value || ''; }); },
|
||
handleFreightGroupQuantityUnitChange(route, group, value) {
|
||
(group.rows || []).forEach(item => {
|
||
item.quantityUnit = value || '';
|
||
const goods = (route.goods || []).find(row => String(row.sourceIndex) === String(item.sourceIndex));
|
||
if (goods) goods.quantityUnit = value || '';
|
||
});
|
||
},
|
||
freightTotal(route) {
|
||
const goodsFreight = (route.freightItems || []).reduce(
|
||
(sum, item) => sum + this.freightAmount(item),
|
||
0
|
||
);
|
||
return goodsFreight + Number(route.otherFeeTotal || 0);
|
||
},
|
||
buildFreightJson(route) {
|
||
return JSON.stringify({
|
||
currency: route.currency || 'RMB',
|
||
totalFreightAmount: this.freightTotal(route),
|
||
otherFreightAmount: route.otherFeeTotal || '',
|
||
freightItems: (route.freightItems || []).map(item => ({
|
||
...item,
|
||
freightAmount: this.freightAmount(item),
|
||
})),
|
||
});
|
||
},
|
||
currencyLabel(value) { return ({ CNY: '人民币', RMB: '人民币', USD: '美元', EUR: '欧元' })[value] || value || '人民币'; },
|
||
quantityNumber(value) { return Number(value || 0); },
|
||
goodsKey(goods = {}, includeUnit = true) {
|
||
return [
|
||
goods.cargoName || '',
|
||
goods.cargoType || '',
|
||
includeUnit ? goods.quantityUnit || '' : '',
|
||
].join('\u0000');
|
||
},
|
||
masterGoodsIndex(goods = {}) {
|
||
if (goods.sourceIndex !== undefined && goods.sourceIndex !== null && goods.sourceIndex !== '') {
|
||
return Number(goods.sourceIndex);
|
||
}
|
||
return (this.master?.goods || []).findIndex(item =>
|
||
item.cargoName === goods.cargoName &&
|
||
item.cargoType === goods.cargoType &&
|
||
(!goods.quantityUnit || !item.quantityUnit || item.quantityUnit === goods.quantityUnit)
|
||
);
|
||
},
|
||
isSameGoods(left = {}, right = {}) {
|
||
const leftIndex = this.masterGoodsIndex(left);
|
||
const rightIndex = this.masterGoodsIndex(right);
|
||
if (leftIndex >= 0 && rightIndex >= 0) return leftIndex === rightIndex;
|
||
return left.cargoName === right.cargoName && left.cargoType === right.cargoType && left.quantityUnit === right.quantityUnit;
|
||
},
|
||
dispatchedGoodsQuantity(route, row) {
|
||
const dispatchedGoods = route.dispatchedGoods || {};
|
||
const exactValue = dispatchedGoods[this.goodsKey(row)] ?? dispatchedGoods[this.goodsKey(row, false)];
|
||
if (exactValue !== undefined && exactValue !== null) return this.quantityNumber(exactValue);
|
||
const matchedValue = Object.entries(dispatchedGoods).find(([key]) => {
|
||
const [cargoName, cargoType] = String(key).split('\u0000');
|
||
return cargoName === row.cargoName && cargoType === row.cargoType;
|
||
})?.[1];
|
||
return this.quantityNumber(matchedValue);
|
||
},
|
||
baseGoodsRemainingQuantity(route, row) {
|
||
return Math.max(0, this.quantityNumber(row.quantity) - this.dispatchedGoodsQuantity(route, row));
|
||
},
|
||
pendingSegmentQuantity(route) {
|
||
return this.pending.reduce((sum, item) => {
|
||
if (!this.isSameRouteSegment(route, item)) return sum;
|
||
return sum + this.quantityNumber(item.quantity);
|
||
}, 0);
|
||
},
|
||
validatePreviousSegmentCompletedQuantity(route, goods) {
|
||
const routeIndex = this.routes.indexOf(route);
|
||
if (routeIndex <= 0) return true;
|
||
const previousCompletedQuantity = this.quantityNumber(
|
||
this.routes[routeIndex - 1]?.arrivedQuantity
|
||
);
|
||
const currentDispatchQuantity = (goods || []).reduce(
|
||
(sum, item) => sum + this.quantityNumber(item.dispatchQuantity),
|
||
0
|
||
);
|
||
const dispatchTotal =
|
||
this.quantityNumber(route.dispatchedQuantity) +
|
||
this.pendingSegmentQuantity(route) +
|
||
currentDispatchQuantity;
|
||
if (dispatchTotal <= previousCompletedQuantity) return true;
|
||
this.$message.warning(
|
||
`${route.segmentNo}调度总量不能大于上一段已完成数量(${this.formatQuantity(previousCompletedQuantity)} ${this.quantityUnitLabel})`
|
||
);
|
||
return false;
|
||
},
|
||
isSameRouteSegment(route = {}, item = {}) {
|
||
const routeSegment = route.segmentNo || route.relationNo || '';
|
||
const itemSegment = item.segmentNo || item.relationNo || '';
|
||
return String(routeSegment) === String(itemSegment);
|
||
},
|
||
handleDispatchQuantityInput(route, row, value) {
|
||
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
|
||
row.dispatchQuantity =
|
||
decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 3)}` : integer;
|
||
this.syncFreightItems(route);
|
||
},
|
||
validatePendingTotals() {
|
||
for (const route of this.routes) {
|
||
const items = this.pending.filter(item => this.isSameRouteSegment(route, item));
|
||
if (!items.length) continue;
|
||
if (!this.validatePreviousSegmentCompletedQuantity(route, [])) return false;
|
||
|
||
const totals = new Map();
|
||
for (const item of items) {
|
||
const index = this.masterGoodsIndex(item);
|
||
const key = index >= 0 ? `i:${index}` : this.goodsKey(item);
|
||
totals.set(key, (totals.get(key) || 0) + this.quantityNumber(item.quantity));
|
||
}
|
||
for (const [key, qty] of totals.entries()) {
|
||
if (!key.startsWith('i:')) continue;
|
||
const sourceIndex = Number(key.slice(2));
|
||
const masterGoods = this.master?.goods?.[sourceIndex];
|
||
if (!masterGoods) continue;
|
||
const available = this.baseGoodsRemainingQuantity(route, {
|
||
...masterGoods,
|
||
sourceIndex,
|
||
});
|
||
if (qty > available) {
|
||
this.$message.warning(
|
||
`${route.segmentNo}「${masterGoods.cargoName || ''}」调度总量不能超过剩余数量(${this.formatQuantity(available)} ${masterGoods.quantityUnit || this.quantityUnitLabel})`
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
},
|
||
handleFreightUnitPriceInput(item, value) {
|
||
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
|
||
item.unitPrice = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
|
||
},
|
||
handleOtherFeeTotalInput(route, value) {
|
||
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
|
||
route.otherFeeTotal = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
|
||
},
|
||
validateFreightItems(route) {
|
||
for (const [index, item] of (route.freightItems || []).entries()) {
|
||
const fields = [
|
||
['priceUnit', '单价单位'],
|
||
['quantityUnit', '数量单位'],
|
||
];
|
||
const emptyField = fields.find(
|
||
([prop]) => item[prop] === undefined || item[prop] === null || item[prop] === ''
|
||
);
|
||
if (emptyField) {
|
||
this.$message.warning(`第${index + 1}条货物的${emptyField[1]}不能为空`);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
},
|
||
validateDispatchGoods(route, goods) {
|
||
for (const item of goods) {
|
||
const index = (route.goods || []).indexOf(item) + 1;
|
||
const fields = [
|
||
[!String(item.cargoName || '').trim() || item.sourceIndex === undefined || item.sourceIndex === null || item.sourceIndex === '', '货物名称'],
|
||
[!String(item.cargoType || '').trim(), '货物类型'],
|
||
[item.dispatchQuantity === undefined || item.dispatchQuantity === null || item.dispatchQuantity === '' || Number(item.dispatchQuantity) <= 0, '本次数量'],
|
||
[!String(item.quantityUnit || '').trim(), '数量单位'],
|
||
];
|
||
const emptyField = fields.find(([isEmpty]) => isEmpty);
|
||
if (emptyField) {
|
||
this.$message.warning(`第${index}条货物的${emptyField[1]}不能为空`);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
},
|
||
handleFreightQuantityUnitChange(route, freightItem, value) {
|
||
const goods = (route.goods || []).find(item => String(item.sourceIndex) === String(freightItem.sourceIndex));
|
||
if (goods) goods.quantityUnit = value;
|
||
},
|
||
handleMileageInput(route, value) {
|
||
route.mileage = String(value || '').replace(/\D/g, '').slice(0, 10);
|
||
},
|
||
normalizeMileage(value) {
|
||
if (value === undefined || value === null || value === '') return '';
|
||
const mileage = Number(value);
|
||
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
|
||
},
|
||
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
|
||
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
|
||
dateStartLabel(route) { return route.documentType === '计划单' ? '计划开始日期' : '预计发货日期'; },
|
||
dateEndLabel(route) { return route.documentType === '计划单' ? '计划结束日期' : '预计完成日期'; },
|
||
createGoodsRow(goods = {}, sourceIndex) {
|
||
return {
|
||
...goods,
|
||
sourceIndex,
|
||
dispatchQuantity: undefined,
|
||
};
|
||
},
|
||
addGoods(route) {
|
||
const firstGoods = this.masterGoods[0];
|
||
route.goods.push(
|
||
firstGoods
|
||
? this.createGoodsRow(firstGoods, firstGoods.sourceIndex)
|
||
: this.createGoodsRow()
|
||
);
|
||
this.syncFreightItems(route);
|
||
},
|
||
selectGoods(route, row) {
|
||
const source = (this.master.goods || [])[Number(row.sourceIndex)];
|
||
if (!source) return;
|
||
const cargoType = String(row.cargoType || '').trim() || source.cargoType;
|
||
Object.assign(row, this.createGoodsRow(source, Number(row.sourceIndex)), { cargoType });
|
||
this.syncFreightItems(route);
|
||
},
|
||
removeGoods(route, index) { route.goods.splice(index, 1); this.syncFreightItems(route); },
|
||
syncFreightItems(route) {
|
||
const oldItems = route.freightItems || [];
|
||
route.freightItems = (route.goods || [])
|
||
.filter(item => item.sourceIndex !== undefined && item.sourceIndex !== '')
|
||
.map(item => {
|
||
const old = oldItems.find(entry => String(entry.sourceIndex) === String(item.sourceIndex)) || {};
|
||
return {
|
||
...old,
|
||
sourceIndex: item.sourceIndex,
|
||
cargoName: item.cargoName || '',
|
||
quantity: item.dispatchQuantity || '',
|
||
quantityUnit: item.quantityUnit || '',
|
||
unitPrice: old.unitPrice || '',
|
||
priceUnit: old.priceUnit || `元/${item.quantityUnit || '吨'}`,
|
||
};
|
||
});
|
||
},
|
||
handleCarrierTypeChange(route, value) {
|
||
route.carrierType = value;
|
||
route.carrierName = '';
|
||
route.carrierId = '';
|
||
route.carrierContractId = '';
|
||
if (value === '承运商') {
|
||
route.trailerVehicleNo = '';
|
||
route.escortName = '';
|
||
route.escortPhone = '';
|
||
}
|
||
this.loadCarrierOptions().then(() => {
|
||
if (value === '自运') this.applySelfOperatedCarrierDefaults(route);
|
||
});
|
||
},
|
||
handleCarrierContractChange(route, contractId) {
|
||
const contract = this.carrierOptions.find(
|
||
item =>
|
||
[item.contractId, item.carrierName, item.id].some(
|
||
value => String(value || '') === String(contractId || '')
|
||
)
|
||
);
|
||
route.carrierContractId = route.carrierType !== '自运' ? contract?.contractId || '' : '';
|
||
route.carrierId = route.carrierType !== '自运' ? contract?.carrierId || '' : '';
|
||
route.carrierName = contract?.carrierName || '';
|
||
},
|
||
carrierOptionValue(item = {}) {
|
||
return item.contractId || item.id || item.value || item.carrierName || '';
|
||
},
|
||
carrierOptionLabel(item) {
|
||
const duplicateCount = this.carrierOptions.filter(option => option.carrierName === item.carrierName).length;
|
||
return duplicateCount > 1
|
||
? `${item.carrierName}(${item.contractName || item.contractId})`
|
||
: item.carrierName;
|
||
},
|
||
driverOptionLabel(item) { return item.driverName || item.name || ''; },
|
||
driverOptionKey(item) { return item.id || item.driverId || this.driverOptionLabel(item) || item.mobile; },
|
||
handleDriverChange(route, value) {
|
||
const driver = this.driverOptions.find(item => this.driverOptionLabel(item) === value);
|
||
if (driver) {
|
||
route.driverPhone = driver.mobile || driver.driverPhone || driver.phone || '';
|
||
if (String(driver.drivingVehicle || '').trim()) {
|
||
route.vehicleNo = String(driver.drivingVehicle).trim();
|
||
}
|
||
}
|
||
},
|
||
async loadCarrierOptions() {
|
||
this.carrierLoading = true;
|
||
try {
|
||
this.carrierOptions = unwrapRecords(await api.getCarriers(this.id))
|
||
.map(item => ({
|
||
...item,
|
||
contractId: item.contractId || item.carrierContractId || item.id || '',
|
||
carrierName:
|
||
item.partyB ||
|
||
item.partyBName ||
|
||
item.contractPartyB ||
|
||
item.customerContractPartyB ||
|
||
item.carrierName ||
|
||
'',
|
||
carrierId:
|
||
item.partyBId ||
|
||
item.partyBUserId ||
|
||
item.contractPartyBId ||
|
||
item.customerContractPartyBId ||
|
||
item.carrierId ||
|
||
'',
|
||
}))
|
||
.filter(item => item.carrierName);
|
||
} finally {
|
||
this.carrierLoading = false;
|
||
}
|
||
},
|
||
applySelfOperatedCarrierDefaults(targetRoute) {
|
||
const routes = targetRoute ? [targetRoute] : this.routes;
|
||
if (!this.carrierOptions.length) {
|
||
const carrierName = String(this.master?.customerContractPartyB || '').trim();
|
||
if (carrierName) {
|
||
this.carrierOptions = [{
|
||
id: `customer-contract-${carrierName}`,
|
||
contractId: `customer-contract-${carrierName}`,
|
||
carrierName,
|
||
}];
|
||
}
|
||
}
|
||
routes.forEach(route => {
|
||
if (route.carrierType !== '自运') return;
|
||
if (this.carrierOptions.length) {
|
||
const current = this.carrierOptions.find(
|
||
item => String(item.carrierName || '') === String(route.carrierName || '')
|
||
);
|
||
route.carrierName = current?.carrierName || this.carrierOptions[0].carrierName || '';
|
||
}
|
||
route.carrierContractId = '';
|
||
});
|
||
},
|
||
async loadDriverOptions(keyword = '') {
|
||
this.driverLoading = true;
|
||
try {
|
||
this.driverOptions = unwrapRecords(
|
||
await getDriverList(1, 20, { driverName: keyword, posts: '司机' })
|
||
);
|
||
} finally {
|
||
this.driverLoading = false;
|
||
}
|
||
},
|
||
fetchDriverSuggestions(queryString, callback) {
|
||
this.loadDriverOptions(String(queryString || '').trim())
|
||
.then(() =>
|
||
callback(
|
||
this.driverOptions.map(item => ({
|
||
...item,
|
||
value: this.driverOptionLabel(item),
|
||
}))
|
||
)
|
||
)
|
||
.catch(() => callback([]));
|
||
},
|
||
handleDriverSuggestionSelect(route, item = {}) {
|
||
route.driverName = this.driverOptionLabel(item);
|
||
route.driverId = item.id || item.driverId || '';
|
||
route.driverPhone = item.mobile || item.driverPhone || item.phone || route.driverPhone || '';
|
||
const drivingVehicle = String(item.drivingVehicle || '').trim();
|
||
if (drivingVehicle) route.vehicleNo = drivingVehicle;
|
||
},
|
||
fetchEscortSuggestions(queryString, callback) {
|
||
const keyword = String(queryString || '').trim();
|
||
this.escortLoading = true;
|
||
getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '押运员' })
|
||
.then(res => {
|
||
callback(
|
||
unwrapRecords(res).map(item => ({
|
||
...item,
|
||
value: this.driverOptionLabel(item),
|
||
}))
|
||
);
|
||
})
|
||
.catch(() => callback([]))
|
||
.finally(() => {
|
||
this.escortLoading = false;
|
||
});
|
||
},
|
||
handleEscortSuggestionSelect(route, item = {}) {
|
||
route.escortName = this.driverOptionLabel(item);
|
||
const phone = item.mobile || item.driverPhone || item.phone || '';
|
||
if (phone) route.escortPhone = phone;
|
||
},
|
||
setDriverInput(segmentNo, element) {
|
||
if (element) this.driverInputs[segmentNo] = element;
|
||
else delete this.driverInputs[segmentNo];
|
||
},
|
||
resetForNextDispatch(route) {
|
||
route.driverName = '';
|
||
route.driverPhone = '';
|
||
route.vehicleNo = '';
|
||
route.captainName = '';
|
||
route.containerNo = '';
|
||
route.cabinNo = '';
|
||
route.trailerVehicleNo = '';
|
||
route.escortName = '';
|
||
route.escortPhone = '';
|
||
route.goods.forEach(item => { item.dispatchQuantity = undefined; });
|
||
this.$nextTick(() => this.driverInputs[route.segmentNo]?.focus());
|
||
},
|
||
addToPending(route, continueDispatch) {
|
||
const goods = route.goods.filter(item => Number(item.dispatchQuantity) > 0);
|
||
if (!goods.length) return this.$message.warning('请填写本次数量');
|
||
if (!this.validateDispatchGoods(route, goods)) return;
|
||
this.syncFreightItems(route);
|
||
if (!this.validateFreightItems(route)) return;
|
||
if (!this.validateRoutePhones(route)) return;
|
||
if (route.documentType === '运单') {
|
||
if (this.isRoad(route)) {
|
||
if (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName || !route.vehicleNo)) return this.$message.warning('请选择承运商并填写车牌号');
|
||
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo)) return this.$message.warning('请补全自运的车辆与人员信息');
|
||
} else if (!route.vehicleNo || (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName))) {
|
||
return this.$message.warning('请补全非公路运输的承运信息');
|
||
}
|
||
}
|
||
const batchNo = `${route.segmentNo}-${Date.now()}`;
|
||
goods.forEach(item => this.pending.push({
|
||
id: `${route.segmentNo}-${item.cargoName}-${Date.now()}-${Math.random()}`,
|
||
batchNo,
|
||
segmentNo: route.segmentNo, relationNo: route.segmentNo, documentType: route.documentType, transportType: route.transportType, carrierType: route.carrierType,
|
||
carrierContractId: route.carrierContractId, carrierName: route.carrierName, driverName: route.driverName, driverPhone: route.driverPhone, vehicleNo: route.vehicleNo, captainName: route.captainName, cabinNo: route.cabinNo, containerNo: route.containerNo, trailerVehicleNo: route.trailerVehicleNo, escortName: route.escortName, escortPhone: route.escortPhone, mileage: this.normalizeMileage(route.mileage), unitPrice: this.freightItemsForGoods(route, item).unitPrice || '', priceUnit: this.freightItemsForGoods(route, item).priceUnit || '', currency: route.currency || 'RMB', freightAmount: this.freightAmount(this.freightItemsForGoods(route, item)), freightTotal: this.freightTotal(route), otherFeeTotal: route.otherFeeTotal || '', freightJson: this.buildFreightJson(route), routeRemark: route.remark,
|
||
departureName: route.departureName, departureAddress: route.departureAddress, departureContact: route.departureContact, departurePhone: route.departurePhone,
|
||
arrivalName: route.arrivalName, arrivalAddress: route.arrivalAddress, arrivalContact: route.arrivalContact, arrivalPhone: route.arrivalPhone,
|
||
estimatedStartTime: route.estimatedStartTime, estimatedEndTime: route.estimatedEndTime,
|
||
sourceIndex: this.masterGoodsIndex(item), cargoName: item.cargoName, cargoType: item.cargoType, quantity: item.dispatchQuantity, quantityUnit: item.quantityUnit, packageType: item.packageType, brand: item.brand, specification: item.specification, model: item.model, materialCode: item.materialCode, deviceCode: item.deviceCode, remark: item.remark,
|
||
}));
|
||
this.editingId = null;
|
||
this.pendingExpanded = true;
|
||
if (continueDispatch) this.resetForNextDispatch(route);
|
||
else {
|
||
route.goods.forEach(item => { item.dispatchQuantity = undefined; });
|
||
route.selected = false;
|
||
}
|
||
this.$message.success('已加入调度清单');
|
||
},
|
||
pendingGoodsText(batch = {}) {
|
||
return (batch.items || [batch])
|
||
.map(item => [item.cargoName, item.cargoType].filter(Boolean).join(','))
|
||
.join(' | ');
|
||
},
|
||
pendingQuantityText(batch = {}) {
|
||
return (batch.items || [batch])
|
||
.map(item => `${this.formatQuantity(item.quantity)} ${item.quantityUnit || ''}`.trim())
|
||
.join(' | ');
|
||
},
|
||
editPending(batch) {
|
||
const items = batch.items || [batch];
|
||
const first = items[0];
|
||
const route = this.routes.find(routeItem => routeItem.segmentNo === first.segmentNo);
|
||
if (!route) return;
|
||
Object.assign(route, first);
|
||
const editedGoods = items.map(item => {
|
||
const itemSourceIndex = Number(item.sourceIndex);
|
||
const sourceIndex = Number.isInteger(itemSourceIndex) && itemSourceIndex >= 0
|
||
? itemSourceIndex
|
||
: (this.master.goods || []).findIndex(goods => goods.cargoName === item.cargoName && goods.cargoType === item.cargoType);
|
||
let goods = route.goods.find(goodsItem => String(goodsItem.sourceIndex) === String(sourceIndex));
|
||
if (!goods && sourceIndex >= 0) {
|
||
goods = this.createGoodsRow(this.master.goods[sourceIndex], sourceIndex);
|
||
route.goods.push(goods);
|
||
}
|
||
if (goods) goods.dispatchQuantity = item.quantity;
|
||
return { goods, item };
|
||
});
|
||
route.currency = this.normalizeCurrencyValue(
|
||
this.master?.settlementCurrency || route.currency || 'RMB'
|
||
);
|
||
this.syncFreightItems(route);
|
||
editedGoods.forEach(({ goods, item }) => {
|
||
const freightItem = this.freightItemsForGoods(route, goods);
|
||
if (freightItem) {
|
||
freightItem.unitPrice = item.unitPrice || '';
|
||
freightItem.priceUnit = item.priceUnit || freightItem.priceUnit;
|
||
}
|
||
});
|
||
this.removePendingBatch(batch);
|
||
this.editingId = batch.batchKey;
|
||
route.selected = true;
|
||
this.$nextTick(() => document.querySelector(`[data-segment="${first.segmentNo}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||
},
|
||
freightItemsForGoods(route, goods) {
|
||
return (route.freightItems || []).find(item => String(item.sourceIndex) === String(goods?.sourceIndex)) || {};
|
||
},
|
||
removePendingBatch(batch = {}) {
|
||
const ids = new Set((batch.items || [batch]).map(item => item.id));
|
||
this.pending = this.pending.filter(item => !ids.has(item.id));
|
||
if (this.editingId === batch.batchKey) this.editingId = null;
|
||
},
|
||
validateRoutePhones(route = {}) {
|
||
const invalidPhone = [
|
||
[route.departurePhone, '发货联系方式'],
|
||
[route.arrivalPhone, '收货联系方式'],
|
||
[route.driverPhone, '手机号'],
|
||
[route.escortPhone, '押运人手机号'],
|
||
].find(([value]) => String(value || '').trim() && !isMobile(value));
|
||
if (!invalidPhone) return true;
|
||
this.$message.warning(`${invalidPhone[1]}格式不正确`);
|
||
return false;
|
||
},
|
||
async submit() {
|
||
if (!this.pending.length) return this.$message.warning('请加入待提交调度清单');
|
||
if (this.pending.some(item => !this.validateRoutePhones(item))) return;
|
||
if (!this.validatePendingTotals()) return;
|
||
this.submitting = true;
|
||
try { await api.dispatch({ id: this.master.id, dispatches: this.pending.map(({ id, ...item }) => ({ ...item, mileage: this.normalizeMileage(item.mileage) })) }); this.$message.success('调度成功'); this.$emit('back'); } finally { this.submitting = false; }
|
||
},
|
||
statusName(value) { return ({ waiting_dispatch: '待调度', dispatching: '调度中', completed: '调度完成', closed: '调度关闭' })[value] || value || '-'; },
|
||
statusType(value) { return ({ waiting_dispatch: 'warning', dispatching: 'success', completed: 'primary', closed: 'danger' })[value] || 'info'; },
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.master-dispatch { min-height: 100%; padding-bottom: 124px; background: #fff; color: #303133; }
|
||
.pending-list-spacer { height: min(436px, calc(100vh - 130px)); }
|
||
.master-overview, .dispatch-section { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
|
||
.overview-heading, .segment-header, .pending-bar { display: flex; align-items: center; justify-content: space-between; }
|
||
.segment-header :deep(.el-checkbox.is-checked .el-checkbox__label) { font-size: 16px; font-weight: 700; }
|
||
.segment-checkbox-label { display: inline-flex; align-items: center; min-width: 0; }
|
||
.segment-checkbox-path { display: inline-block; white-space: nowrap; }
|
||
.overview-heading { padding: 18px 24px 12px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } }
|
||
.overview-content { display: grid; grid-template-columns: minmax(400px, .9fr) minmax(620px, 1.6fr); gap: 28px; padding: 8px 24px 20px; }
|
||
.overview-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 24px; margin: 0; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { margin: 0; color: #409eff; font-size: 14px; word-break: break-all; } }
|
||
.overview-meta dd.overview-meta__plain-value { color: #303133; }
|
||
.route-overview { display: flex; align-items: flex-start; justify-content: flex-start; padding: 8px 24px 0; overflow-x: auto; }
|
||
.route-overview__node { display: grid; flex: 0 0 auto; min-width: 130px; max-width: 200px; justify-items: center; gap: 6px; text-align: center; padding: 0 4px; strong { font-size: 16px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { color: #606266; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }
|
||
.route-badge { display: inline-flex; width: 32px; height: 32px; align-items: center; justify-content: center; border-radius: 4px; background: #67c23a; color: #fff; font-weight: 600; &.start { background: #409eff; } &.end { background: #e6a23c; } }
|
||
.route-overview__line { flex: 0 1 56px; min-width: 24px; height: 32px; margin-top: 0; border-bottom: 6px solid #e4e7ed; }
|
||
.segment-header { min-height: 46px; padding: 0 20px; border-bottom: 1px solid #eff1f7; font-weight: 600; em { color: #409eff; font-style: normal; } }
|
||
.segment-content { padding: 16px 24px; }
|
||
.dispatch-form { :deep(.el-form-item) { margin-bottom: 14px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
|
||
.transport-type-field :deep(.el-input) { width: 240px; }
|
||
.goods-heading { display: flex; align-items: center; justify-content: space-between; margin: 0 -24px 12px; padding: 14px 24px; border-top: 1px solid #eff1f7; border-bottom: 1px solid #eff1f7; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } span { color: #909399; font-size: 13px; } }
|
||
.freight-heading { margin: 16px 0 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
|
||
.goods-table { :deep(th.el-table__cell), :deep(td.el-table__cell) { border-color: #eff1f7; } :deep(.el-table__row--striped td.el-table__cell) { background: #fafafa; } :deep(.goods-table__dispatch .el-input) { width: 100%; min-width: 0; } :deep(.goods-table__cargo-type), :deep(.goods-table__cargo-name) { width: 100%; } :deep(.goods-table__cargo-type .el-input__inner), :deep(.goods-table__cargo-name .el-select__selected-item) { color: #303133; } }
|
||
.goods-required-mark { margin-left: 2px; color: #f56c6c; }
|
||
.freight-form { :deep(.freight-unit-select) { width: 92px; flex: 0 0 92px; min-width: 0; } }
|
||
.carrier-type-form { margin-top: 16px; }
|
||
.carrier-form { padding-top: 8px; }
|
||
.segment-actions { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||
.dispatch-bottom { position: fixed; z-index: 10; bottom: 0; left: $sidebar_width; width: calc(100% - #{$sidebar_width}); border-top: 1px solid #eff1f7; background: #fff; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04); }
|
||
:global(.avue--collapse) .dispatch-bottom { left: $sidebar_collapse; width: calc(100% - #{$sidebar_collapse}); }
|
||
:global(.avue-layout--horizontal) .dispatch-bottom { left: 0; width: 100%; }
|
||
.pending-panel { background: #fff; }
|
||
.pending-bar { min-height: 58px; padding: 0 32px; em { color: #409eff; font-style: normal; } }
|
||
.pending-summary, .pending-actions { display: flex; align-items: center; }
|
||
.pending-summary { gap: 16px; }
|
||
.pending-summary-text { color: #606266; font-weight: 400; }
|
||
.pending-actions { margin-left: auto; }
|
||
.pending-list { max-height: min(420px, calc(100vh - 130px)); padding: 0 32px 16px; overflow: auto; h3 { margin: 14px 0 10px; font-size: 14px; } }
|
||
.table-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
@media screen and (max-width: 1200px) { .overview-content { grid-template-columns: 1fr; } .route-overview { justify-content: flex-start; } }
|
||
@media screen and (max-width: 992px) { .dispatch-bottom { left: 0; width: 100%; } }
|
||
.route-overview {
|
||
margin-top: -30px !important;
|
||
}
|
||
</style>
|