Files
tms-erp-web/src/views/business/components/master-order-dispatch.vue
T
b2894lxlx ce1ce02551 完善变更记录、客商评分与车辆表单
变更记录详情统一为居中分页弹窗,并去掉经办人等信息行。
客商评分支持删除、提交前自评校验、复评后才能审批,以及 0 分提交。
车辆外廓尺寸和载质量单位改到输入框内,认证审核标签按四字换行,使用部门必填并默认当前部门。
临时额度的 -1 占位金额显示为空。新增项目编号可留空由系统生成。
修复缓存总单页在打开新增项目时误请求缺少 id 的详情接口。
详情页状态与运输方式标签样式统一。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 20:13:44 +08:00

1045 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>