✨ 评分减分累计与最高扣分、用户部门标签联动树,并修复对账导入与结算调整等

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 04:28:22 +08:00
parent 8d9fe998c0
commit 2a5eca8393
17 changed files with 691 additions and 307 deletions
+31 -10
View File
@@ -105,12 +105,27 @@ const isJsonContentType = res => {
return contentType.includes('application/json') || contentType.includes('text/json'); return contentType.includes('application/json') || contentType.includes('text/json');
}; };
/**
* 是否为可按文本读取的 Blob / Blob-like(含跨 realm)。
* 禁止对普通业务 JSON 对象调用 .text()。
*/
const isBlobLikeData = value =>
value != null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.type === 'string' &&
typeof value.text === 'function';
/** /**
* 判断响应体是否已是解析好的对象(无需再解析)。 * 判断响应体是否已是解析好的对象(无需再解析)。
* 注意 Blob 也是 object,必须排除,否则会把待解析的文件流误判为已完成。 * 注意 Blob 也是 object,必须排除,否则会把待解析的文件流误判为已完成。
*/ */
const isPlainObject = value => const isPlainObject = value =>
value !== null && typeof value === 'object' && !(value instanceof Blob); value !== null &&
typeof value === 'object' &&
!(value instanceof Blob) &&
!isBlobLikeData(value) &&
!(value instanceof ArrayBuffer);
//跨域请求,允许保存cookie //跨域请求,允许保存cookie
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
// NProgress Configuration // NProgress Configuration
@@ -190,15 +205,20 @@ axios.interceptors.response.use(
// 用户就会以为系统崩了——而后端其实返回了明确的业务提示(如「未读取到数据行…」)。 // 用户就会以为系统崩了——而后端其实返回了明确的业务提示(如「未读取到数据行…」)。
// 这里按 Content-Type 判断:JSON 才解析回对象;真·文件流(Excel 等)保持原样不动。 // 这里按 Content-Type 判断:JSON 才解析回对象;真·文件流(Excel 等)保持原样不动。
// 兼容 Blob(浏览器 responseType='blob')与 string(非浏览器环境)两种形态。 // 兼容 Blob(浏览器 responseType='blob')与 string(非浏览器环境)两种形态。
// 仅对真正的 Blob/Blob-like 调 .text(),避免业务 JSON 已解析后出现
// 「e.data.text is not a function」。
if (isJsonContentType(res) && !isPlainObject(res.data)) { if (isJsonContentType(res) && !isPlainObject(res.data)) {
try { try {
let text = ''; let text = '';
if (typeof res.data?.text === 'function') { const raw = res.data;
text = await res.data.text(); if (isBlobLikeData(raw) || (typeof Blob !== 'undefined' && raw instanceof Blob)) {
} else if (typeof res.data === 'string') { text = await raw.text();
text = res.data; } else if (typeof raw === 'string') {
} else if (typeof res.data?.arrayBuffer === 'function') { text = raw;
text = new TextDecoder().decode(await res.data.arrayBuffer()); } else if (raw && typeof raw.arrayBuffer === 'function') {
text = new TextDecoder().decode(await raw.arrayBuffer());
} else if (raw instanceof ArrayBuffer) {
text = new TextDecoder().decode(raw);
} }
const parsed = typeof text === 'string' && text ? JSON.parse(text) : null; const parsed = typeof text === 'string' && text ? JSON.parse(text) : null;
if (parsed && typeof parsed === 'object') { if (parsed && typeof parsed === 'object') {
@@ -208,10 +228,11 @@ axios.interceptors.response.use(
// 声明是 JSON 却解析失败:保持原样,交给下游按文件处理 // 声明是 JSON 却解析失败:保持原样,交给下游按文件处理
} }
} }
//获取状态信息 // 获取状态信息:文件流没有 code/msg,必须退回 HTTP status,禁止读 Blob 上的幽灵字段
const status = res.data.error_code || res.data.code || res.status; const payload = isPlainObject(res.data) ? res.data : {};
const status = Number(payload.error_code || payload.code || res.status);
const statusWhiteList = website.statusWhiteList || []; const statusWhiteList = website.statusWhiteList || [];
const message = res.data.msg || res.data.error_description || '系统错误'; const message = payload.msg || payload.error_description || '系统错误';
//如果在白名单里则自行catch逻辑处理 //如果在白名单里则自行catch逻辑处理
if (statusWhiteList.includes(status)) return Promise.reject(res); if (statusWhiteList.includes(status)) return Promise.reject(res);
+22 -2
View File
@@ -88,7 +88,7 @@ export const parseBlobJson = async data => {
* *
* 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。 * 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。
*/ */
const resolveErrorMessage = async error => { export const resolveImportErrorMessage = async error => {
const payload = error?.response?.data ?? error?.data; const payload = error?.response?.data ?? error?.data;
if (isBusinessJson(payload) && payload.msg) { if (isBusinessJson(payload) && payload.msg) {
return payload.msg; return payload.msg;
@@ -109,6 +109,26 @@ const resolveErrorMessage = async error => {
return fallback; return fallback;
}; };
/**
* 统一消费导入接口响应:成功 JSON / 失败 Excel 流。
* 兼容 axios 已把 JSON Blob 解析成对象的情况,禁止再对普通对象调 .text()。
*/
export const consumeImportResponse = async (res, options = {}) => {
const { businessName = '导入', dayjsFormat } = options;
if (isExcelResponse(res)) {
return {
kind: 'excel',
blob: res?.data,
fileName: `${businessName}导入失败明细${dayjsFormat || ''}.xlsx`,
};
}
const result = isBusinessJson(res?.data) ? res.data : await parseBlobJson(res?.data);
if (Number(result?.code) !== 200) {
throw new Error(result?.msg || '导入失败');
}
return { kind: 'success', result };
};
/** /**
* 加工后端返回的导入失败明细 Excel。 * 加工后端返回的导入失败明细 Excel。
* 若传入 failDetailDecorator(函数),则动态加载 exceljs 读取 blob 流, * 若传入 failDetailDecorator(函数),则动态加载 exceljs 读取 blob 流,
@@ -235,7 +255,7 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
// 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次, // 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次,
// 只保留一次清晰的中文提示。 // 只保留一次清晰的中文提示。
if (!error?._notified) { if (!error?._notified) {
vm.$message.error(await resolveErrorMessage(error)); vm.$message.error(await resolveImportErrorMessage(error));
} }
notifyUploadError(option, error); notifyUploadError(option, error);
} finally { } finally {
@@ -13824,13 +13824,13 @@ export default {
} }
const unavailableWaybills = this.selectionList.filter( const unavailableWaybills = this.selectionList.filter(
row => row =>
this.statusValue(row) !== 'pending' || !['pending', 'processing', 'running'].includes(this.statusValue(row)) ||
row.loadingId || row.loadingId ||
row.loadingManageId || row.loadingManageId ||
row.loadingNo row.loadingNo
); );
if (unavailableWaybills.length) { if (unavailableWaybills.length) {
this.$message.warning('公路配载仅支持进行中且未配载的运单'); this.$message.warning('公路配载仅支持待执行/进行中且未关联有效配载单的运单');
return; return;
} }
try { try {
@@ -94,7 +94,7 @@
<div class="route-node-form"> <div class="route-node-form">
<el-form-item label="途经地" required class="route-address-form-item" <el-form-item label="途经地" required class="route-address-form-item"
><div class="route-address-inputs" ><div class="route-address-inputs"
><el-input v-if="isNonRoadAddress(`route-${index}`)" v-model="route.departureName" class="route-address-name" readonly :disabled="isBasicRouteReadonly || !route.transportType" placeholder="请先选择运输方式" /><el-cascader v-else v-model="addressRegionPaths[`route-${index}`]" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly || !route.transportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange(`route-${index}`, value)" /><el-input v-model="route.departureAddress" class="route-address-detail" readonly :disabled="isBasicRouteReadonly || !route.transportType" placeholder="请先选择运输方式" @click="handleAddressDetailClick(`route-${index}`)" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly || !route.transportType" @click="openCommonAddress(`route-${index}`)" /></el-tooltip></div ><el-input v-if="isNonRoadAddress(`route-${index}`)" v-model="route.departureName" class="route-address-name" readonly :disabled="isBasicRouteReadonly" placeholder="请选择站点" /><el-cascader v-else v-model="addressRegionPaths[`route-${index}`]" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange(`route-${index}`, value)" /><el-input v-model="route.departureAddress" class="route-address-detail" readonly :disabled="isBasicRouteReadonly" placeholder="请输入详细地址" @click="handleAddressDetailClick(`route-${index}`)" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly" @click="openCommonAddress(`route-${index}`)" /></el-tooltip></div
></el-form-item> ></el-form-item>
<el-form-item label="联系人" class="route-contact-form-item"><el-input v-model="route.departureContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item> <el-form-item label="联系人" class="route-contact-form-item"><el-input v-model="route.departureContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式" class="route-phone-form-item"><div class="route-contact-actions"><el-input v-model="route.departurePhone" :disabled="isBasicRouteReadonly" placeholder="请输入" /><template v-if="!isBasicRouteReadonly"><el-tooltip v-if="index === form.routes.length - 1" content="新增途经点" placement="top"><el-button class="route-action-button" type="primary" link @click="addRoute"><el-icon><CirclePlus /></el-icon></el-button></el-tooltip><el-tooltip content="删除途经点" placement="top"><el-button class="route-action-button" type="danger" link @click="removeRoute(index)"><el-icon><Remove /></el-icon></el-button></el-tooltip></template></div></el-form-item> <el-form-item label="联系方式" class="route-phone-form-item"><div class="route-contact-actions"><el-input v-model="route.departurePhone" :disabled="isBasicRouteReadonly" placeholder="请输入" /><template v-if="!isBasicRouteReadonly"><el-tooltip v-if="index === form.routes.length - 1" content="新增途经点" placement="top"><el-button class="route-action-button" type="primary" link @click="addRoute"><el-icon><CirclePlus /></el-icon></el-button></el-tooltip><el-tooltip content="删除途经点" placement="top"><el-button class="route-action-button" type="danger" link @click="removeRoute(index)"><el-icon><Remove /></el-icon></el-button></el-tooltip></template></div></el-form-item>
@@ -111,7 +111,7 @@
<div class="route-node-form"> <div class="route-node-form">
<el-form-item label="收货地" required class="route-address-form-item" <el-form-item label="收货地" required class="route-address-form-item"
><div class="route-address-inputs" ><div class="route-address-inputs"
><el-input v-if="isNonRoadAddress('arrival')" v-model="form.arrivalName" class="route-address-name" readonly :disabled="isBasicRouteReadonly || !form.finalTransportType" placeholder="请先选择运输方式" /><el-cascader v-else v-model="addressRegionPaths.arrival" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly || !form.finalTransportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('arrival', value)" /><el-input v-model="form.arrivalAddress" class="route-address-detail" readonly :disabled="isBasicRouteReadonly || !form.finalTransportType" placeholder="请先选择运输方式" @click="handleAddressDetailClick('arrival')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly || !form.finalTransportType" @click="openCommonAddress('arrival')" /></el-tooltip></div ><el-input v-if="isNonRoadAddress('arrival')" v-model="form.arrivalName" class="route-address-name" readonly :disabled="isBasicRouteReadonly" placeholder="请选择站点" /><el-cascader v-else v-model="addressRegionPaths.arrival" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('arrival', value)" /><el-input v-model="form.arrivalAddress" class="route-address-detail" readonly :disabled="isBasicRouteReadonly" placeholder="请输入详细地址" @click="handleAddressDetailClick('arrival')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly" @click="openCommonAddress('arrival')" /></el-tooltip></div
></el-form-item> ></el-form-item>
<el-form-item label="联系人" class="route-contact-form-item" <el-form-item label="联系人" class="route-contact-form-item"
><el-input v-model="form.arrivalContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item> ><el-input v-model="form.arrivalContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item>
@@ -714,7 +714,7 @@ export default {
'msg', 'msg',
'zip', 'zip',
], ],
transports: ['公路运输', '铁路运输', '水路运输', '航空运输'], transports: ['铁路运输', '水路运输', '航空运输', '公路运输'],
quantityUnitOptions: [], quantityUnitOptions: [],
quantityUnitLoading: false, quantityUnitLoading: false,
form: { form: {
@@ -798,6 +798,7 @@ export default {
methods: { methods: {
formatContractOptionLabel, formatContractOptionLabel,
defaultQuantityUnit() { defaultQuantityUnit() {
if (this.quantityUnitOptions.includes('吨')) return '吨';
return this.quantityUnitOptions[0] || '吨'; return this.quantityUnitOptions[0] || '吨';
}, },
loadQuantityUnitOptions() { loadQuantityUnitOptions() {
@@ -1164,22 +1165,30 @@ export default {
const field = this.addressModel(target); const field = this.addressModel(target);
if (!field) return false; if (!field) return false;
const siteCode = String(field.model[field.siteCode] || '').trim(); const siteCode = String(field.model[field.siteCode] || '').trim();
// 地址展示模式由实际选择的数据决定:有站点编码表示非公路站点名称, // 展示模式由已选数据决定:有站点编码则显示站点名,否则省市区;暂不按运输方式强制站点限制。
// 没有站点编码则保留省市区级联,避免切换运输方式时改写已有地址。
return Boolean(siteCode && siteCode !== '/'); return Boolean(siteCode && siteCode !== '/');
}, },
hasAddressValue(target) {
const field = this.addressModel(target);
if (!field) return false;
return Boolean(
String(field.model[field.address] || '').trim() || String(field.model[field.name] || '').trim()
);
},
tipEmptyAddressDetail(target) {
// 替代原「请先选择运输方式」placeholder:空地址点击时 toast,已有值不再提示。
if (!this.hasAddressValue(target)) {
this.$message.warning('请输入详细地址');
}
},
resolveAddressRegionCode(row = {}) { resolveAddressRegionCode(row = {}) {
return row.regionCode || row.districtCode || row.areaCode || row.adcode || ''; return row.regionCode || row.districtCode || row.areaCode || row.adcode || '';
}, },
openCommonAddress(target) { openCommonAddress(target) {
if (this.isBasicRouteReadonly) return; if (this.isBasicRouteReadonly) return;
this.tipEmptyAddressDetail(target);
this.addressTarget = target; this.addressTarget = target;
if (this.addressTransportMode(target) !== 'road') { // 暂不按运输方式限制站点:统一走常用地址选择。
this.stationQuery = {};
this.stationPage.current = 1;
this.stationDialogVisible = true;
return;
}
this.addressQuery = {}; this.addressQuery = {};
this.addressPage.current = 1; this.addressPage.current = 1;
this.addressDialogVisible = true; this.addressDialogVisible = true;
@@ -1279,18 +1288,11 @@ export default {
}, },
handleAddressDetailClick(target) { handleAddressDetailClick(target) {
if (this.isBasicRouteReadonly) return; if (this.isBasicRouteReadonly) return;
if (this.addressTransportMode(target) !== 'road') { this.tipEmptyAddressDetail(target);
this.openCommonAddress(target);
return;
}
this.openAddressMap(target); this.openAddressMap(target);
}, },
openAddressMap(target) { openAddressMap(target) {
if (this.isBasicRouteReadonly) return; if (this.isBasicRouteReadonly) return;
if (this.addressTransportMode(target) !== 'road') {
this.$message.info('非公路运输地址不能使用地图选择');
return;
}
const field = this.addressModel(target); const field = this.addressModel(target);
if (!field) return; if (!field) return;
this.mapTarget = target; this.mapTarget = target;
@@ -1690,7 +1692,11 @@ export default {
'', '',
cargoTypePath, cargoTypePath,
quantity: row.quantity || '', quantity: row.quantity || '',
quantityUnit: row.quantityUnit || this.defaultQuantityUnit(), // 常用货物单位若为空或「个」,新建总单统一默认「吨」
quantityUnit:
!row.quantityUnit || String(row.quantityUnit).trim() === '个'
? this.defaultQuantityUnit()
: row.quantityUnit,
packageType: row.packageType || row.package || '', packageType: row.packageType || row.package || '',
brand: row.brand || '', brand: row.brand || '',
specification: row.specification || row.spec || '', specification: row.specification || row.spec || '',
@@ -1775,17 +1781,21 @@ export default {
return formatUpdateUserName(row); return formatUpdateUserName(row);
}, },
selectRoute(route) { selectRoute(route) {
// 先写入运输方式(会触发地点清空),再回填线路地址,避免被清空逻辑覆盖。
this.form.finalTransportType = '公路运输';
if (!this.form.routes.length) this.addRoute();
if (this.form.routes[0]) this.form.routes[0].transportType = '公路运输';
this.form.departureName = route.departureName || ''; this.form.departureName = route.departureName || '';
this.form.departureAddress = route.departureAddress || ''; this.form.departureAddress = route.departureAddress || '';
this.form.departureContact = route.departureContact || ''; this.form.departureContact = route.departureContact || '';
this.form.departurePhone = route.departurePhone || ''; this.form.departurePhone = route.departurePhone || '';
this.form.departureSiteCode = '';
this.form.arrivalName = route.arrivalName || ''; this.form.arrivalName = route.arrivalName || '';
this.form.arrivalAddress = route.arrivalAddress || ''; this.form.arrivalAddress = route.arrivalAddress || '';
this.form.arrivalContact = route.arrivalContact || ''; this.form.arrivalContact = route.arrivalContact || '';
this.form.arrivalPhone = route.arrivalPhone || ''; this.form.arrivalPhone = route.arrivalPhone || '';
this.form.arrivalSiteCode = '';
this.syncAddressRegionPaths(); this.syncAddressRegionPaths();
this.form.finalTransportType = '公路运输';
if (!this.form.routes.length) this.addRoute();
this.routeDialogVisible = false; this.routeDialogVisible = false;
}, },
createRoute(index = this.form.routes.length) { createRoute(index = this.form.routes.length) {
@@ -10414,13 +10414,13 @@ export default {
} }
const unavailableWaybills = this.selectionList.filter( const unavailableWaybills = this.selectionList.filter(
row => row =>
!['processing', 'running'].includes(this.statusValue(row)) || !['pending', 'processing', 'running'].includes(this.statusValue(row)) ||
row.loadingId || row.loadingId ||
row.loadingManageId || row.loadingManageId ||
row.loadingNo row.loadingNo
); );
if (unavailableWaybills.length) { if (unavailableWaybills.length) {
this.$message.warning('公路配载仅支持进行中且未配载的运单'); this.$message.warning('公路配载仅支持待执行/进行中且未关联有效配载单的运单');
return; return;
} }
try { try {
+62 -14
View File
@@ -635,10 +635,11 @@
min-width="100" /><el-table-column min-width="100" /><el-table-column
prop="quantity" prop="quantity"
label="数量" label="数量"
min-width="90" /><el-table-column min-width="120"
prop="quantityUnit" ><template #default="{ row }">{{
label="数量单位" formatCargoQuantityDisplay(row)
min-width="100" /><el-table-column }}</template></el-table-column
><el-table-column
prop="materialCode" prop="materialCode"
label="物料编码" label="物料编码"
min-width="120" /><el-table-column min-width="120" /><el-table-column
@@ -897,6 +898,16 @@
> >
<div title="配载信息" class="loading-manage-dialog__load-info-card"> <div title="配载信息" class="loading-manage-dialog__load-info-card">
<section-card> <section-card>
<div class="loading-manage-dialog__admission-tip">
<div class="loading-manage-dialog__admission-title">可配载条件</div>
<div class="loading-manage-dialog__admission-text">
同时满足以下条件的运单可被配载:运输方式为「公路」;运单状态为「待执行」或「进行中」;未关联其他有效配载单。
</div>
<div class="loading-manage-dialog__admission-title">配载后状态说明</div>
<div class="loading-manage-dialog__admission-text">
子运单:按项目过程配置是否需司机接单,无需接单则为进行中,需接单则为待执行。配载单:按所选运单所属项目的过程配置,需接单则为待执行,无需接单则为进行中。
</div>
</div>
<div class="loading-manage-dialog__candidate-search"> <div class="loading-manage-dialog__candidate-search">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6"> <el-col :span="6">
@@ -1425,8 +1436,9 @@
<el-table-column prop="cargoName" label="货物名称" min-width="140" /> <el-table-column prop="cargoName" label="货物名称" min-width="140" />
<el-table-column prop="cargoType" label="货物类型" min-width="140" /> <el-table-column prop="cargoType" label="货物类型" min-width="140" />
<el-table-column prop="packageType" label="包装" min-width="100" /> <el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="quantity" label="数量" min-width="100" /> <el-table-column prop="quantity" label="数量" min-width="120">
<el-table-column prop="quantityUnit" label="数量单位" min-width="100" /> <template #default="{ row }">{{ formatCargoQuantityDisplay(row) }}</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料编码" min-width="130" /> <el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="equipmentCode" label="设备编码" min-width="130" /> <el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="120" /> <el-table-column prop="brand" label="品牌" min-width="120" />
@@ -3900,12 +3912,10 @@ export default {
try { try {
const query = { const query = {
...this.buildQueryParams(this.candidateQuery), ...this.buildQueryParams(this.candidateQuery),
// 待执行(需接单未接)与进行中(无需接单或已接单)均可配载 // 与后端 LoadableAdmissionSupport 一致:公路 + 待执行/进行中 + 未关联有效配载单
businessStatus: 'pending,running', loadableOnly: 1,
onlyUnassignedLoading: 1,
// 仅查当前账户所属组织及子组织下的运单 // 仅查当前账户所属组织及子组织下的运单
allDept: 0, allDept: 0,
...(this.dialogMode === 'add' ? { transportType: 'road' } : {}),
}; };
const transportType = this.getCandidateTransportTypeQuery(query.transportType); const transportType = this.getCandidateTransportTypeQuery(query.transportType);
const res = await getWaybillList( const res = await getWaybillList(
@@ -4195,14 +4205,28 @@ export default {
return [this.normalizeCargoRow(row, row)]; return [this.normalizeCargoRow(row, row)];
}); });
}, },
formatCargoQuantityDisplay(row = {}) {
const quantity = blankCargoSentinel(row.quantity ?? row.cargoQuantity ?? '');
const unit = String(
blankCargoSentinel(
row.quantityUnit ||
row.goodsQuantityUnit ||
row.cargoUnit ||
row.unit ||
''
) || ''
).trim();
if (quantity === '' || quantity === undefined || quantity === null) {
return unit || '';
}
return unit ? `${quantity} ${unit}` : String(quantity);
},
normalizeCargoRow(waybill, item = {}) { normalizeCargoRow(waybill, item = {}) {
return sanitizeCargoRow({ return sanitizeCargoRow({
projectName: item.projectName || waybill.projectName || '', projectName: item.projectName || waybill.projectName || '',
cargoName: item.cargoName || item.goodsName || waybill.cargoName || '', cargoName: item.cargoName || item.goodsName || waybill.cargoName || '',
cargoType: item.cargoType || item.goodsType || waybill.cargoType || '', cargoType: item.cargoType || item.goodsType || waybill.cargoType || '',
packageType: item.packageType || item.package || '', packageType: item.packageType || item.package || '',
weight: item.weight || item.cargoWeight || '',
volume: item.volume || item.cargoVolume || '',
quantity: item.quantity || item.cargoQuantity || waybill.quantity || '', quantity: item.quantity || item.cargoQuantity || waybill.quantity || '',
quantityUnit: quantityUnit:
item.quantityUnit || item.quantityUnit ||
@@ -4399,8 +4423,8 @@ export default {
try { try {
const payload = { const payload = {
...this.normalizePayload(), ...this.normalizePayload(),
// 进行中编辑保持 running;其余确认生成正式配载单为待执行 // 进行中编辑保持 running;新建/草稿确认由后端按项目是否需接单写入 pending/running
businessStatus: this.isRunningEdit ? 'running' : 'pending', ...(this.isRunningEdit ? { businessStatus: 'running' } : {}),
}; };
if (this.dialogMode === 'reassign') { if (this.dialogMode === 'reassign') {
await loadingApi.reassign(payload); await loadingApi.reassign(payload);
@@ -4969,6 +4993,30 @@ export default {
justify-content: space-between; justify-content: space-between;
} }
.loading-manage-dialog__admission-tip {
margin-bottom: 12px;
padding: 12px 14px;
background: #f5f9ff;
border: 1px solid #d9ecff;
border-radius: 4px;
line-height: 1.6;
color: #606266;
}
.loading-manage-dialog__admission-title {
margin-bottom: 4px;
color: #303133;
font-weight: 600;
}
.loading-manage-dialog__admission-title + .loading-manage-dialog__admission-text + .loading-manage-dialog__admission-title {
margin-top: 8px;
}
.loading-manage-dialog__admission-text {
font-size: 13px;
}
.loading-manage-dialog__candidate-search { .loading-manage-dialog__candidate-search {
padding: 0; padding: 0;
margin-bottom: 0; margin-bottom: 0;
+14 -4
View File
@@ -203,7 +203,11 @@
@click="openBatchDialog(row)" @click="openBatchDialog(row)"
>更换运单批次</el-link >更换运单批次</el-link
><el-link ><el-link
v-if="(row.processStatus !== '处理完成' || row.auditStatus === '待审核') && row.auditStatus !== '审核驳回'" v-if="
row.processStatus !== '处理失败' &&
(row.processStatus !== '处理完成' || row.auditStatus === '待审核') &&
row.auditStatus !== '审核驳回'
"
type="primary" type="primary"
@click="openBatchDialog(row)" @click="openBatchDialog(row)"
>更换运单批次</el-link >更换运单批次</el-link
@@ -320,7 +324,7 @@
<el-form-item label="执行凭证" required <el-form-item label="执行凭证" required
><el-upload ><el-upload
action="#" action="#"
accept=".zip,.7z" accept=".zip"
:auto-upload="false" :auto-upload="false"
:limit="1" :limit="1"
:on-change="selectVoucherFile" :on-change="selectVoucherFile"
@@ -332,7 +336,7 @@
>上传</el-button >上传</el-button
><template #tip ><template #tip
><span class="voucher-manage-page__upload-tip" ><span class="voucher-manage-page__upload-tip"
>请上传车辆的执行凭证压缩包,支持zip、7z格式。 上传附件大于50M时为超大附件,确认提交后可在“批量补录->上传任务”中查看上传进度。</span >请上传车辆的执行凭证压缩包,仅支持 zip 格式;无法解析的文件将标记为「处理失败」,可重新上传。上传附件大于50M时为超大附件,确认提交后可在“批量补录->上传任务”中查看上传进度。</span
></template ></template
></el-upload ></el-upload
><el-progress ><el-progress
@@ -549,7 +553,7 @@
ref="resumeFileInput" ref="resumeFileInput"
class="voucher-manage-page__hidden-file" class="voucher-manage-page__hidden-file"
type="file" type="file"
accept=".zip,.7z" accept=".zip"
@change="resumeFileSelected" @change="resumeFileSelected"
/> />
</template> </template>
@@ -998,6 +1002,12 @@ const uploadVoucher = async file => {
}; };
const selectVoucherFile = file => { const selectVoucherFile = file => {
if (!file?.raw) return; if (!file?.raw) return;
const fileName = String(file.raw.name || '').toLowerCase();
if (!fileName.endsWith('.zip')) {
ElMessage.warning('仅支持 zip 压缩包,无法解析的文件将标记为处理失败');
clearFile();
return;
}
selectedVoucherFile.value = file.raw; selectedVoucherFile.value = file.raw;
editing.fileName = file.raw.name; editing.fileName = file.raw.name;
editing.fileUrl = ''; editing.fileUrl = '';
+107 -166
View File
@@ -244,7 +244,7 @@
<el-table-column prop="paymentRatio" label="付款比例(%)" min-width="160"> <el-table-column prop="paymentRatio" label="付款比例(%)" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-if="multipleSettlementRows && !readonly" v-if="!readonly"
:model-value="row.paymentRatio" :model-value="row.paymentRatio"
:min="0" :min="0"
:max="rowPaymentRatioMax(row)" :max="rowPaymentRatioMax(row)"
@@ -262,7 +262,7 @@
<el-table-column prop="appliedAmount" label="付款金额" min-width="160"> <el-table-column prop="appliedAmount" label="付款金额" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-if="multipleSettlementRows && !readonly" v-if="!readonly"
:model-value="row.appliedAmount" :model-value="row.appliedAmount"
:min="0" :min="0"
:max="Number(row.payableAmount || 0)" :max="Number(row.payableAmount || 0)"
@@ -1014,62 +1014,37 @@ export default {
referenceSettlementCount() { referenceSettlementCount() {
return this.referenceSettlementNos.length; return this.referenceSettlementNos.length;
}, },
multipleSettlementRows() {
return this.referenceRows.length > 1;
},
// 结算单号展示:单张显示单号;多张仅提示数量,明细在下方「结算信息」表,悬停可看全部单号 // 结算单号展示:单张显示单号;多张仅提示数量,明细在下方「结算信息」表,悬停可看全部单号
referenceDisplayText() { referenceDisplayText() {
if (this.referenceSettlementCount > 1) return `已选 ${this.referenceSettlementCount} 张结算单`; if (this.referenceSettlementCount > 1) return `已选 ${this.referenceSettlementCount} 张结算单`;
return this.referenceLabel; return this.referenceLabel;
}, },
settlementRows() { settlementRows() {
if (this.referenceRows.length > 1) { const source = this.referenceRows.length
// 多结算单:行内比例/金额独立维护,不回落到表头比例(表头与结算信息不关联) ? this.referenceRows
return this.referenceRows.map(item => { : this.referenceLabel
const paymentRatio = ? [
item.paymentRatio === undefined || item.paymentRatio === null || item.paymentRatio === '' {
? 0 settlementNo: this.referenceLabel,
: Number(item.paymentRatio); settlementAmount: this.form.settlementAmount,
const appliedAmount = Number(item.appliedAmount || 0); payableAmount: this.form.payableAmount,
return { paymentRatio: null,
...item, appliedAmount: null,
paymentRatio, paymentSequence: 1,
appliedAmount, },
...this.buildSettlementRatioHint({ ...item, paymentRatio }), ]
}; : [];
}); // 结算行比例/金额只取行内数据,不回落到付款基本信息
} return source.map(item => {
if (this.referenceRows.length) { const paymentRatio = this.nullableNumber(item.paymentRatio);
return this.referenceRows.map(item => { const appliedAmount = this.nullableNumber(item.appliedAmount);
const appliedAmount = return {
item.fixedAppliedAmount === true ...item,
? Number(item.appliedAmount || 0) paymentRatio,
: Number( appliedAmount,
( ...this.buildSettlementRatioHint({ ...item, paymentRatio }),
(Number(item.settlementAmount || 0) * Number(this.form.paymentRatio || 0)) / };
100 });
).toFixed(2)
);
return {
...item,
paymentRatio: this.form.paymentRatio,
appliedAmount,
...this.buildSettlementRatioHint(item),
};
});
}
if (!this.referenceLabel) return [];
return [
{
settlementNo: this.referenceLabel,
settlementAmount: this.form.settlementAmount,
payableAmount: this.form.payableAmount,
paymentRatio: this.form.paymentRatio,
appliedAmount: this.form.appliedAmount,
paymentSequence: 1,
...this.buildSettlementRatioHint({ paymentSequence: 1 }),
},
];
}, },
highRiskPayee() { highRiskPayee() {
const level = String(this.payeeCustomerLevel || '') const level = String(this.payeeCustomerLevel || '')
@@ -1127,11 +1102,12 @@ export default {
if ( if (
this.amountSyncing || this.amountSyncing ||
this.form.paymentType === 'project_advance' || this.form.paymentType === 'project_advance' ||
!this.paymentAmountBase !this.paymentAmountBase ||
this.nullableNumber(this.form.paymentRatio) === null
) { ) {
return; return;
} }
// 结算金额变化时按上方比例重算申请付款金额,仍不联动结算信息明细 // 结算金额变化时只按上方比例重算申请付款金额,不改结算信息明细
this.amountSyncing = true; this.amountSyncing = true;
this.form.appliedAmount = Number( this.form.appliedAmount = Number(
((this.paymentAmountBase * Number(this.form.paymentRatio || 0)) / 100).toFixed(2) ((this.paymentAmountBase * Number(this.form.paymentRatio || 0)) / 100).toFixed(2)
@@ -1532,8 +1508,6 @@ export default {
paymentSequence: amount.paymentSequence, paymentSequence: amount.paymentSequence,
}), }),
settlementNo: item.formalSettlementNo || item.settlementNo || '', settlementNo: item.formalSettlementNo || item.settlementNo || '',
appliedAmount: Number(item.appliedAmount || 0),
fixedAppliedAmount: true,
}; };
}); });
this.seedMultipleRowPayments(referenceRows); this.seedMultipleRowPayments(referenceRows);
@@ -1545,27 +1519,6 @@ export default {
(total, item) => total + Number(item.payableAmount || 0), (total, item) => total + Number(item.payableAmount || 0),
0 0
); );
const multiple = referenceRows.length > 1;
// 多结算单:表头比例/申请金额独立;单结算单:沿用行金额或按比例计算
const paymentRatio = Number(this.form.paymentRatio || 0);
const rowsAppliedTotal = referenceRows.reduce(
(total, item) => total + Number(item.appliedAmount || 0),
0
);
const headerAppliedAmount = multiple
? Number(((settlementAmount * paymentRatio) / 100).toFixed(2))
: Number(
(
rowsAppliedTotal > 0
? rowsAppliedTotal
: (payableAmount * paymentRatio) / 100
).toFixed(2)
);
const headerPaymentRatio = multiple
? paymentRatio
: settlementAmount
? Number(((headerAppliedAmount / settlementAmount) * 100).toFixed(2))
: paymentRatio;
const settlementIds = rows.map(item => item.settlementId || item.id).filter(Boolean); const settlementIds = rows.map(item => item.settlementId || item.id).filter(Boolean);
this.amountSyncing = true; this.amountSyncing = true;
this.referenceRows = referenceRows; this.referenceRows = referenceRows;
@@ -1592,8 +1545,7 @@ export default {
settlementAmount: Number(settlementAmount.toFixed(2)), settlementAmount: Number(settlementAmount.toFixed(2)),
payableAmount: Number(payableAmount.toFixed(2)), payableAmount: Number(payableAmount.toFixed(2)),
billType: '', billType: '',
paymentRatio: headerPaymentRatio, appliedAmount: this.headerAppliedAmountByRatio(settlementAmount),
appliedAmount: headerAppliedAmount,
invoices: rows.flatMap(item => invoices: rows.flatMap(item =>
this.createInvoiceRows(item.invoices || [], item.formalSettlementNo || item.settlementNo) this.createInvoiceRows(item.invoices || [], item.formalSettlementNo || item.settlementNo)
), ),
@@ -1649,7 +1601,7 @@ export default {
referenceType: isPre ? 'pre' : 'formal', referenceType: isPre ? 'pre' : 'formal',
settlementAmount: Number(Number(item.settlementAmount || 0).toFixed(2)), settlementAmount: Number(Number(item.settlementAmount || 0).toFixed(2)),
payableAmount: Number(Number(item.payableAmount ?? item.settlementAmount ?? 0).toFixed(2)), payableAmount: Number(Number(item.payableAmount ?? item.settlementAmount ?? 0).toFixed(2)),
appliedAmount: Number(Number(item.appliedAmount || 0).toFixed(2)), appliedAmount: this.nullableNumber(item.appliedAmount),
paymentRatio: Number(item.settlementAmount || 0) paymentRatio: Number(item.settlementAmount || 0)
? Number( ? Number(
( (
@@ -1658,11 +1610,10 @@ export default {
).toFixed(2) ).toFixed(2)
) )
: null, : null,
fixedAppliedAmount: this.readonly,
}; };
}) })
.filter(Boolean); .filter(Boolean);
if (rows.length > 1) { if (rows.length) {
this.form.preSettlementIds = rows.filter(item => item.referenceType === 'pre').map(item => item.id); this.form.preSettlementIds = rows.filter(item => item.referenceType === 'pre').map(item => item.id);
this.form.settlementIds = rows.filter(item => item.referenceType === 'formal').map(item => item.id); this.form.settlementIds = rows.filter(item => item.referenceType === 'formal').map(item => item.id);
if (this.form.preSettlementIds.length) this.form.preSettlementId = this.form.preSettlementIds[0]; if (this.form.preSettlementIds.length) this.form.preSettlementId = this.form.preSettlementIds[0];
@@ -1696,18 +1647,27 @@ export default {
settlementAmount: Number(Number(settlementAmount || 0).toFixed(2)), settlementAmount: Number(Number(settlementAmount || 0).toFixed(2)),
payableAmount: Number(Math.max(0, payableAmount).toFixed(2)), payableAmount: Number(Math.max(0, payableAmount).toFixed(2)),
paymentSequence: Number.isFinite(paymentSequence) && paymentSequence > 0 ? paymentSequence : 1, paymentSequence: Number.isFinite(paymentSequence) && paymentSequence > 0 ? paymentSequence : 1,
paymentRatio: null,
appliedAmount: null,
}; };
}, },
nullableNumber(value) {
if (value === undefined || value === null || value === '') return null;
const amount = Number(value);
return Number.isFinite(amount) ? amount : null;
},
/** 付款基本信息内部:已填写比例时,按结算金额重算申请付款金额;不读取结算行付款金额。 */
headerAppliedAmountByRatio(settlementAmount) {
if (this.nullableNumber(this.form.paymentRatio) === null) return this.form.appliedAmount;
return Number(((Number(settlementAmount || 0) * Number(this.form.paymentRatio)) / 100).toFixed(2));
},
/** /**
* 按合同付款比例配置,判断当前付款次序是否超上限。 * 按合同付款比例配置,判断当前付款次序是否超上限。
* @param {Object} row 结算信息行(含 paymentSequence) * @param {Object} row 结算信息行(含 paymentSequence)
*/ */
buildSettlementRatioHint(row = {}) { buildSettlementRatioHint(row = {}) {
const sequence = Number(row.paymentSequence || 1); const sequence = Number(row.paymentSequence || 1);
const rawRatio = const rawRatio = row.paymentRatio;
row.paymentRatio !== undefined && row.paymentRatio !== null && row.paymentRatio !== ''
? row.paymentRatio
: this.form.paymentRatio;
const paymentRatio = Number(rawRatio); const paymentRatio = Number(rawRatio);
const ratioConfig = this.contractPaymentRatioRows[sequence - 1]; const ratioConfig = this.contractPaymentRatioRows[sequence - 1];
const ratioLimitValue = ratioConfig?.ratioLimit; const ratioLimitValue = ratioConfig?.ratioLimit;
@@ -1850,21 +1810,6 @@ export default {
(total, item) => total + Number(item.payableAmount || 0), (total, item) => total + Number(item.payableAmount || 0),
0 0
); );
const multiple = this.referenceRows.length > 1;
const paymentRatio = Number(this.form.paymentRatio || 0);
const rowsAppliedTotal = this.referenceRows.reduce(
(total, item) => total + Number(item.appliedAmount || 0),
0
);
// 多结算单:表头申请金额只由上方比例计算;单结算单仍按可付款金额×比例
const headerAppliedAmount = multiple
? Number(((settlementAmount * paymentRatio) / 100).toFixed(2))
: Number(((payableAmount * paymentRatio) / 100).toFixed(2));
const headerPaymentRatio = multiple
? paymentRatio
: settlementAmount && rowsAppliedTotal > 0
? Number(((rowsAppliedTotal / settlementAmount) * 100).toFixed(2))
: paymentRatio;
// 尾款付款场景下选择预付结算单时保留尾款付款类型,不强制改为进度预付 // 尾款付款场景下选择预付结算单时保留尾款付款类型,不强制改为进度预付
this.amountSyncing = true; this.amountSyncing = true;
Object.assign(this.form, { Object.assign(this.form, {
@@ -1890,8 +1835,7 @@ export default {
settlementAmount: Number(settlementAmount.toFixed(2)), settlementAmount: Number(settlementAmount.toFixed(2)),
payableAmount: Number(payableAmount.toFixed(2)), payableAmount: Number(payableAmount.toFixed(2)),
billType: '', billType: '',
paymentRatio: headerPaymentRatio, appliedAmount: this.headerAppliedAmountByRatio(settlementAmount),
appliedAmount: headerAppliedAmount,
}); });
this.rememberSettlementParties(first.payerName, first.payeeName); this.rememberSettlementParties(first.payerName, first.payeeName);
this.paymentRecords = []; this.paymentRecords = [];
@@ -3080,22 +3024,54 @@ export default {
return Number(Math.min(100, (payableAmount / settlementAmount) * 100).toFixed(2)); return Number(Math.min(100, (payableAmount / settlementAmount) * 100).toFixed(2));
}, },
/** /**
* 多结算单行金额规范化:仅在行内反算比例,不使用表头比例灌入(表头与结算信息不关联)。 * 结算行金额规范化:只在行内反算比例,不使用付款基本信息的比例和申请金额。
*/ */
seedMultipleRowPayments(rows = []) { seedMultipleRowPayments(rows = []) {
if (rows.length <= 1) return;
rows.forEach(item => { rows.forEach(item => {
const settlementAmount = Number(item.settlementAmount || 0); if (this.nullableNumber(item.appliedAmount) === null && this.nullableNumber(item.paymentRatio) === null) {
const payableAmount = Number(item.payableAmount || 0); item.appliedAmount = null;
let applied = Number(item.appliedAmount || 0); item.paymentRatio = null;
if (!Number.isFinite(applied) || applied < 0) applied = 0; return;
if (applied > payableAmount) applied = Number(payableAmount.toFixed(2)); }
item.appliedAmount = Number(applied.toFixed(2)); this.syncRowPayment(item, 'amount');
item.paymentRatio = settlementAmount
? Number(((item.appliedAmount / settlementAmount) * 100).toFixed(2))
: 0;
}); });
}, },
/**
* 结算行付款比例与付款金额联动。比例 = 付款金额 / 结算总金额。
* @param {'ratio'|'amount'} source 以哪一侧为准
*/
syncRowPayment(row, source) {
const settlementAmount = Number(row.settlementAmount || 0);
const payableAmount = Number(row.payableAmount || 0);
if (source === 'ratio') {
const ratio = this.nullableNumber(row.paymentRatio);
if (ratio === null) {
row.paymentRatio = null;
row.appliedAmount = null;
return;
}
let applied = Number(((settlementAmount * ratio) / 100).toFixed(2));
if (applied > payableAmount) applied = Number(payableAmount.toFixed(2));
row.appliedAmount = applied;
row.paymentRatio = settlementAmount
? Number(((applied / settlementAmount) * 100).toFixed(2))
: Number(ratio.toFixed(2));
return;
}
const appliedValue = this.nullableNumber(row.appliedAmount);
if (appliedValue === null) {
row.appliedAmount = null;
row.paymentRatio = null;
return;
}
let applied = appliedValue;
if (applied < 0) applied = 0;
if (applied > payableAmount) applied = payableAmount;
row.appliedAmount = Number(applied.toFixed(2));
row.paymentRatio = settlementAmount
? Number(((row.appliedAmount / settlementAmount) * 100).toFixed(2))
: 0;
},
findReferenceRow(row) { findReferenceRow(row) {
return this.referenceRows.find( return this.referenceRows.find(
item => item =>
@@ -3104,55 +3080,19 @@ export default {
item.settlementNo === row.settlementNo item.settlementNo === row.settlementNo
); );
}, },
/** 结算信息付款金额合计 */
settlementRowsAppliedTotal(rows = this.referenceRows) {
return (rows || []).reduce((total, item) => total + Number(item.appliedAmount || 0), 0);
},
/** 结算信息合计不得超过上方申请付款金额 */
ensureSettlementAppliedWithinHeader(showWarning = true) {
if (!this.multipleSettlementRows) return true;
const headerApplied = Number(this.form.appliedAmount || 0);
const rowsTotal = Number(this.settlementRowsAppliedTotal().toFixed(2));
if (rowsTotal - headerApplied > 1e-8) {
if (showWarning) {
this.$message.warning(
`结算信息付款金额合计(${rowsTotal.toFixed(2)})不能大于申请付款金额(${headerApplied.toFixed(2)})`
);
}
return false;
}
return true;
},
handleRowPaymentRatioChange(row, value) { handleRowPaymentRatioChange(row, value) {
const target = this.findReferenceRow(row); const target = this.findReferenceRow(row);
if (!target) return; if (!target) return;
const settlementAmount = Number(target.settlementAmount || 0); target.paymentRatio = value;
const payableAmount = Number(target.payableAmount || 0); this.syncRowPayment(target, 'ratio');
const ratio = value === null || value === undefined || value === '' ? 0 : Number(value);
let applied = Number(((settlementAmount * ratio) / 100).toFixed(2));
if (applied > payableAmount) applied = Number(payableAmount.toFixed(2));
target.appliedAmount = applied;
target.paymentRatio = settlementAmount
? Number(((applied / settlementAmount) * 100).toFixed(2))
: Number(ratio.toFixed(2));
// 仅刷新结算金额/可付款金额合计,不回写表头申请金额与比例
this.syncHeaderSettlementTotalsFromRows(); this.syncHeaderSettlementTotalsFromRows();
this.ensureSettlementAppliedWithinHeader(true);
}, },
handleRowAppliedAmountChange(row, value) { handleRowAppliedAmountChange(row, value) {
const target = this.findReferenceRow(row); const target = this.findReferenceRow(row);
if (!target) return; if (!target) return;
const settlementAmount = Number(target.settlementAmount || 0); target.appliedAmount = value;
const payableAmount = Number(target.payableAmount || 0); this.syncRowPayment(target, 'amount');
let applied = value === null || value === undefined || value === '' ? 0 : Number(value);
if (!Number.isFinite(applied) || applied < 0) applied = 0;
if (applied > payableAmount) applied = payableAmount;
target.appliedAmount = Number(applied.toFixed(2));
target.paymentRatio = settlementAmount
? Number(((target.appliedAmount / settlementAmount) * 100).toFixed(2))
: 0;
this.syncHeaderSettlementTotalsFromRows(); this.syncHeaderSettlementTotalsFromRows();
this.ensureSettlementAppliedWithinHeader(true);
}, },
/** 多结算单时仅同步结算金额/可付款金额合计,申请付款金额与比例由上方独立维护 */ /** 多结算单时仅同步结算金额/可付款金额合计,申请付款金额与比例由上方独立维护 */
syncHeaderSettlementTotalsFromRows() { syncHeaderSettlementTotalsFromRows() {
@@ -3261,8 +3201,8 @@ export default {
settlementLines: (this.referenceRows || []).map(item => ({ settlementLines: (this.referenceRows || []).map(item => ({
formalSettlementId: item.referenceType === 'formal' ? item.id : null, formalSettlementId: item.referenceType === 'formal' ? item.id : null,
preSettlementId: item.referenceType === 'pre' ? item.id : null, preSettlementId: item.referenceType === 'pre' ? item.id : null,
paymentRatio: item.paymentRatio, paymentRatio: this.nullableNumber(item.paymentRatio),
appliedAmount: item.appliedAmount, appliedAmount: this.nullableNumber(item.appliedAmount),
})), })),
paymentMethod, paymentMethod,
billLedgerId, billLedgerId,
@@ -3323,21 +3263,22 @@ export default {
ElMessage.warning('申请付款金额必填且必须大于0'); ElMessage.warning('申请付款金额必填且必须大于0');
return false; return false;
} }
if (this.multipleSettlementRows) { if (this.isSettlementRelatedType && this.referenceRows.length) {
const invalidRow = this.referenceRows.find( const invalidRow = this.referenceRows.find(item => {
item => Number(item.appliedAmount || 0) > Number(item.payableAmount || 0) const rowApplied = Number(item.appliedAmount || 0);
); return !Number.isFinite(rowApplied) || rowApplied <= 0;
});
if (invalidRow) { if (invalidRow) {
this.clearMessages(); this.clearMessages();
ElMessage.warning(`${invalidRow.settlementNo || '结算单'}付款金额不能超过可付款金额`); ElMessage.warning(`${invalidRow.settlementNo || '结算单'}请填写付款金额,且必须大于0`);
return false; return false;
} }
const rowsTotal = Number(this.settlementRowsAppliedTotal().toFixed(2)); const exceededRow = this.referenceRows.find(
if (rowsTotal - appliedAmount > 1e-8) { item => Number(item.appliedAmount || 0) > Number(item.payableAmount || 0)
);
if (exceededRow) {
this.clearMessages(); this.clearMessages();
ElMessage.warning( ElMessage.warning(`${exceededRow.settlementNo || '结算单'}付款金额不能超过可付款金额`);
`结算信息付款金额合计(${rowsTotal.toFixed(2)})不能大于申请付款金额(${appliedAmount.toFixed(2)})`
);
return false; return false;
} }
} }
@@ -380,7 +380,13 @@ export default {
}, },
async loadFormalSettlements(keyword = '') { async loadFormalSettlements(keyword = '') {
const { data } = await api.getFormalSettlements(keyword); const { data } = await api.getFormalSettlements(keyword);
this.candidates = data?.data || data || []; const payload = data?.data ?? data;
// 兼容误返回分页对象的情况,只取正式结算单列表
this.candidates = Array.isArray(payload)
? payload
: Array.isArray(payload?.records)
? payload.records
: [];
}, },
async loadFeeOptions() { async loadFeeOptions() {
const { data } = await api.getFeeOptions(); const { data } = await api.getFeeOptions();
@@ -926,14 +926,34 @@ const stripInternalRowFields = row => {
}; };
}; };
/** 合并费用列名与各行 feeItems 键,避免保存/回显时丢动态费用列 */
const collectFeeItemNames = (preferred = [], detailRows = []) => {
const names = [];
const seen = new Set();
const pushName = value => {
const name = String(value || '').trim();
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
};
(preferred || []).forEach(pushName);
(detailRows || []).forEach(row => {
const feeItems = row?.feeItems;
if (!feeItems || typeof feeItems !== 'object' || Array.isArray(feeItems)) return;
Object.keys(feeItems).forEach(pushName);
});
return names;
};
const buildPayload = () => { const buildPayload = () => {
const { file, ...batch } = form; const { file, ...batch } = form;
const payloadRows = rows.value.map(stripInternalRowFields);
return { return {
...batch, ...batch,
settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal', settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal',
waybillMode: form.waybillMode === 'history' ? 'history' : 'new', waybillMode: form.waybillMode === 'history' ? 'history' : 'new',
feeItemNames: [...feeItemNames.value], feeItemNames: collectFeeItemNames(feeItemNames.value, payloadRows),
rows: rows.value.map(stripInternalRowFields), rows: payloadRows,
}; };
}; };
@@ -1436,9 +1456,13 @@ const loadDetail = async id => {
if (detail.projectId) { if (detail.projectId) {
await loadContractsByProject(detail.projectId, detail.projectName || ''); await loadContractsByProject(detail.projectId, detail.projectName || '');
} }
feeItemNames.value = Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [];
parsedHeaders.value = []; parsedHeaders.value = [];
const detailRows = Array.isArray(detail.rows) ? detail.rows : []; const detailRows = Array.isArray(detail.rows) ? detail.rows : [];
// 先按库中列名与明细 feeItems 合并,再规范化行数据,保证草稿再打开仍展示全部费用列
feeItemNames.value = collectFeeItemNames(
Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [],
detailRows
);
rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index)); rows.value = detailRows.map((item, index) => normalizeParsedRow(item, index));
refreshDuplicateFlags(); refreshDuplicateFlags();
// 查看模式始终只读;编辑时仅草稿可改,已完成/已作废强制只读 // 查看模式始终只读;编辑时仅草稿可改,已完成/已作废强制只读
@@ -125,15 +125,17 @@
<span class="reconciliation-editor__filter-label">货物名称</span> <span class="reconciliation-editor__filter-label">货物名称</span>
<el-input v-model="internalQuery.cargoName" placeholder="请输入" clearable /> <el-input v-model="internalQuery.cargoName" placeholder="请输入" clearable />
</div> </div>
<el-button @click="resetInternalQuery">重置</el-button> <div class="reconciliation-editor__filter-actions">
<el-button type="primary" @click="internalFilterTick++">查询</el-button> <el-button @click="resetInternalQuery">重置</el-button>
<el-button <el-button type="primary" @click="internalFilterTick++">查询</el-button>
type="primary" <el-button
plain type="primary"
:disabled="!filteredInternalRows.length" plain
@click="exportInternalDetails" :disabled="!filteredInternalRows.length"
>导出</el-button @click="exportInternalDetails"
> >导出</el-button
>
</div>
</div> </div>
<el-table <el-table
:data="filteredInternalRows" :data="filteredInternalRows"
@@ -189,6 +191,9 @@
" "
>{{ formatDate(row[column.prop]) }}</span >{{ formatDate(row[column.prop]) }}</span
> >
<span v-else-if="column.prop === 'unitPrice'">{{
formatOptionalMoney(row.unitPrice)
}}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span> <span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span> <span v-else>{{ displayValue(row[column.prop]) }}</span>
</template> </template>
@@ -318,6 +323,9 @@
" "
>{{ formatDate(row[column.prop]) }}</span >{{ formatDate(row[column.prop]) }}</span
> >
<span v-else-if="column.prop === 'unitPrice'">{{
formatOptionalMoney(row.unitPrice)
}}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span> <span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span> <span v-else>{{ displayValue(row[column.prop]) }}</span>
</template> </template>
@@ -476,6 +484,7 @@
<span v-else-if="column.feeItemName">{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}</span> <span v-else-if="column.feeItemName">{{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }}</span>
<span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span> <span v-else-if="column.prop === 'mileage'">{{ formatMileage(row.mileage) }}</span>
<span v-else-if="column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'">{{ formatDate(row[column.prop]) }}</span> <span v-else-if="column.prop === 'actualDepartureTime' || column.prop === 'actualCompletionTime'">{{ formatDate(row[column.prop]) }}</span>
<span v-else-if="column.prop === 'unitPrice'">{{ formatOptionalMoney(row.unitPrice) }}</span>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span> <span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span> <span v-else>{{ displayValue(row[column.prop]) }}</span>
</template> </template>
@@ -630,7 +639,10 @@ import { getDictionary } from '@/api/system/dictbiz';
import { Search } from '@element-plus/icons-vue'; import { Search } from '@element-plus/icons-vue';
import { h } from 'vue'; import { h } from 'vue';
import { downloadXls } from '@/utils/util'; import { downloadXls } from '@/utils/util';
import { isExcelResponse, parseBlobJson } from '@/utils/import-excel'; import {
consumeImportResponse,
resolveImportErrorMessage,
} from '@/utils/import-excel';
import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm'; import { transportReconciliationFormFields } from '@/option/settlement/transportReconciliationForm';
import { import {
internalColumns, internalColumns,
@@ -1042,12 +1054,24 @@ export default {
.map(normalized) .map(normalized)
.join('|'); .join('|');
}, },
blankOptionalNumber(value) {
if (value === null || value === undefined || value === '' || Number(value) === -1) {
return null;
}
return value;
},
normalizeInternalRow(row) { normalizeInternalRow(row) {
const feeItems = const feeItems =
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
? row.feeItems ? row.feeItems
: this.parseFeeItems(row.feeItemsJson); : this.parseFeeItems(row.feeItemsJson);
return { ...row, feeItems }; return {
...row,
feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
};
}, },
normalizeExternalRow(row, index) { normalizeExternalRow(row, index) {
const feeItems = const feeItems =
@@ -1057,6 +1081,9 @@ export default {
return { return {
...row, ...row,
feeItems, feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
_externalKey: row.id || row.externalLineNo || `external-${index}`, _externalKey: row.id || row.externalLineNo || `external-${index}`,
}; };
}, },
@@ -1284,6 +1311,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {}); snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime); snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime); snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.unitPrice = this.blankOptionalNumber(row.unitPrice);
snapshot.mileage = this.blankOptionalNumber(row.mileage);
snapshot.freightAmount = this.blankOptionalNumber(row.freightAmount);
return snapshot; return snapshot;
}); });
}, },
@@ -1297,6 +1327,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {}); snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime); snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime); snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.unitPrice = this.blankOptionalNumber(row.unitPrice);
snapshot.mileage = this.blankOptionalNumber(row.mileage);
snapshot.freightAmount = this.blankOptionalNumber(row.freightAmount);
snapshot.suspectedDuplicate = duplicateIds.has(String(row.id)); snapshot.suspectedDuplicate = duplicateIds.has(String(row.id));
return snapshot; return snapshot;
}); });
@@ -1326,7 +1359,7 @@ export default {
} }
}, },
async handleUpdate() { async handleUpdate() {
await this.$confirm('将以外部账单数据更新运单及内部结算明细,是否继续?', '更新账单', { await this.$confirm('将以外部账单数据更新内部结算明细,是否继续?', '更新账单', {
type: 'warning', type: 'warning',
}); });
this.actionLoading = true; this.actionLoading = true;
@@ -1508,22 +1541,23 @@ export default {
? await api.importCargo(this.currentId, file) ? await api.importCargo(this.currentId, file)
: await api.importVehicle(this.currentId, file); : await api.importVehicle(this.currentId, file);
// 成功时 axios 拦截器可能已把 JSON Blob 解析成对象,不能再调 .text() // 成功时 axios 拦截器可能已把 JSON Blob 解析成对象,不能再调 .text()
if (isExcelResponse(response)) { const consumed = await consumeImportResponse(response, {
downloadXls( businessName: '运输对账',
response.data, dayjsFormat: this.$dayjs().format('YYYY-MM-DD HH-mm-ss'),
`运输对账导入失败明细${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx` });
); if (consumed.kind === 'excel') {
downloadXls(consumed.blob, consumed.fileName);
this.$message.warning('部分数据导入失败,已下载失败明细'); this.$message.warning('部分数据导入失败,已下载失败明细');
} else { } else {
const result = await parseBlobJson(response.data);
if (Number(result.code) !== 200) throw new Error(result.msg || '导入失败');
this.$message.success('外部账单导入成功'); this.$message.success('外部账单导入成功');
} }
this.matchingStarted = false; this.matchingStarted = false;
await this.loadDetail(); await this.loadDetail();
this.matchingStarted = false; this.matchingStarted = false;
} catch (error) { } catch (error) {
this.$message.error(error.message || '外部账单导入失败'); if (!error?._notified) {
this.$message.error(await resolveImportErrorMessage(error));
}
} }
}, },
async downloadTemplate(mode) { async downloadTemplate(mode) {
@@ -1831,7 +1865,10 @@ export default {
: Number(value || 0).toFixed(2); : Number(value || 0).toFixed(2);
}, },
formatMileage(value) { formatMileage(value) {
return Number(value) === -1 ? '' : this.displayValue(value); return this.blankOptionalNumber(value) === null ? '' : this.displayValue(value);
},
formatOptionalMoney(value, currency = 'RMB') {
return this.blankOptionalNumber(value) === null ? '-' : this.formatMoney(value, currency);
}, },
formatDate(value) { formatDate(value) {
if (value === null || value === undefined || value === '') return '-'; if (value === null || value === undefined || value === '') return '-';
@@ -1952,15 +1989,26 @@ export default {
} }
.reconciliation-editor__filter { .reconciliation-editor__filter {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto auto; grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
gap: 8px; gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
align-items: center;
} }
.reconciliation-editor__filter-item { .reconciliation-editor__filter-item {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
} }
.reconciliation-editor__filter-actions {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.reconciliation-editor__filter-actions .el-button {
min-width: 64px;
margin-left: 0;
}
.reconciliation-editor__filter-label { .reconciliation-editor__filter-label {
flex: 0 0 auto; flex: 0 0 auto;
margin-right: 8px; margin-right: 8px;
@@ -6,15 +6,33 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed, getCurrentInstance, nextTick, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import SettlementImportPage from './components/settlement-import-page.vue'; import SettlementImportPage from './components/settlement-import-page.vue';
const route = useRoute(); const route = useRoute();
const store = useStore();
const instance = getCurrentInstance();
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (route.query.mode === 'view') return '查看导入结算单'; if (route.query.mode === 'view') return '查看导入结算单';
return route.query.id ? '编辑导入结算单' : '新增导入结算单'; return route.query.id ? '编辑导入结算单' : '新增导入结算单';
}); });
const syncTagTitle = () => {
nextTick(() => {
store.commit('SET_TAG', {
fullPath: route.fullPath,
name: pageTitle.value,
});
instance?.proxy?.$router?.$avueRouter?.setTitle?.(pageTitle.value);
});
};
onMounted(() => {
syncTagTitle();
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
+6 -3
View File
@@ -239,19 +239,22 @@ export default {
this.onLoad(this.page); this.onLoad(this.page);
}, },
openCreate() { openCreate() {
this.$router.push({ path: '/settlement/settlement-import/form' }); this.$router.push({
path: '/settlement/settlement-import/form',
query: { name: '新增导入结算单' },
});
}, },
openDetail(row) { openDetail(row) {
if (!row?.id) return; if (!row?.id) return;
this.$router.push({ this.$router.push({
path: '/settlement/settlement-import/form', path: '/settlement/settlement-import/form',
query: { id: row.id, mode: 'view' }, query: { id: row.id, mode: 'view', name: '查看导入结算单' },
}); });
}, },
editDraft(row) { editDraft(row) {
this.$router.push({ this.$router.push({
path: '/settlement/settlement-import/form', path: '/settlement/settlement-import/form',
query: { id: row.id }, query: { id: row.id, name: '编辑导入结算单' },
}); });
}, },
async handleDelete(row) { async handleDelete(row) {
+105 -2
View File
@@ -115,9 +115,10 @@
<template #deptName="{ row }"> <template #deptName="{ row }">
<template v-if="formatDeptNames(row.deptName).length"> <template v-if="formatDeptNames(row.deptName).length">
<el-tag <el-tag
v-for="name in formatDeptNames(row.deptName)" v-for="(name, index) in formatDeptNames(row.deptName)"
:key="name" :key="`${row.id}-${index}-${name}`"
class="user-dept-tag" class="user-dept-tag"
@click.stop="handleDeptTagClick(row, index)"
>{{ name }}</el-tag> >{{ name }}</el-tag>
</template> </template>
<span v-else>-</span> <span v-else>-</span>
@@ -666,6 +667,104 @@ export default {
this.page.currentPage = 1; this.page.currentPage = 1;
this.onLoad(this.page); this.onLoad(this.page);
}, },
formatDeptIds(deptId) {
if (!deptId && deptId !== 0) {
return [];
}
return String(deptId)
.split(',')
.map(item => item.trim())
.filter(Boolean);
},
handleDeptTagClick(row, index) {
const deptIds = this.formatDeptIds(row.deptId);
let deptId = deptIds[index];
if (!deptId) {
const names = this.formatDeptNames(row.deptName);
deptId = this.findDeptIdByName(names[index]);
}
if (!deptId) {
this.$message.warning('未找到对应组织');
return;
}
this.selectDeptOnTree(deptId);
},
findDeptIdByName(name, tree = this.permissionDeptTree) {
if (!name) return null;
for (const node of tree || []) {
if (node.title === name || node.deptName === name || node.label === name) {
return node.id;
}
const matched = this.findDeptIdByName(name, node.children);
if (matched != null) return matched;
}
return null;
},
/**
* 展开左侧懒加载组织树到目标节点并设为选中,同时按该组织筛选用户列表
*/
async selectDeptOnTree(deptId) {
if (deptId == null || deptId === '') return;
if (!this.permissionDeptTree?.length) {
const tenantId = this.userInfo.tenantId || this.website.tenantId;
try {
const res = await getDeptTree(tenantId);
this.permissionDeptTree = res.data.data || [];
} catch (error) {
this.$message.error('组织树加载失败');
return;
}
}
const path = this.findDeptPath(deptId);
if (!path?.length) {
this.$message.warning('未在组织树中找到该部门');
return;
}
const tree = this.$refs.deptTree;
if (!tree) return;
for (let i = 0; i < path.length - 1; i++) {
await this.expandLazyTreeNode(path[i]);
}
await this.$nextTick();
const targetId = path[path.length - 1];
tree.setCurrentKey?.(targetId);
this.$nextTick(() => {
const currentEl = tree.$el?.querySelector?.('.el-tree-node.is-current');
currentEl?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
});
if (String(this.treeDeptId) === String(targetId)) {
return;
}
this.treeDeptId = targetId;
this.page.currentPage = 1;
this.onLoad(this.page);
},
expandLazyTreeNode(key) {
return new Promise(resolve => {
const tree = this.$refs.deptTree;
if (!tree?.getNode) {
resolve();
return;
}
const tryExpand = (retries = 40) => {
const node = tree.getNode(key);
if (!node) {
if (retries <= 0) {
resolve();
return;
}
setTimeout(() => tryExpand(retries - 1), 50);
return;
}
if (node.expanded || node.isLeaf) {
resolve();
return;
}
node.expand(() => resolve());
};
tryExpand();
});
},
handleDropdownCommand(command, row, index) { handleDropdownCommand(command, row, index) {
if (command === 'setLeader') { if (command === 'setLeader') {
this.handleSetLeader(row); this.handleSetLeader(row);
@@ -1537,6 +1636,10 @@ export default {
justify-content: flex-start; justify-content: flex-start;
} }
.user-dept-tag {
cursor: pointer;
}
.user-dept-tag + .user-dept-tag { .user-dept-tag + .user-dept-tag {
margin-left: 6px; margin-left: 6px;
} }
@@ -101,6 +101,17 @@
style="width: 500px" style="width: 500px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="减分最高扣分">
<el-input
:model-value="configForm.maxMinusScore"
placeholder="所有减分项合计不得超过该分值"
clearable
style="width: 500px"
@input="handleMaxMinusScoreInput"
>
<template #append>分</template>
</el-input>
</el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input <el-input
v-model="configForm.remark" v-model="configForm.remark"
@@ -628,6 +639,7 @@ export default {
name: '', name: '',
remark: '', remark: '',
standardDescription: '', standardDescription: '',
maxMinusScore: '',
status: 3, status: 3,
categories: this.defaultCategories(), categories: this.defaultCategories(),
standards: [], standards: [],
@@ -684,6 +696,7 @@ export default {
}); });
return { return {
...detail, ...detail,
maxMinusScore: this.normalizePlainDecimal(detail.maxMinusScore),
categories: categories.map(category => ({ categories: categories.map(category => ({
...(map[category.categoryCode] || category), ...(map[category.categoryCode] || category),
categoryName: category.categoryName, categoryName: category.categoryName,
@@ -691,6 +704,14 @@ export default {
standards: (detail.standards || []).map(standard => this.normalizeStandard(standard)), standards: (detail.standards || []).map(standard => this.normalizeStandard(standard)),
}; };
}, },
handleMaxMinusScoreInput(value) {
const raw = String(value ?? '')
.replace(/[^\d.]/g, '')
.replace(/^\./, '')
.replace(/(\..*)\./g, '$1')
.replace(/^(\d+)(\.\d{0,2})?.*$/, '$1$2');
this.configForm.maxMinusScore = raw;
},
openConfig(row, readonly = false) { openConfig(row, readonly = false) {
this.readonly = readonly; this.readonly = readonly;
if (row && row.id) { if (row && row.id) {
@@ -816,6 +837,9 @@ export default {
}, },
prepareSubmitConfig(config) { prepareSubmitConfig(config) {
const payload = JSON.parse(JSON.stringify(config || {})); const payload = JSON.parse(JSON.stringify(config || {}));
const maxMinus = this.normalizePlainDecimal(payload.maxMinusScore);
payload.maxMinusScore =
maxMinus === '' || maxMinus == null ? null : this.toScoreValue(maxMinus);
(payload.categories || []).forEach(category => { (payload.categories || []).forEach(category => {
(category.items || []).forEach(item => { (category.items || []).forEach(item => {
item.score = this.toScoreValue(item.score); item.score = this.toScoreValue(item.score);
@@ -1072,6 +1096,14 @@ export default {
this.$message.warning('请输入评定表名称'); this.$message.warning('请输入评定表名称');
return false; return false;
} }
if (
this.configForm.maxMinusScore !== '' &&
this.configForm.maxMinusScore != null &&
!this.isNonNegativeDecimal(this.configForm.maxMinusScore)
) {
this.$message.warning('减分最高扣分须为不小于0的数字,最多两位小数');
return false;
}
const basic = this.configForm.categories.find(item => item.categoryCode === 'basic'); const basic = this.configForm.categories.find(item => item.categoryCode === 'basic');
if (!basic || basic.items.length === 0) { if (!basic || basic.items.length === 0) {
this.$message.warning('至少配置1个基础得分项目'); this.$message.warning('至少配置1个基础得分项目');
+138 -38
View File
@@ -592,18 +592,27 @@
<span>{{ row.reviewStatus }}</span> <span>{{ row.reviewStatus }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center"> <el-table-column label="操作" min-width="220" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-link type="primary" @click="openScore(row.__raw, row.__rawIndex)"> <div class="score-list-table__actions">
详情 <el-link type="primary" @click="openScore(row.__raw, row.__rawIndex)">
</el-link> 详情
<el-link </el-link>
v-if="showScoreDelete" <el-link
type="danger" v-if="canReviewScore(row)"
@click="deleteScore(row.__rawIndex)" type="primary"
> @click="startReviewScore(row.__raw, row.__rawIndex)"
删除 >
</el-link> 复评
</el-link>
<el-link
v-if="showScoreDelete"
type="danger"
@click="deleteScore(row.__rawIndex)"
>
删除
</el-link>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -2231,7 +2240,8 @@ export default {
showScoreDelete() { showScoreDelete() {
if (this.readonly) return false; if (this.readonly) return false;
const status = this.archiveForm.approvalStatus || 'draft'; const status = this.archiveForm.approvalStatus || 'draft';
return status === 'draft' || status === 'rejected'; // 草稿/驳回可删;审批通过后也允许删除并重建评分表(旧评分逻辑删除保留,新评分审核通过前仍用旧额度)
return status === 'draft' || status === 'rejected' || status === 'approved';
}, },
isNewScore() { isNewScore() {
return this.scoreRecordIndex < 0 && !this.reviewMode; return this.scoreRecordIndex < 0 && !this.reviewMode;
@@ -2281,10 +2291,17 @@ export default {
{ categoryCode: 'plus', categoryName: '加分项目' }, { categoryCode: 'plus', categoryName: '加分项目' },
{ categoryCode: 'minus', categoryName: '减分项目' }, { categoryCode: 'minus', categoryName: '减分项目' },
]; ];
const maxMinus = Number(this.currentScore?.maxMinusScore);
const hasMaxMinus = Number.isFinite(maxMinus) && maxMinus >= 0;
const rows = categoryMap.map((category, index) => { const rows = categoryMap.map((category, index) => {
const details = this.getScoreCategoryDetails(category.categoryCode); const details = this.getScoreCategoryDetails(category.categoryCode);
const selfScore = details.reduce((sum, item) => sum + Number(item.selfScore || 0), 0); let selfScore = details.reduce((sum, item) => sum + Number(item.selfScore || 0), 0);
const reviewScore = details.reduce((sum, item) => sum + Number(item.reviewScore || 0), 0); let reviewScore = details.reduce((sum, item) => sum + Number(item.reviewScore || 0), 0);
// 减分项目合计受量化表「最高扣分」限制
if (category.categoryCode === 'minus' && hasMaxMinus) {
selfScore = Math.min(selfScore, maxMinus);
reviewScore = Math.min(reviewScore, maxMinus);
}
return { return {
...category, ...category,
index: index + 1, index: index + 1,
@@ -4199,6 +4216,7 @@ export default {
scoreDate: this.$dayjs().format('YYYY-MM-DD'), scoreDate: this.$dayjs().format('YYYY-MM-DD'),
applyCreditLimit: this.archiveForm.applyCreditLimit || '', applyCreditLimit: this.archiveForm.applyCreditLimit || '',
maxCreditLimit: '', maxCreditLimit: '',
maxMinusScore: '',
creditLevel: '', creditLevel: '',
standards: [], standards: [],
tempApplyCreditLimit: '', tempApplyCreditLimit: '',
@@ -4218,6 +4236,10 @@ export default {
: score.applyCreditLimit : score.applyCreditLimit
), ),
maxCreditLimit: this.normalizeOptionalAmount(score.maxCreditLimit), maxCreditLimit: this.normalizeOptionalAmount(score.maxCreditLimit),
maxMinusScore:
score.maxMinusScore === undefined || score.maxMinusScore === null
? ''
: score.maxMinusScore,
tempApplyCreditLimit: this.normalizeOptionalAmount(score.tempApplyCreditLimit), tempApplyCreditLimit: this.normalizeOptionalAmount(score.tempApplyCreditLimit),
attachments: score.attachments || this.parseAttachments(score.proofAttachments), attachments: score.attachments || this.parseAttachments(score.proofAttachments),
details: (score.details || []).map(detail => ({ details: (score.details || []).map(detail => ({
@@ -4256,7 +4278,14 @@ export default {
String(item.score.creditLevel || '').trim() String(item.score.creditLevel || '').trim()
)[0]?.score; )[0]?.score;
}, },
/** 曾审核通过后:删除/重建评分在再次审核通过前,主表等级与额度保持旧值暂时生效 */
shouldFreezeEffectiveCredit(archive = this.archiveForm) {
return Boolean(archive?.approvedTime);
},
applyArchiveCreditFromScores(archive) { applyArchiveCreditFromScores(archive) {
if (this.shouldFreezeEffectiveCredit(archive)) {
return archive;
}
const latestScore = this.getLatestCreditScore(archive.scores || []); const latestScore = this.getLatestCreditScore(archive.scores || []);
archive.customerLevel = latestScore?.creditLevel || archive.customerLevel || ''; archive.customerLevel = latestScore?.creditLevel || archive.customerLevel || '';
archive.maxCreditLimit = latestScore?.maxCreditLimit || archive.maxCreditLimit || ''; archive.maxCreditLimit = latestScore?.maxCreditLimit || archive.maxCreditLimit || '';
@@ -4588,9 +4617,31 @@ export default {
} }
this.$nextTick(() => this.openScore(scores[0], 0)); this.$nextTick(() => this.openScore(scores[0], 0));
}, },
/** 提交后详情页评分表:审批中且未完成复评时可复评 */
canReviewScore(row = {}) {
return (
this.hasPermission('customer_type_re_cert') &&
this.archiveForm.approvalStatus === 'reviewing' &&
row.reviewStatus !== '已完成'
);
},
startReviewScore(row, index = 0) {
if (!row) {
this.$message.warning('暂无评分记录,无法复评');
return;
}
// 详情只读进入时也要能改复评得分并确认
this.reviewMode = true;
this.openScore(row, index);
},
addScore() { addScore() {
if ((this.archiveForm.scores || []).length) { const existingScores = this.archiveForm.scores || [];
this.$message.warning('请勿重复添加'); if (existingScores.length) {
this.$message.warning(
this.shouldFreezeEffectiveCredit()
? '请先删除原评分记录后再添加新评分表'
: '请勿重复添加'
);
return; return;
} }
this.scoreRecordIndex = -1; this.scoreRecordIndex = -1;
@@ -4611,9 +4662,9 @@ export default {
const loadOptions = this.scoreQuantificationOptions.length const loadOptions = this.scoreQuantificationOptions.length
? Promise.resolve() ? Promise.resolve()
: this.initScoreQuantificationOptions(); : this.initScoreQuantificationOptions();
loadOptions.then(() => loadOptions
this.ensureScoreQuantificationOption(this.currentScore.quantificationId) .then(() => this.ensureScoreQuantificationOption(this.currentScore.quantificationId))
); .then(() => this.applyMaxMinusScoreFromQuantification(this.currentScore.quantificationId));
}, },
resetScoreDialog() { resetScoreDialog() {
this.scoreRecordIndex = -1; this.scoreRecordIndex = -1;
@@ -4648,6 +4699,11 @@ export default {
applyCreditLimit: this.currentScore.applyCreditLimit, applyCreditLimit: this.currentScore.applyCreditLimit,
maxCreditLimit: '', maxCreditLimit: '',
creditLevel: '', creditLevel: '',
maxMinusScore:
quantificationData.maxMinusScore ??
scoreTemplate.maxMinusScore ??
this.currentScore.maxMinusScore ??
'',
tempApplyCreditLimit: this.currentScore.tempApplyCreditLimit, tempApplyCreditLimit: this.currentScore.tempApplyCreditLimit,
tempCreditStartDate: this.currentScore.tempCreditStartDate, tempCreditStartDate: this.currentScore.tempCreditStartDate,
tempCreditEndDate: this.currentScore.tempCreditEndDate, tempCreditEndDate: this.currentScore.tempCreditEndDate,
@@ -4664,6 +4720,17 @@ export default {
this.calculateScore(this.currentScore); this.calculateScore(this.currentScore);
}); });
}, },
applyMaxMinusScoreFromQuantification(quantificationId) {
if (!quantificationId) return Promise.resolve();
return getCreditScoreQuantificationDetail(quantificationId).then(res => {
const data = res?.data?.data || {};
this.currentScore.maxMinusScore =
data.maxMinusScore === undefined || data.maxMinusScore === null
? ''
: data.maxMinusScore;
this.calculateScore(this.currentScore);
});
},
normalizeScoreTemplateDetails(template = {}) { normalizeScoreTemplateDetails(template = {}) {
const categoryDetails = (template.categories || []).flatMap(category => const categoryDetails = (template.categories || []).flatMap(category =>
(category.items || []).map(item => ({ (category.items || []).map(item => ({
@@ -4808,13 +4875,24 @@ export default {
if (!valid) return null; if (!valid) return null;
const distance = increase ? input - base : base - input; const distance = increase ? input - base : base - input;
const changeValue = Number(rule.changeValue);
const stepSize = Number.isFinite(changeValue) && changeValue > 0 ? changeValue : 1;
const steps = Math.floor(distance / stepSize + 1e-9);
const stepScore = Math.abs(Number(rule.score || 0)); const stepScore = Math.abs(Number(rule.score || 0));
if (!Number.isFinite(stepScore)) return null; if (!Number.isFinite(stepScore) || steps < 0) return null;
const projectScore = this.getScoreItemFullScore(detail); // 减分项:按阶梯累计扣分,存正数,总分计算时再减;不做项目满分截断
if (this.isMinusScoreDetail(detail)) {
return Number((steps * stepScore).toFixed(2));
}
// 基础/加分项:从项目分值起步,按阶梯加减,限制在 [0, 项目满分]
const baseScore = Number(detail.score || 0);
const signedStep = rule.scoreType === 'subtract' ? -stepScore : stepScore; const signedStep = rule.scoreType === 'subtract' ? -stepScore : stepScore;
const calculatedScore = Number(detail.score || 0) + distance * signedStep; const calculatedScore = baseScore + steps * signedStep;
return Number(Math.min(Math.max(calculatedScore, 0), projectScore).toFixed(2)); const projectScore = this.getScoreItemFullScore(detail);
const upper = Math.max(projectScore, baseScore, 0);
return Number(Math.min(Math.max(calculatedScore, 0), upper).toFixed(2));
}, },
handleScoreValueInput(detail, value) { handleScoreValueInput(detail, value) {
const raw = String(value ?? '') const raw = String(value ?? '')
@@ -4995,14 +5073,17 @@ export default {
const plusDetails = this.getScoreCategoryDetailsByList(details, 'plus'); const plusDetails = this.getScoreCategoryDetailsByList(details, 'plus');
const minusDetails = this.getScoreCategoryDetailsByList(details, 'minus'); const minusDetails = this.getScoreCategoryDetailsByList(details, 'minus');
const sumScore = (list, prop) => list.reduce((sum, item) => sum + Number(item[prop] || 0), 0); const sumScore = (list, prop) => list.reduce((sum, item) => sum + Number(item[prop] || 0), 0);
const rawMinusSelf = sumScore(minusDetails, 'selfScore');
const rawMinusReview = sumScore(minusDetails, 'reviewScore');
// 量化表「减分最高扣分」:所有减分项合计不得超过该上限
const maxMinus = Number(score.maxMinusScore);
const hasMaxMinus = Number.isFinite(maxMinus) && maxMinus >= 0;
const minusSelf = hasMaxMinus ? Math.min(rawMinusSelf, maxMinus) : rawMinusSelf;
const minusReview = hasMaxMinus ? Math.min(rawMinusReview, maxMinus) : rawMinusReview;
const selfScore = const selfScore =
sumScore(basicDetails, 'selfScore') + sumScore(basicDetails, 'selfScore') + sumScore(plusDetails, 'selfScore') - minusSelf;
sumScore(plusDetails, 'selfScore') -
sumScore(minusDetails, 'selfScore');
const reviewScore = const reviewScore =
sumScore(basicDetails, 'reviewScore') + sumScore(basicDetails, 'reviewScore') + sumScore(plusDetails, 'reviewScore') - minusReview;
sumScore(plusDetails, 'reviewScore') -
sumScore(minusDetails, 'reviewScore');
const hasReviewScore = details.some( const hasReviewScore = details.some(
item => !this.isBlankScore(item.reviewScore) item => !this.isBlankScore(item.reviewScore)
); );
@@ -5076,7 +5157,11 @@ export default {
return pending; return pending;
}, },
deleteScore(index) { deleteScore(index) {
this.$confirm('确定删除该评分记录?', '提示', { const freezeCredit = this.shouldFreezeEffectiveCredit();
const confirmText = freezeCredit
? '确定删除该评分记录?删除后可重新添加评分表;原评分数据会保留,新评分再次审核通过前,客商等级与额度仍按原评分暂时生效。'
: '确定删除该评分记录?';
this.$confirm(confirmText, '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning',
@@ -5089,16 +5174,23 @@ export default {
} else if (this.scoreRecordIndex > index) { } else if (this.scoreRecordIndex > index) {
this.scoreRecordIndex -= 1; this.scoreRecordIndex -= 1;
} }
const latestScore = this.getLatestCreditScore(this.archiveForm.scores); if (!freezeCredit) {
if (latestScore) { const latestScore = this.getLatestCreditScore(this.archiveForm.scores);
this.syncArchiveCreditFromScores(); if (latestScore) {
} else { this.syncArchiveCreditFromScores();
this.archiveForm.customerLevel = ''; } else {
this.archiveForm.maxCreditLimit = ''; this.archiveForm.customerLevel = '';
this.archiveForm.applyCreditLimit = ''; this.archiveForm.maxCreditLimit = '';
this.archiveForm.applyCreditLimit = '';
}
} }
this.handleScoreCurrentChange(this.scorePage.currentPage); this.handleScoreCurrentChange(this.scorePage.currentPage);
this.saveScoreArchive('评分记录已删除', '评分记录已删除,请保存客商档案后生效'); this.saveScoreArchive(
freezeCredit
? '评分记录已删除,原等级与额度暂时继续生效'
: '评分记录已删除',
'评分记录已删除,请保存客商档案后生效'
);
}) })
.catch(() => {}); .catch(() => {});
}, },
@@ -5795,6 +5887,14 @@ export default {
} }
} }
.score-list-table__actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px;
}
.contact-form, .contact-form,
.receipt-form, .receipt-form,
.invoice-form { .invoice-form {
+15 -15
View File
@@ -49,28 +49,28 @@ export default ({ mode, command }) => {
__VUE_I18N_LEGACY_API__: true, __VUE_I18N_LEGACY_API__: true,
__INTLIFY_PROD_DEVTOOLS__: false, __INTLIFY_PROD_DEVTOOLS__: false,
}, },
// server: {
// port: 2888,
// proxy: {
// '/api': {
// // 本地网关默认 80 端口(blade-gateway bootstrap.yml);开发时需先启动网关及 blade-auth/blade-system 等服务
// // 注意:本机 8080 一般是 Nacos,不是业务网关
// target: 'http://localhost',
// // target: 'http://172.16.203.228:8000', // 仅联调远程网关时启用(且不要 rewrite)
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api/, ''),
// },
// },
// },
server: { server: {
port: 2889, port: 2888,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://172.16.203.228:8000', // 本地网关默认 80 端口(blade-gateway bootstrap.yml);开发时需先启动网关及 blade-auth/blade-system 等服务
// 注意:本机 8080 一般是 Nacos,不是业务网关
target: 'http://localhost',
// target: 'http://172.16.203.228:8000', // 仅联调远程网关时启用(且不要 rewrite)
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
}, },
}, },
}, },
// server: {
// port: 2889,
// proxy: {
// '/api': {
// target: 'http://172.16.203.228:8000',
// changeOrigin: true,
// },
// },
// },
resolve: { resolve: {
alias: { alias: {
'~': resolve(__dirname, './'), '~': resolve(__dirname, './'),