656 lines
42 KiB
Vue
656 lines
42 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)">{{ 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>{{ goodsNames }}</dd></div>
|
||
<div><dt>货物类型</dt><dd>{{ 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>
|
||
<strong>{{ route.departureName || '-' }}</strong>
|
||
<small>{{ route.departureAddress || '-' }}</small>
|
||
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
|
||
</div>
|
||
<div class="route-overview__line"></div>
|
||
<div v-if="index === routes.length - 1" class="route-overview__node">
|
||
<span class="route-badge end">终</span>
|
||
<strong>{{ route.arrivalName || '-' }}</strong>
|
||
<small>{{ route.arrivalAddress || '-' }}</small>
|
||
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
|
||
</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" :label="`${route.segmentNo}:${route.departureName} → ${route.arrivalName}`" />
|
||
</div>
|
||
<div>总量 {{ formatQuantity(totalQuantity) }},已调度 <em>{{ formatQuantity(dispatchedQuantity(route)) }}</em>,剩余 <em>{{ formatQuantity(remaining(route)) }}</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" readonly /></el-form-item>
|
||
</el-col>
|
||
<el-col v-if="isRoad(route)" :span="8">
|
||
<el-form-item label="单据类型" required>
|
||
<el-radio-group v-model="route.documentType">
|
||
<el-radio value="计划单">计划单</el-radio><el-radio value="运单">运单</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="发货地址" required><el-input :model-value="addressText(route.departureName, route.departureAddress)" readonly /></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)" required><el-date-picker v-model="route.estimatedStartTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="收货地址" required><el-input :model-value="addressText(route.arrivalName, route.arrivalAddress)" readonly /></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)" required><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 label="货物类型" min-width="180"><template #default="{ row }"><el-cascader v-model="row.cargoTypePath" :options="cargoTypeOptions" :props="cargoTypeCascaderProps" placeholder="请选择货物类型" clearable filterable @change="value => handleCargoTypeChange(route, row, value)" /></template></el-table-column>
|
||
<el-table-column label="货物名称" min-width="180"><template #default="{ row }"><el-select v-model="row.sourceIndex" 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="remainingQuantity" column-key="remainingQuantity" label="剩余数量" width="150" class-name="goods-table__remaining"><template #default="{ row }">{{ formatQuantity(goodsRemainingQuantity(route, row)) }}</template></el-table-column>
|
||
<el-table-column prop="dispatchQuantity" column-key="dispatchQuantity" label="本次数量" width="200" class-name="goods-table__dispatch"><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" label="数量单位" width="110" />
|
||
<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>
|
||
|
||
<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="(item, index) in route.freightItems" :key="item.sourceIndex" :gutter="16">
|
||
<el-col :span="6">
|
||
<el-form-item :label="`单价${index + 1}`" required>
|
||
<el-input :model-value="item.unitPrice" inputmode="decimal" placeholder="请输入" @input="value => handleFreightUnitPriceInput(item, value)">
|
||
<template #append><el-select v-model="item.priceUnit" class="freight-unit-select"><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="`数量${index + 1}`" required>
|
||
<el-input :model-value="formatQuantity(item.quantity)" readonly>
|
||
<template #append><el-select v-model="item.quantityUnit" class="freight-unit-select" @change="value => handleFreightQuantityUnitChange(route, item, 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="`运费${index + 1}`">
|
||
<el-input :model-value="formatAmount(freightAmount(item))" 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-col :span="6"><el-form-item label="币种"><el-select v-model="route.currency"><el-option label="人民币" value="CNY" /><el-option label="美元" value="USD" /><el-option label="欧元" value="EUR" /></el-select></el-form-item></el-col>
|
||
</el-row>
|
||
</el-form>
|
||
|
||
<template v-if="route.documentType === '运单'">
|
||
<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-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">
|
||
<el-col v-if="route.carrierType === '承运商'" :span="6"><el-form-item label="承运商" required><el-select v-model="route.carrierName" filterable remote clearable placeholder="请选择" :remote-method="loadCarrierOptions" :loading="carrierLoading" @visible-change="visible => visible && loadCarrierOptions()"><el-option v-for="item in carrierOptions" :key="carrierOptionKey(item)" :label="carrierOptionLabel(item)" :value="carrierOptionLabel(item)" /></el-select></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="司机" :required="route.carrierType !== '承运商'"><el-select :ref="element => setDriverInput(route.segmentNo, element)" v-model="route.driverName" filterable remote clearable placeholder="请选择" :remote-method="loadDriverOptions" :loading="driverLoading" @visible-change="visible => visible && loadDriverOptions()" @change="value => handleDriverChange(route, value)"><el-option v-for="item in driverOptions" :key="driverOptionKey(item)" :label="driverOptionLabel(item)" :value="driverOptionLabel(item)" /></el-select></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="挂车车牌号" required><el-input v-model="route.trailerVehicleNo" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人" required><el-input v-model="route.escortName" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人手机号" required><el-input v-model="route.escortPhone" placeholder="请输入" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="里程(km)" required><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="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>{{ pending.length }}</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 }}:{{ items[0].departureName }} → {{ items[0].arrivalName }}</h3>
|
||
<el-table :data="items" border><el-table-column type="index" label="序号" width="70" /><el-table-column label="单据类型" prop="documentType" width="100" /><el-table-column label="货物" min-width="220"><template #default="{ row }">{{ row.cargoName }}{{ row.cargoType ? `,${row.cargoType}` : '' }}</template></el-table-column><el-table-column label="本次数量" width="150"><template #default="{ row }">{{ formatQuantity(row.quantity) }} {{ row.quantityUnit }}</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="removePending(row.id)">删除</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 getCustomerList } from '@/api/vehicle/customer-archive';
|
||
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, cargoTypeOptions: [], cargoTypeCascaderProps: { label: 'cargoName', value: 'id', children: 'children', emitPath: true }, quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'] };
|
||
},
|
||
computed: {
|
||
totalQuantity() { return (this.master?.goods || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
|
||
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(' / ') })); },
|
||
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) => { (groups[item.segmentNo] ||= []).push(item); return groups; }, {}); },
|
||
pendingSegmentSummary() {
|
||
return this.routes.map(route => {
|
||
const items = this.pending.filter(item => item.segmentNo === route.segmentNo);
|
||
if (!items.length) return `${route.segmentNo}:无`;
|
||
const documentSummary = items.reduce((result, item) => {
|
||
result[item.documentType] = (result[item.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()]);
|
||
},
|
||
methods: {
|
||
async load() {
|
||
this.loading = true;
|
||
try {
|
||
const res = await api.getDetail(this.id);
|
||
this.master = res.data?.data || res.data || res;
|
||
this.cargoTypeOptions = this.buildMasterCargoTypeOptions(this.master.goods || []);
|
||
this.routes = this.dispatchRouteNodes().map((node, index) => this.createRoute(node, index));
|
||
} finally { this.loading = false; }
|
||
},
|
||
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: '承运商',
|
||
currency: 'CNY',
|
||
otherFeeTotal: '',
|
||
freightItems: [],
|
||
departureName: previous.departureName || '', departureAddress: previous.departureAddress || '', departureContact: previous.departureContact || '', departurePhone: previous.departurePhone || '',
|
||
arrivalName: node.departureName || this.master.arrivalName || '', arrivalAddress: node.departureAddress || this.master.arrivalAddress || '', arrivalContact: node.departureContact || '', arrivalPhone: node.departurePhone || '',
|
||
goods: (this.master.goods || []).map((goods, sourceIndex) => this.createGoodsRow(goods, sourceIndex)),
|
||
};
|
||
this.syncFreightItems(route);
|
||
return route;
|
||
},
|
||
buildMasterCargoTypeOptions(goods = []) {
|
||
return [...new Set(goods.map(item => item.cargoType).filter(Boolean))].map(cargoType => ({
|
||
id: cargoType,
|
||
cargoName: cargoType,
|
||
}));
|
||
},
|
||
masterGoodsByCargoType(row = {}) {
|
||
return this.masterGoods.filter(item => !row.cargoType || item.cargoType === row.cargoType);
|
||
},
|
||
findCargoTypeByPath(path = []) {
|
||
let options = this.cargoTypeOptions;
|
||
let selected;
|
||
(path || []).forEach(id => {
|
||
selected = (options || []).find(item => String(item.id) === String(id));
|
||
options = selected?.children || [];
|
||
});
|
||
return selected;
|
||
},
|
||
findCargoTypePath(cargoType, options = this.cargoTypeOptions, parentPath = []) {
|
||
for (const option of options || []) {
|
||
const path = [...parentPath, option.id];
|
||
if (option.cargoName === cargoType) return path;
|
||
if (option.children?.length) {
|
||
const result = this.findCargoTypePath(cargoType, option.children, path);
|
||
if (result.length) return result;
|
||
}
|
||
}
|
||
return [];
|
||
},
|
||
handleCargoTypeChange(route, row, value) {
|
||
const path = Array.isArray(value) ? value : [];
|
||
const cargoType = this.findCargoTypeByPath(path);
|
||
row.cargoTypePath = path;
|
||
row.cargoType = cargoType?.cargoName || '';
|
||
const firstGoods = this.masterGoodsByCargoType(row)[0];
|
||
if (firstGoods) {
|
||
row.sourceIndex = firstGoods.sourceIndex;
|
||
this.selectGoods(route, row);
|
||
}
|
||
else Object.assign(row, { sourceIndex: undefined, cargoName: '', quantity: '', quantityUnit: '' });
|
||
this.syncFreightItems(route);
|
||
},
|
||
isRoad(route) { return String(route.transportType || '').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); },
|
||
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 || 'CNY',
|
||
totalFreightAmount: this.freightTotal(route),
|
||
otherFreightAmount: route.otherFeeTotal || '',
|
||
freightItems: (route.freightItems || []).map(item => ({
|
||
...item,
|
||
freightAmount: this.freightAmount(item),
|
||
})),
|
||
});
|
||
},
|
||
currencyLabel(value) { return ({ CNY: '人民币', 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 || {};
|
||
return this.quantityNumber(
|
||
dispatchedGoods[this.goodsKey(row)] ?? dispatchedGoods[this.goodsKey(row, false)]
|
||
);
|
||
},
|
||
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);
|
||
},
|
||
pendingGoodsQuantity(route, row) {
|
||
const goodsIndex = this.masterGoodsIndex(row);
|
||
return this.pending.reduce((sum, item) => {
|
||
if (!this.isSameRouteSegment(route, item)) return sum;
|
||
const pendingGoodsIndex = this.masterGoodsIndex(item);
|
||
if (
|
||
(goodsIndex >= 0 && pendingGoodsIndex >= 0 && pendingGoodsIndex !== goodsIndex) ||
|
||
(goodsIndex < 0 && !this.isSameGoods(item, row))
|
||
) {
|
||
return sum;
|
||
}
|
||
return sum + this.quantityNumber(item.quantity);
|
||
}, 0);
|
||
},
|
||
isSameRouteSegment(route = {}, item = {}) {
|
||
const routeSegment = route.segmentNo || route.relationNo || '';
|
||
const itemSegment = item.segmentNo || item.relationNo || '';
|
||
return String(routeSegment) === String(itemSegment);
|
||
},
|
||
editingGoodsQuantity(route, row) {
|
||
return (route.goods || []).reduce((sum, item) => {
|
||
if (item === row || !this.isSameGoods(item, row)) return sum;
|
||
return sum + this.quantityNumber(item.dispatchQuantity);
|
||
}, 0);
|
||
},
|
||
goodsRemainingQuantity(route, row) {
|
||
return Math.max(
|
||
0,
|
||
this.baseGoodsRemainingQuantity(route, row) -
|
||
this.pendingGoodsQuantity(route, row) -
|
||
this.editingGoodsQuantity(route, row)
|
||
);
|
||
},
|
||
availableDispatchQuantity(route, row) { return this.goodsRemainingQuantity(route, row); },
|
||
handleDispatchQuantityInput(route, row, value) {
|
||
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
|
||
const nextValue = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 3)}` : integer;
|
||
const availableQuantity = this.availableDispatchQuantity(route, row);
|
||
if (Number(nextValue || 0) > availableQuantity) {
|
||
row.dispatchQuantity = this.formatQuantity(availableQuantity);
|
||
this.syncFreightItems(route);
|
||
this.$message.warning('本次数量不能超过剩余数量');
|
||
return;
|
||
}
|
||
row.dispatchQuantity = nextValue;
|
||
this.syncFreightItems(route);
|
||
},
|
||
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 = [
|
||
['unitPrice', '单价'],
|
||
['priceUnit', '单价单位'],
|
||
['quantity', '数量'],
|
||
['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;
|
||
},
|
||
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,
|
||
cargoTypePath: this.findCargoTypePath(goods.cargoType),
|
||
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;
|
||
Object.assign(row, this.createGoodsRow(source, Number(row.sourceIndex)));
|
||
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;
|
||
if (value === '承运商') {
|
||
route.trailerVehicleNo = '';
|
||
route.escortName = '';
|
||
route.escortPhone = '';
|
||
route.mileage = undefined;
|
||
} else {
|
||
route.carrierName = '';
|
||
}
|
||
},
|
||
carrierOptionLabel(item) { return item.customerName || item.carrierName || item.fullName || item.name || ''; },
|
||
carrierOptionKey(item) { return item.id || item.customerId || item.carrierId || this.carrierOptionLabel(item); },
|
||
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 || '';
|
||
},
|
||
async loadCarrierOptions(keyword = '') {
|
||
this.carrierLoading = true;
|
||
try {
|
||
this.carrierOptions = unwrapRecords(
|
||
await getCustomerList(1, 20, { customerName: keyword, customerType: '承运商' })
|
||
);
|
||
} finally {
|
||
this.carrierLoading = false;
|
||
}
|
||
},
|
||
async loadDriverOptions(keyword = '') {
|
||
this.driverLoading = true;
|
||
try {
|
||
this.driverOptions = unwrapRecords(await getDriverList(1, 20, { driverName: keyword }));
|
||
} finally {
|
||
this.driverLoading = false;
|
||
}
|
||
},
|
||
setDriverInput(segmentNo, element) {
|
||
if (element) this.driverInputs[segmentNo] = element;
|
||
else delete this.driverInputs[segmentNo];
|
||
},
|
||
resetForNextDispatch(route) {
|
||
route.driverName = '';
|
||
route.driverPhone = '';
|
||
route.vehicleNo = '';
|
||
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 (goods.some(item => Number(item.dispatchQuantity) > this.availableDispatchQuantity(route, item))) return this.$message.warning('本次数量不能超过剩余数量');
|
||
this.syncFreightItems(route);
|
||
if (!this.validateFreightItems(route)) return;
|
||
if (!route.estimatedStartTime) return this.$message.warning(`请选择${this.dateStartLabel(route)}`);
|
||
if (!route.estimatedEndTime) return this.$message.warning(`请选择${this.dateEndLabel(route)}`);
|
||
if (!this.validateRoutePhones(route)) return;
|
||
if (route.documentType === '运单') {
|
||
if (route.carrierType === '承运商' && (!route.carrierName || !route.vehicleNo || !route.mileage)) return this.$message.warning('请填写承运商、车牌号和里程');
|
||
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo || !route.trailerVehicleNo || !route.escortName || !route.escortPhone || route.mileage === undefined || route.mileage === null || route.mileage === '')) 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,
|
||
carrierName: route.carrierName, driverName: route.driverName, driverPhone: route.driverPhone, vehicleNo: route.vehicleNo, 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 || 'CNY', freightAmount: this.freightAmount(this.freightItemsForGoods(route, item)), freightTotal: this.freightTotal(route), otherFeeTotal: route.otherFeeTotal || '', freightJson: this.buildFreightJson(route), remark: 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,
|
||
}));
|
||
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('已加入调度清单');
|
||
},
|
||
editPending(item) {
|
||
const route = this.routes.find(routeItem => routeItem.segmentNo === item.segmentNo);
|
||
if (!route) return;
|
||
const sourceIndex = (this.master.goods || []).findIndex(goods => goods.cargoName === item.cargoName && goods.cargoType === item.cargoType);
|
||
let goods = route.goods.find(goodsItem => goodsItem.cargoName === item.cargoName && goodsItem.cargoType === item.cargoType);
|
||
if (!goods && sourceIndex >= 0) {
|
||
goods = this.createGoodsRow(this.master.goods[sourceIndex], sourceIndex);
|
||
route.goods.push(goods);
|
||
}
|
||
if (goods) goods.dispatchQuantity = item.quantity;
|
||
Object.assign(route, item);
|
||
this.syncFreightItems(route);
|
||
const freightItem = this.freightItemsForGoods(route, goods);
|
||
if (freightItem) { freightItem.unitPrice = item.unitPrice || ''; freightItem.priceUnit = item.priceUnit || freightItem.priceUnit; }
|
||
this.editingId = item.id;
|
||
this.removePending(item.id);
|
||
this.$nextTick(() => document.querySelector(`[data-segment="${item.segmentNo}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||
},
|
||
freightItemsForGoods(route, goods) {
|
||
return (route.freightItems || []).find(item => String(item.sourceIndex) === String(goods?.sourceIndex)) || {};
|
||
},
|
||
removePending(id) { this.pending = this.pending.filter(item => item.id !== id); if (this.editingId === id) 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;
|
||
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; }
|
||
.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; } }
|
||
.route-overview { display: flex; align-items: flex-start; justify-content: flex-end; padding-top: 8px; overflow-x: auto; }
|
||
.route-overview__node { display: grid; flex: 0 0 130px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; 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: 1 0 56px; min-width: 56px; height: 32px; margin-top: 0; border-bottom: 2px solid #606266; }
|
||
.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: 16px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } }
|
||
.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__remaining .cell) { white-space: nowrap; } :deep(.goods-table__dispatch .el-input) { width: 100%; min-width: 0; } }
|
||
.freight-form { :deep(.freight-unit-select) { width: 92px; } }
|
||
.carrier-type-form { margin-top: 16px; }
|
||
.carrier-form { padding-top: 8px; }
|
||
.segment-actions { display: flex; justify-content: flex-end; gap: 12px; 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 { gap: 12px; 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%; } }
|
||
</style>
|