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

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');
};
/**
* 是否为可按文本读取的 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,必须排除,否则会把待解析的文件流误判为已完成。
*/
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
axios.defaults.withCredentials = true;
// NProgress Configuration
@@ -190,15 +205,20 @@ axios.interceptors.response.use(
// 用户就会以为系统崩了——而后端其实返回了明确的业务提示(如「未读取到数据行…」)。
// 这里按 Content-Type 判断:JSON 才解析回对象;真·文件流(Excel 等)保持原样不动。
// 兼容 Blob(浏览器 responseType='blob')与 string(非浏览器环境)两种形态。
// 仅对真正的 Blob/Blob-like 调 .text(),避免业务 JSON 已解析后出现
// 「e.data.text is not a function」。
if (isJsonContentType(res) && !isPlainObject(res.data)) {
try {
let text = '';
if (typeof res.data?.text === 'function') {
text = await res.data.text();
} else if (typeof res.data === 'string') {
text = res.data;
} else if (typeof res.data?.arrayBuffer === 'function') {
text = new TextDecoder().decode(await res.data.arrayBuffer());
const raw = res.data;
if (isBlobLikeData(raw) || (typeof Blob !== 'undefined' && raw instanceof Blob)) {
text = await raw.text();
} else if (typeof raw === 'string') {
text = raw;
} 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;
if (parsed && typeof parsed === 'object') {
@@ -208,10 +228,11 @@ axios.interceptors.response.use(
// 声明是 JSON 却解析失败:保持原样,交给下游按文件处理
}
}
//获取状态信息
const status = res.data.error_code || res.data.code || res.status;
// 获取状态信息:文件流没有 code/msg,必须退回 HTTP status,禁止读 Blob 上的幽灵字段
const payload = isPlainObject(res.data) ? res.data : {};
const status = Number(payload.error_code || payload.code || res.status);
const statusWhiteList = website.statusWhiteList || [];
const message = res.data.msg || res.data.error_description || '系统错误';
const message = payload.msg || payload.error_description || '系统错误';
//如果在白名单里则自行catch逻辑处理
if (statusWhiteList.includes(status)) return Promise.reject(res);
+22 -2
View File
@@ -88,7 +88,7 @@ export const parseBlobJson = async data => {
*
* 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。
*/
const resolveErrorMessage = async error => {
export const resolveImportErrorMessage = async error => {
const payload = error?.response?.data ?? error?.data;
if (isBusinessJson(payload) && payload.msg) {
return payload.msg;
@@ -109,6 +109,26 @@ const resolveErrorMessage = async error => {
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。
* 若传入 failDetailDecorator(函数),则动态加载 exceljs 读取 blob 流,
@@ -235,7 +255,7 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
// 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次,
// 只保留一次清晰的中文提示。
if (!error?._notified) {
vm.$message.error(await resolveErrorMessage(error));
vm.$message.error(await resolveImportErrorMessage(error));
}
notifyUploadError(option, error);
} finally {
@@ -13824,13 +13824,13 @@ export default {
}
const unavailableWaybills = this.selectionList.filter(
row =>
this.statusValue(row) !== 'pending' ||
!['pending', 'processing', 'running'].includes(this.statusValue(row)) ||
row.loadingId ||
row.loadingManageId ||
row.loadingNo
);
if (unavailableWaybills.length) {
this.$message.warning('公路配载仅支持进行中且未配载的运单');
this.$message.warning('公路配载仅支持待执行/进行中且未关联有效配载单的运单');
return;
}
try {
@@ -94,7 +94,7 @@
<div class="route-node-form">
<el-form-item label="途经地" required class="route-address-form-item"
><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 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>
@@ -111,7 +111,7 @@
<div class="route-node-form">
<el-form-item label="收货地" required class="route-address-form-item"
><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 label="联系人" class="route-contact-form-item"
><el-input v-model="form.arrivalContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item>
@@ -714,7 +714,7 @@ export default {
'msg',
'zip',
],
transports: ['公路运输', '铁路运输', '水路运输', '航空运输'],
transports: ['铁路运输', '水路运输', '航空运输', '公路运输'],
quantityUnitOptions: [],
quantityUnitLoading: false,
form: {
@@ -798,6 +798,7 @@ export default {
methods: {
formatContractOptionLabel,
defaultQuantityUnit() {
if (this.quantityUnitOptions.includes('吨')) return '吨';
return this.quantityUnitOptions[0] || '吨';
},
loadQuantityUnitOptions() {
@@ -1164,22 +1165,30 @@ export default {
const field = this.addressModel(target);
if (!field) return false;
const siteCode = String(field.model[field.siteCode] || '').trim();
// 地址展示模式由实际选择的数据决定:有站点编码表示非公路站点名称,
// 没有站点编码则保留省市区级联,避免切换运输方式时改写已有地址。
// 展示模式由已选数据决定:有站点编码则显示站点名,否则省市区;暂不按运输方式强制站点限制。
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 = {}) {
return row.regionCode || row.districtCode || row.areaCode || row.adcode || '';
},
openCommonAddress(target) {
if (this.isBasicRouteReadonly) return;
this.tipEmptyAddressDetail(target);
this.addressTarget = target;
if (this.addressTransportMode(target) !== 'road') {
this.stationQuery = {};
this.stationPage.current = 1;
this.stationDialogVisible = true;
return;
}
// 暂不按运输方式限制站点:统一走常用地址选择。
this.addressQuery = {};
this.addressPage.current = 1;
this.addressDialogVisible = true;
@@ -1279,18 +1288,11 @@ export default {
},
handleAddressDetailClick(target) {
if (this.isBasicRouteReadonly) return;
if (this.addressTransportMode(target) !== 'road') {
this.openCommonAddress(target);
return;
}
this.tipEmptyAddressDetail(target);
this.openAddressMap(target);
},
openAddressMap(target) {
if (this.isBasicRouteReadonly) return;
if (this.addressTransportMode(target) !== 'road') {
this.$message.info('非公路运输地址不能使用地图选择');
return;
}
const field = this.addressModel(target);
if (!field) return;
this.mapTarget = target;
@@ -1690,7 +1692,11 @@ export default {
'',
cargoTypePath,
quantity: row.quantity || '',
quantityUnit: row.quantityUnit || this.defaultQuantityUnit(),
// 常用货物单位若为空或「个」,新建总单统一默认「吨」
quantityUnit:
!row.quantityUnit || String(row.quantityUnit).trim() === '个'
? this.defaultQuantityUnit()
: row.quantityUnit,
packageType: row.packageType || row.package || '',
brand: row.brand || '',
specification: row.specification || row.spec || '',
@@ -1775,17 +1781,21 @@ export default {
return formatUpdateUserName(row);
},
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.departureAddress = route.departureAddress || '';
this.form.departureContact = route.departureContact || '';
this.form.departurePhone = route.departurePhone || '';
this.form.departureSiteCode = '';
this.form.arrivalName = route.arrivalName || '';
this.form.arrivalAddress = route.arrivalAddress || '';
this.form.arrivalContact = route.arrivalContact || '';
this.form.arrivalPhone = route.arrivalPhone || '';
this.form.arrivalSiteCode = '';
this.syncAddressRegionPaths();
this.form.finalTransportType = '公路运输';
if (!this.form.routes.length) this.addRoute();
this.routeDialogVisible = false;
},
createRoute(index = this.form.routes.length) {
@@ -10414,13 +10414,13 @@ export default {
}
const unavailableWaybills = this.selectionList.filter(
row =>
!['processing', 'running'].includes(this.statusValue(row)) ||
!['pending', 'processing', 'running'].includes(this.statusValue(row)) ||
row.loadingId ||
row.loadingManageId ||
row.loadingNo
);
if (unavailableWaybills.length) {
this.$message.warning('公路配载仅支持进行中且未配载的运单');
this.$message.warning('公路配载仅支持待执行/进行中且未关联有效配载单的运单');
return;
}
try {
+62 -14
View File
@@ -635,10 +635,11 @@
min-width="100" /><el-table-column
prop="quantity"
label="数量"
min-width="90" /><el-table-column
prop="quantityUnit"
label="数量单位"
min-width="100" /><el-table-column
min-width="120"
><template #default="{ row }">{{
formatCargoQuantityDisplay(row)
}}</template></el-table-column
><el-table-column
prop="materialCode"
label="物料编码"
min-width="120" /><el-table-column
@@ -897,6 +898,16 @@
>
<div title="配载信息" class="loading-manage-dialog__load-info-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">
<el-row :gutter="16">
<el-col :span="6">
@@ -1425,8 +1436,9 @@
<el-table-column prop="cargoName" 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="quantity" label="数量" min-width="100" />
<el-table-column prop="quantityUnit" label="数量单位" min-width="100" />
<el-table-column prop="quantity" label="数量" min-width="120">
<template #default="{ row }">{{ formatCargoQuantityDisplay(row) }}</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="120" />
@@ -3900,12 +3912,10 @@ export default {
try {
const query = {
...this.buildQueryParams(this.candidateQuery),
// 待执行(需接单未接)与进行中(无需接单或已接单)均可配载
businessStatus: 'pending,running',
onlyUnassignedLoading: 1,
// 与后端 LoadableAdmissionSupport 一致:公路 + 待执行/进行中 + 未关联有效配载单
loadableOnly: 1,
// 仅查当前账户所属组织及子组织下的运单
allDept: 0,
...(this.dialogMode === 'add' ? { transportType: 'road' } : {}),
};
const transportType = this.getCandidateTransportTypeQuery(query.transportType);
const res = await getWaybillList(
@@ -4195,14 +4205,28 @@ export default {
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 = {}) {
return sanitizeCargoRow({
projectName: item.projectName || waybill.projectName || '',
cargoName: item.cargoName || item.goodsName || waybill.cargoName || '',
cargoType: item.cargoType || item.goodsType || waybill.cargoType || '',
packageType: item.packageType || item.package || '',
weight: item.weight || item.cargoWeight || '',
volume: item.volume || item.cargoVolume || '',
quantity: item.quantity || item.cargoQuantity || waybill.quantity || '',
quantityUnit:
item.quantityUnit ||
@@ -4399,8 +4423,8 @@ export default {
try {
const payload = {
...this.normalizePayload(),
// 进行中编辑保持 running;其余确认生成正式配载单为待执行
businessStatus: this.isRunningEdit ? 'running' : 'pending',
// 进行中编辑保持 running;新建/草稿确认由后端按项目是否需接单写入 pending/running
...(this.isRunningEdit ? { businessStatus: 'running' } : {}),
};
if (this.dialogMode === 'reassign') {
await loadingApi.reassign(payload);
@@ -4969,6 +4993,30 @@ export default {
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 {
padding: 0;
margin-bottom: 0;
+14 -4
View File
@@ -203,7 +203,11 @@
@click="openBatchDialog(row)"
>更换运单批次</el-link
><el-link
v-if="(row.processStatus !== '处理完成' || row.auditStatus === '待审核') && row.auditStatus !== '审核驳回'"
v-if="
row.processStatus !== '处理失败' &&
(row.processStatus !== '处理完成' || row.auditStatus === '待审核') &&
row.auditStatus !== '审核驳回'
"
type="primary"
@click="openBatchDialog(row)"
>更换运单批次</el-link
@@ -320,7 +324,7 @@
<el-form-item label="执行凭证" required
><el-upload
action="#"
accept=".zip,.7z"
accept=".zip"
:auto-upload="false"
:limit="1"
:on-change="selectVoucherFile"
@@ -332,7 +336,7 @@
>上传</el-button
><template #tip
><span class="voucher-manage-page__upload-tip"
>请上传车辆的执行凭证压缩包,支持zip、7z格式。 上传附件大于50M时为超大附件,确认提交后可在“批量补录->上传任务”中查看上传进度。</span
>请上传车辆的执行凭证压缩包,仅支持 zip 格式;无法解析的文件将标记为「处理失败」,可重新上传。上传附件大于50M时为超大附件,确认提交后可在“批量补录->上传任务”中查看上传进度。</span
></template
></el-upload
><el-progress
@@ -549,7 +553,7 @@
ref="resumeFileInput"
class="voucher-manage-page__hidden-file"
type="file"
accept=".zip,.7z"
accept=".zip"
@change="resumeFileSelected"
/>
</template>
@@ -998,6 +1002,12 @@ const uploadVoucher = async file => {
};
const selectVoucherFile = file => {
if (!file?.raw) return;
const fileName = String(file.raw.name || '').toLowerCase();
if (!fileName.endsWith('.zip')) {
ElMessage.warning('仅支持 zip 压缩包,无法解析的文件将标记为处理失败');
clearFile();
return;
}
selectedVoucherFile.value = file.raw;
editing.fileName = file.raw.name;
editing.fileUrl = '';
+107 -166
View File
@@ -244,7 +244,7 @@
<el-table-column prop="paymentRatio" label="付款比例(%)" min-width="160">
<template #default="{ row }">
<el-input-number
v-if="multipleSettlementRows && !readonly"
v-if="!readonly"
:model-value="row.paymentRatio"
:min="0"
:max="rowPaymentRatioMax(row)"
@@ -262,7 +262,7 @@
<el-table-column prop="appliedAmount" label="付款金额" min-width="160">
<template #default="{ row }">
<el-input-number
v-if="multipleSettlementRows && !readonly"
v-if="!readonly"
:model-value="row.appliedAmount"
:min="0"
:max="Number(row.payableAmount || 0)"
@@ -1014,62 +1014,37 @@ export default {
referenceSettlementCount() {
return this.referenceSettlementNos.length;
},
multipleSettlementRows() {
return this.referenceRows.length > 1;
},
// 结算单号展示:单张显示单号;多张仅提示数量,明细在下方「结算信息」表,悬停可看全部单号
referenceDisplayText() {
if (this.referenceSettlementCount > 1) return `已选 ${this.referenceSettlementCount} 张结算单`;
return this.referenceLabel;
},
settlementRows() {
if (this.referenceRows.length > 1) {
// 多结算单:行内比例/金额独立维护,不回落到表头比例(表头与结算信息不关联)
return this.referenceRows.map(item => {
const paymentRatio =
item.paymentRatio === undefined || item.paymentRatio === null || item.paymentRatio === ''
? 0
: Number(item.paymentRatio);
const appliedAmount = Number(item.appliedAmount || 0);
return {
...item,
paymentRatio,
appliedAmount,
...this.buildSettlementRatioHint({ ...item, paymentRatio }),
};
});
}
if (this.referenceRows.length) {
return this.referenceRows.map(item => {
const appliedAmount =
item.fixedAppliedAmount === true
? Number(item.appliedAmount || 0)
: Number(
(
(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 }),
},
];
const source = this.referenceRows.length
? this.referenceRows
: this.referenceLabel
? [
{
settlementNo: this.referenceLabel,
settlementAmount: this.form.settlementAmount,
payableAmount: this.form.payableAmount,
paymentRatio: null,
appliedAmount: null,
paymentSequence: 1,
},
]
: [];
// 结算行比例/金额只取行内数据,不回落到付款基本信息
return source.map(item => {
const paymentRatio = this.nullableNumber(item.paymentRatio);
const appliedAmount = this.nullableNumber(item.appliedAmount);
return {
...item,
paymentRatio,
appliedAmount,
...this.buildSettlementRatioHint({ ...item, paymentRatio }),
};
});
},
highRiskPayee() {
const level = String(this.payeeCustomerLevel || '')
@@ -1127,11 +1102,12 @@ export default {
if (
this.amountSyncing ||
this.form.paymentType === 'project_advance' ||
!this.paymentAmountBase
!this.paymentAmountBase ||
this.nullableNumber(this.form.paymentRatio) === null
) {
return;
}
// 结算金额变化时按上方比例重算申请付款金额,仍不联动结算信息明细
// 结算金额变化时只按上方比例重算申请付款金额,不改结算信息明细
this.amountSyncing = true;
this.form.appliedAmount = Number(
((this.paymentAmountBase * Number(this.form.paymentRatio || 0)) / 100).toFixed(2)
@@ -1532,8 +1508,6 @@ export default {
paymentSequence: amount.paymentSequence,
}),
settlementNo: item.formalSettlementNo || item.settlementNo || '',
appliedAmount: Number(item.appliedAmount || 0),
fixedAppliedAmount: true,
};
});
this.seedMultipleRowPayments(referenceRows);
@@ -1545,27 +1519,6 @@ export default {
(total, item) => total + Number(item.payableAmount || 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);
this.amountSyncing = true;
this.referenceRows = referenceRows;
@@ -1592,8 +1545,7 @@ export default {
settlementAmount: Number(settlementAmount.toFixed(2)),
payableAmount: Number(payableAmount.toFixed(2)),
billType: '',
paymentRatio: headerPaymentRatio,
appliedAmount: headerAppliedAmount,
appliedAmount: this.headerAppliedAmountByRatio(settlementAmount),
invoices: rows.flatMap(item =>
this.createInvoiceRows(item.invoices || [], item.formalSettlementNo || item.settlementNo)
),
@@ -1649,7 +1601,7 @@ export default {
referenceType: isPre ? 'pre' : 'formal',
settlementAmount: Number(Number(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)
? Number(
(
@@ -1658,11 +1610,10 @@ export default {
).toFixed(2)
)
: null,
fixedAppliedAmount: this.readonly,
};
})
.filter(Boolean);
if (rows.length > 1) {
if (rows.length) {
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);
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)),
payableAmount: Number(Math.max(0, payableAmount).toFixed(2)),
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)
*/
buildSettlementRatioHint(row = {}) {
const sequence = Number(row.paymentSequence || 1);
const rawRatio =
row.paymentRatio !== undefined && row.paymentRatio !== null && row.paymentRatio !== ''
? row.paymentRatio
: this.form.paymentRatio;
const rawRatio = row.paymentRatio;
const paymentRatio = Number(rawRatio);
const ratioConfig = this.contractPaymentRatioRows[sequence - 1];
const ratioLimitValue = ratioConfig?.ratioLimit;
@@ -1850,21 +1810,6 @@ export default {
(total, item) => total + Number(item.payableAmount || 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;
Object.assign(this.form, {
@@ -1890,8 +1835,7 @@ export default {
settlementAmount: Number(settlementAmount.toFixed(2)),
payableAmount: Number(payableAmount.toFixed(2)),
billType: '',
paymentRatio: headerPaymentRatio,
appliedAmount: headerAppliedAmount,
appliedAmount: this.headerAppliedAmountByRatio(settlementAmount),
});
this.rememberSettlementParties(first.payerName, first.payeeName);
this.paymentRecords = [];
@@ -3080,22 +3024,54 @@ export default {
return Number(Math.min(100, (payableAmount / settlementAmount) * 100).toFixed(2));
},
/**
* 多结算单行金额规范化:仅在行内反算比例,不使用表头比例灌入(表头与结算信息不关联)。
* 结算行金额规范化:只在行内反算比例,不使用付款基本信息的比例和申请金额。
*/
seedMultipleRowPayments(rows = []) {
if (rows.length <= 1) return;
rows.forEach(item => {
const settlementAmount = Number(item.settlementAmount || 0);
const payableAmount = Number(item.payableAmount || 0);
let applied = Number(item.appliedAmount || 0);
if (!Number.isFinite(applied) || applied < 0) applied = 0;
if (applied > payableAmount) applied = Number(payableAmount.toFixed(2));
item.appliedAmount = Number(applied.toFixed(2));
item.paymentRatio = settlementAmount
? Number(((item.appliedAmount / settlementAmount) * 100).toFixed(2))
: 0;
if (this.nullableNumber(item.appliedAmount) === null && this.nullableNumber(item.paymentRatio) === null) {
item.appliedAmount = null;
item.paymentRatio = null;
return;
}
this.syncRowPayment(item, 'amount');
});
},
/**
* 结算行付款比例与付款金额联动。比例 = 付款金额 / 结算总金额。
* @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) {
return this.referenceRows.find(
item =>
@@ -3104,55 +3080,19 @@ export default {
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) {
const target = this.findReferenceRow(row);
if (!target) return;
const settlementAmount = Number(target.settlementAmount || 0);
const payableAmount = Number(target.payableAmount || 0);
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));
// 仅刷新结算金额/可付款金额合计,不回写表头申请金额与比例
target.paymentRatio = value;
this.syncRowPayment(target, 'ratio');
this.syncHeaderSettlementTotalsFromRows();
this.ensureSettlementAppliedWithinHeader(true);
},
handleRowAppliedAmountChange(row, value) {
const target = this.findReferenceRow(row);
if (!target) return;
const settlementAmount = Number(target.settlementAmount || 0);
const payableAmount = Number(target.payableAmount || 0);
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;
target.appliedAmount = value;
this.syncRowPayment(target, 'amount');
this.syncHeaderSettlementTotalsFromRows();
this.ensureSettlementAppliedWithinHeader(true);
},
/** 多结算单时仅同步结算金额/可付款金额合计,申请付款金额与比例由上方独立维护 */
syncHeaderSettlementTotalsFromRows() {
@@ -3261,8 +3201,8 @@ export default {
settlementLines: (this.referenceRows || []).map(item => ({
formalSettlementId: item.referenceType === 'formal' ? item.id : null,
preSettlementId: item.referenceType === 'pre' ? item.id : null,
paymentRatio: item.paymentRatio,
appliedAmount: item.appliedAmount,
paymentRatio: this.nullableNumber(item.paymentRatio),
appliedAmount: this.nullableNumber(item.appliedAmount),
})),
paymentMethod,
billLedgerId,
@@ -3323,21 +3263,22 @@ export default {
ElMessage.warning('申请付款金额必填且必须大于0');
return false;
}
if (this.multipleSettlementRows) {
const invalidRow = this.referenceRows.find(
item => Number(item.appliedAmount || 0) > Number(item.payableAmount || 0)
);
if (this.isSettlementRelatedType && this.referenceRows.length) {
const invalidRow = this.referenceRows.find(item => {
const rowApplied = Number(item.appliedAmount || 0);
return !Number.isFinite(rowApplied) || rowApplied <= 0;
});
if (invalidRow) {
this.clearMessages();
ElMessage.warning(`${invalidRow.settlementNo || '结算单'}付款金额不能超过可付款金额`);
ElMessage.warning(`${invalidRow.settlementNo || '结算单'}请填写付款金额,且必须大于0`);
return false;
}
const rowsTotal = Number(this.settlementRowsAppliedTotal().toFixed(2));
if (rowsTotal - appliedAmount > 1e-8) {
const exceededRow = this.referenceRows.find(
item => Number(item.appliedAmount || 0) > Number(item.payableAmount || 0)
);
if (exceededRow) {
this.clearMessages();
ElMessage.warning(
`结算信息付款金额合计(${rowsTotal.toFixed(2)})不能大于申请付款金额(${appliedAmount.toFixed(2)})`
);
ElMessage.warning(`${exceededRow.settlementNo || '结算单'}付款金额不能超过可付款金额`);
return false;
}
}
@@ -380,7 +380,13 @@ export default {
},
async loadFormalSettlements(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() {
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 { file, ...batch } = form;
const payloadRows = rows.value.map(stripInternalRowFields);
return {
...batch,
settlementBillType: form.settlementType === 'payable' ? form.settlementBillType || 'formal' : 'formal',
waybillMode: form.waybillMode === 'history' ? 'history' : 'new',
feeItemNames: [...feeItemNames.value],
rows: rows.value.map(stripInternalRowFields),
feeItemNames: collectFeeItemNames(feeItemNames.value, payloadRows),
rows: payloadRows,
};
};
@@ -1436,9 +1456,13 @@ const loadDetail = async id => {
if (detail.projectId) {
await loadContractsByProject(detail.projectId, detail.projectName || '');
}
feeItemNames.value = Array.isArray(detail.feeItemNames) ? detail.feeItemNames : [];
parsedHeaders.value = [];
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));
refreshDuplicateFlags();
// 查看模式始终只读;编辑时仅草稿可改,已完成/已作废强制只读
@@ -125,15 +125,17 @@
<span class="reconciliation-editor__filter-label">货物名称</span>
<el-input v-model="internalQuery.cargoName" placeholder="请输入" clearable />
</div>
<el-button @click="resetInternalQuery">重置</el-button>
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
<el-button
type="primary"
plain
:disabled="!filteredInternalRows.length"
@click="exportInternalDetails"
>导出</el-button
>
<div class="reconciliation-editor__filter-actions">
<el-button @click="resetInternalQuery">重置</el-button>
<el-button type="primary" @click="internalFilterTick++">查询</el-button>
<el-button
type="primary"
plain
:disabled="!filteredInternalRows.length"
@click="exportInternalDetails"
>导出</el-button
>
</div>
</div>
<el-table
:data="filteredInternalRows"
@@ -189,6 +191,9 @@
"
>{{ 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>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -318,6 +323,9 @@
"
>{{ 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>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -476,6 +484,7 @@
<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 === '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>{{ displayValue(row[column.prop]) }}</span>
</template>
@@ -630,7 +639,10 @@ import { getDictionary } from '@/api/system/dictbiz';
import { Search } from '@element-plus/icons-vue';
import { h } from 'vue';
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 {
internalColumns,
@@ -1042,12 +1054,24 @@ export default {
.map(normalized)
.join('|');
},
blankOptionalNumber(value) {
if (value === null || value === undefined || value === '' || Number(value) === -1) {
return null;
}
return value;
},
normalizeInternalRow(row) {
const feeItems =
row.feeItems && typeof row.feeItems === 'object' && Object.keys(row.feeItems).length
? row.feeItems
: 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) {
const feeItems =
@@ -1057,6 +1081,9 @@ export default {
return {
...row,
feeItems,
unitPrice: this.blankOptionalNumber(row.unitPrice),
mileage: this.blankOptionalNumber(row.mileage),
freightAmount: this.blankOptionalNumber(row.freightAmount),
_externalKey: row.id || row.externalLineNo || `external-${index}`,
};
},
@@ -1284,6 +1311,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
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;
});
},
@@ -1297,6 +1327,9 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
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));
return snapshot;
});
@@ -1326,7 +1359,7 @@ export default {
}
},
async handleUpdate() {
await this.$confirm('将以外部账单数据更新运单及内部结算明细,是否继续?', '更新账单', {
await this.$confirm('将以外部账单数据更新内部结算明细,是否继续?', '更新账单', {
type: 'warning',
});
this.actionLoading = true;
@@ -1508,22 +1541,23 @@ export default {
? await api.importCargo(this.currentId, file)
: await api.importVehicle(this.currentId, file);
// 成功时 axios 拦截器可能已把 JSON Blob 解析成对象,不能再调 .text()
if (isExcelResponse(response)) {
downloadXls(
response.data,
`运输对账导入失败明细${this.$dayjs().format('YYYY-MM-DD HH-mm-ss')}.xlsx`
);
const consumed = await consumeImportResponse(response, {
businessName: '运输对账',
dayjsFormat: this.$dayjs().format('YYYY-MM-DD HH-mm-ss'),
});
if (consumed.kind === 'excel') {
downloadXls(consumed.blob, consumed.fileName);
this.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = await parseBlobJson(response.data);
if (Number(result.code) !== 200) throw new Error(result.msg || '导入失败');
this.$message.success('外部账单导入成功');
}
this.matchingStarted = false;
await this.loadDetail();
this.matchingStarted = false;
} catch (error) {
this.$message.error(error.message || '外部账单导入失败');
if (!error?._notified) {
this.$message.error(await resolveImportErrorMessage(error));
}
}
},
async downloadTemplate(mode) {
@@ -1831,7 +1865,10 @@ export default {
: Number(value || 0).toFixed(2);
},
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) {
if (value === null || value === undefined || value === '') return '-';
@@ -1952,15 +1989,26 @@ export default {
}
.reconciliation-editor__filter {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto auto;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.reconciliation-editor__filter-item {
display: flex;
align-items: center;
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 {
flex: 0 0 auto;
margin-right: 8px;
@@ -6,15 +6,33 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, getCurrentInstance, nextTick, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import SettlementImportPage from './components/settlement-import-page.vue';
const route = useRoute();
const store = useStore();
const instance = getCurrentInstance();
const pageTitle = computed(() => {
if (route.query.mode === 'view') return '查看导入结算单';
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>
<style scoped lang="scss">
+6 -3
View File
@@ -239,19 +239,22 @@ export default {
this.onLoad(this.page);
},
openCreate() {
this.$router.push({ path: '/settlement/settlement-import/form' });
this.$router.push({
path: '/settlement/settlement-import/form',
query: { name: '新增导入结算单' },
});
},
openDetail(row) {
if (!row?.id) return;
this.$router.push({
path: '/settlement/settlement-import/form',
query: { id: row.id, mode: 'view' },
query: { id: row.id, mode: 'view', name: '查看导入结算单' },
});
},
editDraft(row) {
this.$router.push({
path: '/settlement/settlement-import/form',
query: { id: row.id },
query: { id: row.id, name: '编辑导入结算单' },
});
},
async handleDelete(row) {
+105 -2
View File
@@ -115,9 +115,10 @@
<template #deptName="{ row }">
<template v-if="formatDeptNames(row.deptName).length">
<el-tag
v-for="name in formatDeptNames(row.deptName)"
:key="name"
v-for="(name, index) in formatDeptNames(row.deptName)"
:key="`${row.id}-${index}-${name}`"
class="user-dept-tag"
@click.stop="handleDeptTagClick(row, index)"
>{{ name }}</el-tag>
</template>
<span v-else>-</span>
@@ -666,6 +667,104 @@ export default {
this.page.currentPage = 1;
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) {
if (command === 'setLeader') {
this.handleSetLeader(row);
@@ -1537,6 +1636,10 @@ export default {
justify-content: flex-start;
}
.user-dept-tag {
cursor: pointer;
}
.user-dept-tag + .user-dept-tag {
margin-left: 6px;
}
@@ -101,6 +101,17 @@
style="width: 500px"
/>
</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-input
v-model="configForm.remark"
@@ -628,6 +639,7 @@ export default {
name: '',
remark: '',
standardDescription: '',
maxMinusScore: '',
status: 3,
categories: this.defaultCategories(),
standards: [],
@@ -684,6 +696,7 @@ export default {
});
return {
...detail,
maxMinusScore: this.normalizePlainDecimal(detail.maxMinusScore),
categories: categories.map(category => ({
...(map[category.categoryCode] || category),
categoryName: category.categoryName,
@@ -691,6 +704,14 @@ export default {
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) {
this.readonly = readonly;
if (row && row.id) {
@@ -816,6 +837,9 @@ export default {
},
prepareSubmitConfig(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 => {
(category.items || []).forEach(item => {
item.score = this.toScoreValue(item.score);
@@ -1072,6 +1096,14 @@ export default {
this.$message.warning('请输入评定表名称');
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');
if (!basic || basic.items.length === 0) {
this.$message.warning('至少配置1个基础得分项目');
+138 -38
View File
@@ -592,18 +592,27 @@
<span>{{ row.reviewStatus }}</span>
</template>
</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 }">
<el-link type="primary" @click="openScore(row.__raw, row.__rawIndex)">
详情
</el-link>
<el-link
v-if="showScoreDelete"
type="danger"
@click="deleteScore(row.__rawIndex)"
>
删除
</el-link>
<div class="score-list-table__actions">
<el-link type="primary" @click="openScore(row.__raw, row.__rawIndex)">
详情
</el-link>
<el-link
v-if="canReviewScore(row)"
type="primary"
@click="startReviewScore(row.__raw, row.__rawIndex)"
>
复评
</el-link>
<el-link
v-if="showScoreDelete"
type="danger"
@click="deleteScore(row.__rawIndex)"
>
删除
</el-link>
</div>
</template>
</el-table-column>
</el-table>
@@ -2231,7 +2240,8 @@ export default {
showScoreDelete() {
if (this.readonly) return false;
const status = this.archiveForm.approvalStatus || 'draft';
return status === 'draft' || status === 'rejected';
// 草稿/驳回可删;审批通过后也允许删除并重建评分表(旧评分逻辑删除保留,新评分审核通过前仍用旧额度)
return status === 'draft' || status === 'rejected' || status === 'approved';
},
isNewScore() {
return this.scoreRecordIndex < 0 && !this.reviewMode;
@@ -2281,10 +2291,17 @@ export default {
{ categoryCode: 'plus', categoryName: '加分项目' },
{ categoryCode: 'minus', categoryName: '减分项目' },
];
const maxMinus = Number(this.currentScore?.maxMinusScore);
const hasMaxMinus = Number.isFinite(maxMinus) && maxMinus >= 0;
const rows = categoryMap.map((category, index) => {
const details = this.getScoreCategoryDetails(category.categoryCode);
const selfScore = details.reduce((sum, item) => sum + Number(item.selfScore || 0), 0);
const reviewScore = details.reduce((sum, item) => sum + Number(item.reviewScore || 0), 0);
let selfScore = details.reduce((sum, item) => sum + Number(item.selfScore || 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 {
...category,
index: index + 1,
@@ -4199,6 +4216,7 @@ export default {
scoreDate: this.$dayjs().format('YYYY-MM-DD'),
applyCreditLimit: this.archiveForm.applyCreditLimit || '',
maxCreditLimit: '',
maxMinusScore: '',
creditLevel: '',
standards: [],
tempApplyCreditLimit: '',
@@ -4218,6 +4236,10 @@ export default {
: score.applyCreditLimit
),
maxCreditLimit: this.normalizeOptionalAmount(score.maxCreditLimit),
maxMinusScore:
score.maxMinusScore === undefined || score.maxMinusScore === null
? ''
: score.maxMinusScore,
tempApplyCreditLimit: this.normalizeOptionalAmount(score.tempApplyCreditLimit),
attachments: score.attachments || this.parseAttachments(score.proofAttachments),
details: (score.details || []).map(detail => ({
@@ -4256,7 +4278,14 @@ export default {
String(item.score.creditLevel || '').trim()
)[0]?.score;
},
/** 曾审核通过后:删除/重建评分在再次审核通过前,主表等级与额度保持旧值暂时生效 */
shouldFreezeEffectiveCredit(archive = this.archiveForm) {
return Boolean(archive?.approvedTime);
},
applyArchiveCreditFromScores(archive) {
if (this.shouldFreezeEffectiveCredit(archive)) {
return archive;
}
const latestScore = this.getLatestCreditScore(archive.scores || []);
archive.customerLevel = latestScore?.creditLevel || archive.customerLevel || '';
archive.maxCreditLimit = latestScore?.maxCreditLimit || archive.maxCreditLimit || '';
@@ -4588,9 +4617,31 @@ export default {
}
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() {
if ((this.archiveForm.scores || []).length) {
this.$message.warning('请勿重复添加');
const existingScores = this.archiveForm.scores || [];
if (existingScores.length) {
this.$message.warning(
this.shouldFreezeEffectiveCredit()
? '请先删除原评分记录后再添加新评分表'
: '请勿重复添加'
);
return;
}
this.scoreRecordIndex = -1;
@@ -4611,9 +4662,9 @@ export default {
const loadOptions = this.scoreQuantificationOptions.length
? Promise.resolve()
: this.initScoreQuantificationOptions();
loadOptions.then(() =>
this.ensureScoreQuantificationOption(this.currentScore.quantificationId)
);
loadOptions
.then(() => this.ensureScoreQuantificationOption(this.currentScore.quantificationId))
.then(() => this.applyMaxMinusScoreFromQuantification(this.currentScore.quantificationId));
},
resetScoreDialog() {
this.scoreRecordIndex = -1;
@@ -4648,6 +4699,11 @@ export default {
applyCreditLimit: this.currentScore.applyCreditLimit,
maxCreditLimit: '',
creditLevel: '',
maxMinusScore:
quantificationData.maxMinusScore ??
scoreTemplate.maxMinusScore ??
this.currentScore.maxMinusScore ??
'',
tempApplyCreditLimit: this.currentScore.tempApplyCreditLimit,
tempCreditStartDate: this.currentScore.tempCreditStartDate,
tempCreditEndDate: this.currentScore.tempCreditEndDate,
@@ -4664,6 +4720,17 @@ export default {
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 = {}) {
const categoryDetails = (template.categories || []).flatMap(category =>
(category.items || []).map(item => ({
@@ -4808,13 +4875,24 @@ export default {
if (!valid) return null;
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));
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 calculatedScore = Number(detail.score || 0) + distance * signedStep;
return Number(Math.min(Math.max(calculatedScore, 0), projectScore).toFixed(2));
const calculatedScore = baseScore + steps * signedStep;
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) {
const raw = String(value ?? '')
@@ -4995,14 +5073,17 @@ export default {
const plusDetails = this.getScoreCategoryDetailsByList(details, 'plus');
const minusDetails = this.getScoreCategoryDetailsByList(details, 'minus');
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 =
sumScore(basicDetails, 'selfScore') +
sumScore(plusDetails, 'selfScore') -
sumScore(minusDetails, 'selfScore');
sumScore(basicDetails, 'selfScore') + sumScore(plusDetails, 'selfScore') - minusSelf;
const reviewScore =
sumScore(basicDetails, 'reviewScore') +
sumScore(plusDetails, 'reviewScore') -
sumScore(minusDetails, 'reviewScore');
sumScore(basicDetails, 'reviewScore') + sumScore(plusDetails, 'reviewScore') - minusReview;
const hasReviewScore = details.some(
item => !this.isBlankScore(item.reviewScore)
);
@@ -5076,7 +5157,11 @@ export default {
return pending;
},
deleteScore(index) {
this.$confirm('确定删除该评分记录?', '提示', {
const freezeCredit = this.shouldFreezeEffectiveCredit();
const confirmText = freezeCredit
? '确定删除该评分记录?删除后可重新添加评分表;原评分数据会保留,新评分再次审核通过前,客商等级与额度仍按原评分暂时生效。'
: '确定删除该评分记录?';
this.$confirm(confirmText, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
@@ -5089,16 +5174,23 @@ export default {
} else if (this.scoreRecordIndex > index) {
this.scoreRecordIndex -= 1;
}
const latestScore = this.getLatestCreditScore(this.archiveForm.scores);
if (latestScore) {
this.syncArchiveCreditFromScores();
} else {
this.archiveForm.customerLevel = '';
this.archiveForm.maxCreditLimit = '';
this.archiveForm.applyCreditLimit = '';
if (!freezeCredit) {
const latestScore = this.getLatestCreditScore(this.archiveForm.scores);
if (latestScore) {
this.syncArchiveCreditFromScores();
} else {
this.archiveForm.customerLevel = '';
this.archiveForm.maxCreditLimit = '';
this.archiveForm.applyCreditLimit = '';
}
}
this.handleScoreCurrentChange(this.scorePage.currentPage);
this.saveScoreArchive('评分记录已删除', '评分记录已删除,请保存客商档案后生效');
this.saveScoreArchive(
freezeCredit
? '评分记录已删除,原等级与额度暂时继续生效'
: '评分记录已删除',
'评分记录已删除,请保存客商档案后生效'
);
})
.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,
.receipt-form,
.invoice-form {
+15 -15
View File
@@ -49,28 +49,28 @@ export default ({ mode, command }) => {
__VUE_I18N_LEGACY_API__: true,
__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: {
port: 2889,
port: 2888,
proxy: {
'/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,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
// server: {
// port: 2889,
// proxy: {
// '/api': {
// target: 'http://172.16.203.228:8000',
// changeOrigin: true,
// },
// },
// },
resolve: {
alias: {
'~': resolve(__dirname, './'),