diff --git a/src/api/business/waybill-manage.js b/src/api/business/waybill-manage.js index ac31975..eb66cb3 100644 --- a/src/api/business/waybill-manage.js +++ b/src/api/business/waybill-manage.js @@ -14,6 +14,13 @@ export const cancel = api.cancel; export const reassign = api.reassign; export const complete = api.complete; export const batchComplete = api.batchComplete; +/** 确认回单:待确认回单运单完成后转为已完成 */ +export const confirmReturn = id => + request({ + url: `${baseUrl}/confirm-return`, + method: 'post', + params: { id }, + }); export const changeRoute = data => request({ url: `${baseUrl}/change-route`, diff --git a/src/api/system/dept.js b/src/api/system/dept.js index c7f22de..2964557 100644 --- a/src/api/system/dept.js +++ b/src/api/system/dept.js @@ -139,3 +139,10 @@ export const getPlatformCompanySelect = () => { method: 'get', }); }; + +export const getBusinessDeptSelect = () => { + return request({ + url: '/blade-system/dept/business-dept-select', + method: 'get', + }); +}; diff --git a/src/axios.js b/src/axios.js index a3de4a3..455eb3d 100644 --- a/src/axios.js +++ b/src/axios.js @@ -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); diff --git a/src/option/business/common.js b/src/option/business/common.js index 48ff87e..729199e 100644 --- a/src/option/business/common.js +++ b/src/option/business/common.js @@ -42,6 +42,7 @@ export const waybillStatusOptions = [ { label: '草稿', value: 'draft' }, { label: '待执行', value: 'pending' }, { label: '进行中', value: 'running' }, + { label: '待确认回单', value: 'pending_return' }, { label: '已完成', value: 'completed' }, { label: '已取消', value: 'cancelled' }, ]; diff --git a/src/option/business/waybill-manage.js b/src/option/business/waybill-manage.js index 9f617d3..9fd695b 100644 --- a/src/option/business/waybill-manage.js +++ b/src/option/business/waybill-manage.js @@ -478,7 +478,15 @@ export const config = { formatStatus(row, prop, defaultText) { if (prop !== 'businessStatus') return defaultText; const status = String(row.businessStatus || ''); - const terminalStatuses = ['draft', 'completed', 'cancelled', 'waiting_dispatch', 'dispatching']; + const terminalStatuses = [ + 'draft', + 'completed', + 'cancelled', + 'waiting_dispatch', + 'dispatching', + 'pending_return', + ]; + if (status === 'pending_return') return '待确认回单'; const needAccept = row.requireAccept === true || row.requireAccept === 1 || @@ -497,14 +505,20 @@ export const config = { getDriverRejectReason, isDriverRejectedWaybill, canEdit(row) { - // 进行中的运单不允许编辑 + // 进行中、待确认回单不允许编辑 if (isInProgressStatus(row, row.businessStatusName)) { return false; } + if (String(row.businessStatus || '') === 'pending_return') { + return false; + } return !isDriverRejectedWaybill(row); }, canCancel(row) { - // 进行中的运单不允许取消 + // 进行中、待确认回单不允许取消 + if (String(row.businessStatus || '') === 'pending_return') { + return false; + } return !isInProgressStatus(row, row.businessStatusName); }, deleteStatus: ['draft'], diff --git a/src/option/system/mpUser.js b/src/option/system/mpUser.js index 994f76f..bc06d41 100644 --- a/src/option/system/mpUser.js +++ b/src/option/system/mpUser.js @@ -14,6 +14,11 @@ const validatePhone = (rule, value, callback) => { callback(new Error('请输入手机号')); return; } + // 详情回显为脱敏手机号(含 *)时放行,未修改则提交时剔除该字段 + if (phone.includes('*')) { + callback(); + return; + } if (!/^1\d{10}$/.test(phone)) { callback(new Error('请输入正确的手机号')); return; @@ -121,6 +126,62 @@ export default { order: 60, rules: [{ required: true, message: '请选择小程序身份', trigger: 'change' }], }, + { + label: '所属组织', + prop: 'deptId', + formslot: true, + hide: true, + span: 24, + order: 55, + rules: [{ required: true, message: '请选择所属组织', trigger: 'change' }], + }, + { + label: '所属组织', + prop: 'deptName', + minWidth: 180, + overHidden: true, + addDisplay: false, + editDisplay: false, + viewDisplay: false, + order: 54, + }, + { + label: '数据权限范围', + prop: 'dataScopeRange', + type: 'radio', + span: 24, + hide: true, + order: 53, + value: 1, + dicData: [ + { label: '仅所属组织', value: 1 }, + { label: '全部组织', value: 2 }, + { label: '自定义', value: 3 }, + ], + rules: [{ required: true, message: '请选择数据权限范围', trigger: 'change' }], + }, + { + label: '数据层级范围', + prop: 'dataLevelRange', + type: 'radio', + span: 24, + hide: true, + order: 52, + value: 1, + dicData: [ + { label: '包含下级', value: 1 }, + { label: '仅本级', value: 2 }, + ], + rules: [{ required: true, message: '请选择数据层级范围', trigger: 'change' }], + }, + { + label: '数据权限', + prop: 'dataScopeDeptIds', + formslot: true, + span: 24, + hide: true, + order: 51, + }, { label: '性别', prop: 'sex', diff --git a/src/utils/import-excel.js b/src/utils/import-excel.js index b6a9093..1bd5e02 100644 --- a/src/utils/import-excel.js +++ b/src/utils/import-excel.js @@ -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 { diff --git a/src/views/business/components/business-crud-page.vue b/src/views/business/components/business-crud-page.vue index eb23da3..36aa8b5 100644 --- a/src/views/business/components/business-crud-page.vue +++ b/src/views/business/components/business-crud-page.vue @@ -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 { diff --git a/src/views/business/components/master-order-editor.vue b/src/views/business/components/master-order-editor.vue index 74ce647..6ad4fde 100644 --- a/src/views/business/components/master-order-editor.vue +++ b/src/views/business/components/master-order-editor.vue @@ -94,7 +94,7 @@
@@ -111,7 +111,7 @@
@@ -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) { diff --git a/src/views/business/components/waybill-manage-page.vue b/src/views/business/components/waybill-manage-page.vue index dad89a3..a701aaf 100644 --- a/src/views/business/components/waybill-manage-page.vue +++ b/src/views/business/components/waybill-manage-page.vue @@ -1591,8 +1591,8 @@ 取消 - - {{ completeActionLabel }} + + {{ completeActionLabelFor(row) }} 维护里程 @@ -5111,7 +5111,29 @@ export default { return false; } const status = this.statusValue(row); - return status === 'processing' || status === 'running'; + return status === 'processing' || status === 'running' || status === 'pending_return'; + }, + completeActionLabelFor(row) { + return this.statusValue(row) === 'pending_return' ? '确认回单' : this.completeActionLabel; + }, + handleCompleteAction(row) { + const isPendingReturn = this.statusValue(row) === 'pending_return'; + const actionName = this.completeActionLabelFor(row); + this.$confirm(`确定${actionName}该${this.config.title}?`, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + }) + .then(() => { + if (isPendingReturn && typeof this.api.confirmReturn === 'function') { + return this.api.confirmReturn(row.id); + } + return this.api.complete(row.id); + }) + .then(() => { + this.$message.success(`${actionName}成功`); + this.onLoad(this.page, this.query); + }); }, canMaintainMileage(row) { return ( @@ -6543,6 +6565,9 @@ export default { if (status === 'completed') { return 'primary'; } + if (status === 'pending_return') { + return 'warning'; + } return 'warning'; }, normalizeRow(row, saveAsDraft = false) { @@ -10356,7 +10381,14 @@ export default { this.$message.warning('请选择至少一条数据'); return; } - this.$confirm('确定完成选中的运单?', '提示', { + const pendingReturnCount = this.selectionList.filter( + row => this.statusValue(row) === 'pending_return' + ).length; + const tip = + pendingReturnCount > 0 + ? `确定完成选中的运单?其中 ${pendingReturnCount} 条为待确认回单,确认后将转为已完成。` + : '确定完成选中的运单?'; + this.$confirm(tip, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', @@ -10382,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 { diff --git a/src/views/business/contract-manage-change.vue b/src/views/business/contract-manage-change.vue index 6a5b6bf..e3fef3c 100644 --- a/src/views/business/contract-manage-change.vue +++ b/src/views/business/contract-manage-change.vue @@ -8,11 +8,17 @@ + - + @@ -80,6 +87,7 @@ format="YYYY-MM-DD" value-format="YYYY-MM-DD" placeholder="请输入" + :disabled="isSettlementRuleChange" /> 至 @@ -95,6 +104,7 @@ @@ -105,22 +115,36 @@ :model-value="contractAmountDisplay" placeholder="请输入" clearable + :disabled="isSettlementRuleChange" @input="handleContractAmountInput" @blur="formatContractAmountField" @clear="form.contractAmount = null" /> - + - + - + @@ -129,13 +153,19 @@ - + - + - + - + @@ -201,6 +248,7 @@ maxlength="200" show-word-limit placeholder="请输入其他事项说明" + :disabled="isSettlementRuleChange" /> @@ -214,6 +262,7 @@ title="用印材料" description attachment-type + :readonly="isSettlementRuleChange" :attachment-location-options="changeAttachmentLocationOptions" :rows="contractFileRows" :preview="previewAttachment" @@ -224,12 +273,36 @@
计费信息
- 添加
- 系统生成账单导入生成 - + 系统生成账单导入生成 +
@@ -248,19 +321,106 @@ 关闭 - - - - - - + + + + + + - + - - - - + + + +
@@ -268,17 +428,48 @@
付款比例设置 - +
- - - + + + - 添加 + 添加 this.isAttachmentImage(item) && this.attachmentUrl(item)).map(item => this.attachmentUrl(item)); this.imagePreviewIndex = Math.max(this.imagePreviewUrls.indexOf(url), 0); this.imagePreviewVisible = true; return; } this.previewFile = { name: this.attachmentName(row), url, mimeType: row.mimeType || row.contentType || '' }; this.documentPreviewVisible = true; }, handleAttachmentUploadToLocation({ location, rows = [] }) { if (!rows.length) return; + if (this.isSettlementRuleChange) { + if (location === '变更材料') { + this.changeMaterials = [...(this.changeMaterials || []), ...rows]; + } + return; + } if (isContractSealMaterialLocation(location)) { this.contractFileRows = [...(this.contractFileRows || []), ...rows]; return; @@ -759,7 +963,81 @@ export default { removeCustomPeriodRow(index) { if (index <= 0) return; const periods = [...(this.settlementRule.customPeriods || [])]; periods.splice(index, 1); this.settlementRule.customPeriods = normalizeCustomPeriods(periods); }, validateCustomPeriods(periods = [], label) { const rows = normalizeCustomPeriods(periods); if (!rows.length) { this.$message.warning(`${label}:请至少配置一段自定义周期`); return false; } for (let index = 0; index < rows.length; index += 1) { const row = rows[index]; const startDay = Number(row.startDay); const endDay = Number(row.endDay); if (!Number.isFinite(startDay) || startDay < 1 || startDay > 31) { this.$message.warning(`${label}:请选择第${index + 1}行运单区间开始日`); return false; } if (!Number.isFinite(endDay) || endDay < 1 || endDay > 31) { this.$message.warning(`${label}:请选择第${index + 1}行运单区间结束日`); return false; } if (endDay < startDay) { this.$message.warning(`${label}:第${index + 1}行结束日不能早于开始日`); return false; } if (index > 0 && startDay !== Number(rows[index - 1].endDay) + 1) { this.$message.warning(`${label}:自定义多周期区间必须连续,不允许重叠或存在日期缺口`); return false; } } return true; }, validateSettlementRule(rule, label) { if (Number(rule.autoGenerate) !== 1) return true; if (!rule.billStartDate || !rule.settlementType) { this.$message.warning(`${label}:请完整填写账单起始日和结算类型`); return false; } if (rule.settlementType === '月结' && !rule.billCycleType) { this.$message.warning(`${label}:请选择结算周期`); return false; } if (rule.settlementType === '月结' && rule.billCycleType === '固定截单日' && !rule.billCutoffDay) { this.$message.warning(`${label}:请选择账单截单日`); return false; } if (rule.settlementType === '月结' && rule.billCycleType === '自定义多周期') return this.validateCustomPeriods(rule.customPeriods, label); if (rule.settlementType === '固定天数周期结算' && !rule.cycleDays) { this.$message.warning(`${label}:请选择周期天数`); return false; } return true; }, - async submit() { await this.$refs.formRef.validate(); const total = this.paymentRatioRows.reduce((sum, row) => sum + Number(row.ratioLimit || 0), 0); if (this.paymentRatioRows.length && Math.abs(total - 100) > 0.0001) { this.$message.warning('付款比例上限合计必须等于100%'); return; } if (this.settlementConfigTab === 'pre') this.preSettlementConfig = { ...this.settlementRule }; else this.formalSettlementConfig = { ...this.settlementRule }; if (!this.validateSettlementRule(this.preSettlementConfig, '预结算配置') || !this.validateSettlementRule(this.formalSettlementConfig, '正式结算配置')) return; const settlementRule = { preSettlementConfig: this.preSettlementConfig, formalSettlementConfig: this.formalSettlementConfig }; await api.submitChange({ ...this.form, settlementCurrency: String(this.form.settlementCurrency || '').trim() || 'RMB', copyCount: normalizeOptionalPositiveInteger(this.form.copyCount), invoiceCycle: normalizeOptionalPositiveInteger(this.form.invoiceCycle), paymentDays: normalizeOptionalPositiveInteger(this.form.paymentDays), contractAmount: normalizeOptionalAmount(this.form.contractAmount), startDate: this.period[0], endDate: this.period[1], feeGenerationMode: this.feeGenerationMode, billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0, billingPlanJson: JSON.stringify(this.plans), settlementRuleJson: JSON.stringify(settlementRule), preSettlementConfigJson: JSON.stringify(this.preSettlementConfig), formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig), paymentRatioJson: JSON.stringify(this.paymentRatioRows), contractFileJson: JSON.stringify(this.contractFileRows), attachmentsJson: JSON.stringify(this.attachments), changeContent: this.form.changeContent, changeReason: this.form.changeReason, changeAttachmentsJson: JSON.stringify(this.changeMaterials) }); await submitMkApprovalFlow({ bizType: 'contract-manage', bizId: this.form.id, subjectName: this.form.contractName || '', approvalStatus: this.form.approvalStatus || 'change_rejected', mkEnabled: await loadMkEnabled().catch(() => false) }); this.$message.success('变更已提交'); this.$router.back(); }, + async submit() { + if (this.form.changeType === '终止合同') { + await api.terminate(this.form.id, this.form.changeReason); + this.$message.success('合同已终止'); + this.$router.back(); + return; + } + await this.$refs.formRef.validate(); + if (!this.isSettlementRuleChange) { + const total = this.paymentRatioRows.reduce((sum, row) => sum + Number(row.ratioLimit || 0), 0); + if (this.paymentRatioRows.length && Math.abs(total - 100) > 0.0001) { + this.$message.warning('付款比例上限合计必须等于100%'); + return; + } + } + if (this.settlementConfigTab === 'pre') this.preSettlementConfig = { ...this.settlementRule }; + else this.formalSettlementConfig = { ...this.settlementRule }; + if ( + !this.validateSettlementRule(this.preSettlementConfig, '预结算配置') || + !this.validateSettlementRule(this.formalSettlementConfig, '正式结算配置') + ) { + return; + } + const settlementRule = { + preSettlementConfig: this.preSettlementConfig, + formalSettlementConfig: this.formalSettlementConfig, + }; + const payload = this.isSettlementRuleChange + ? { + id: this.form.id, + changeType: this.form.changeType, + changeContent: this.form.changeContent, + changeReason: this.form.changeReason, + feeGenerationMode: this.feeGenerationMode, + billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0, + billingPlanJson: JSON.stringify(this.plans), + settlementRuleJson: JSON.stringify(settlementRule), + preSettlementConfigJson: JSON.stringify(this.preSettlementConfig), + formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig), + changeAttachmentsJson: JSON.stringify(this.changeMaterials), + } + : { + ...this.form, + changeType: this.form.changeType || '合同信息变更', + settlementCurrency: String(this.form.settlementCurrency || '').trim() || 'RMB', + copyCount: normalizeOptionalPositiveInteger(this.form.copyCount), + invoiceCycle: normalizeOptionalPositiveInteger(this.form.invoiceCycle), + paymentDays: normalizeOptionalPositiveInteger(this.form.paymentDays), + contractAmount: normalizeOptionalAmount(this.form.contractAmount), + startDate: this.period[0], + endDate: this.period[1], + feeGenerationMode: this.feeGenerationMode, + billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0, + billingPlanJson: JSON.stringify(this.plans), + settlementRuleJson: JSON.stringify(settlementRule), + preSettlementConfigJson: JSON.stringify(this.preSettlementConfig), + formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig), + paymentRatioJson: JSON.stringify(this.paymentRatioRows), + contractFileJson: JSON.stringify(this.contractFileRows), + attachmentsJson: JSON.stringify(this.attachments), + changeContent: this.form.changeContent, + changeReason: this.form.changeReason, + changeAttachmentsJson: JSON.stringify(this.changeMaterials), + }; + await api.submitChange(payload); + await submitMkApprovalFlow({ + bizType: 'contract-manage', + bizId: this.form.id, + subjectName: this.form.contractName || '', + approvalStatus: this.form.approvalStatus || 'change_rejected', + mkEnabled: await loadMkEnabled().catch(() => false), + }); + this.$message.success('变更已提交'); + this.$router.back(); + }, }, }; diff --git a/src/views/business/loading-manage.vue b/src/views/business/loading-manage.vue index aac814e..9196fe0 100644 --- a/src/views/business/loading-manage.vue +++ b/src/views/business/loading-manage.vue @@ -635,10 +635,11 @@ min-width="100" />
+
+
可配载条件
+
+ 同时满足以下条件的运单可被配载:运输方式为「公路」;运单状态为「待执行」或「进行中」;未关联其他有效配载单。 +
+
配载后状态说明
+
+ 子运单:按项目过程配置是否需司机接单,无需接单则为进行中,需接单则为待执行。配载单:按所选运单所属项目的过程配置,需接单则为待执行,无需接单则为进行中。 +
+
@@ -2107,6 +2164,8 @@ export default { routeNodes: [], cargoRows: [], routeDragIndex: -1, + /** 进行中编辑:子运单打卡记录映射 waybillId -> records[] */ + waybillPunchMap: {}, transportTypeOptions: [], projectOptions: [], customerOptions: [], @@ -2170,6 +2229,23 @@ export default { dialogReadonly() { return this.dialogMode === 'view'; }, + /** 进行中配载编辑:可加子单/调未打卡路线,但任务必填字段锁定 */ + isRunningEdit() { + return ( + this.dialogMode === 'edit' && + String(this.dialogForm.businessStatus || '') === 'running' + ); + }, + /** 进行中任务必填字段(已派车)不可改 */ + isRunningTaskRequiredLocked() { + return this.isRunningEdit; + }, + /** 自运进行中:司机/手机号同属必填派车信息,不可改 */ + isRunningSelfDriverLocked() { + return ( + this.isRunningEdit && String(this.dialogForm.carrierType || '').trim() === '自运' + ); + }, // 打卡时间轴:已打卡按时间升序,未打卡保持原节点顺序排在其后 orderedPunchRecords() { const records = Array.isArray(this.punchRecords) ? this.punchRecords : []; @@ -2913,6 +2989,7 @@ export default { if (status === 'running') { return [ { type: 'view', label: '详情' }, + { type: 'edit', label: '编辑' }, { type: 'complete', label: '完成' }, ]; } @@ -3014,7 +3091,12 @@ export default { query: { mode, ...(mode === 'edit' && row?.id ? { id: row.id } : {}), - name: mode === 'edit' ? '编辑配载管理' : '新增配载管理', + name: + mode === 'edit' + ? row?.businessStatus === 'running' + ? '编辑进行中配载' + : '编辑配载管理' + : '新增配载管理', }, }); return; @@ -3037,6 +3119,9 @@ export default { this.dialogForm.loadingSubNos ); this.syncProcessWaybillOptions(); + if (this.isRunningEdit) { + await this.refreshWaybillPunchMap(); + } this.restoreRouteAndCargo(); this.restoreRouteChangeRecords(); this.loadPunchRecords(); @@ -3071,6 +3156,8 @@ export default { this.taskCarrierLoading = false; this.routeNodes = []; this.cargoRows = []; + this.routeDragIndex = -1; + this.waybillPunchMap = {}; this.candidatePage.currentPage = 1; this.candidatePage.total = 0; this.candidateSearchVisible = false; @@ -3435,20 +3522,144 @@ export default { const statusName = String(row.businessStatusName || row.statusName || ''); return statusName.includes('已完成'); }, + isPunchRecordDone(record = {}) { + if (record.punched === false || record.statusName === '未打卡') return false; + return Boolean(record.punched || record.punchTime || record.punchAt || record.punchDateTime); + }, + isLoadPunchRecord(record = {}) { + if (!this.isPunchRecordDone(record)) return false; + const code = String(record.nodeCode || '') + .trim() + .toLowerCase(); + const name = String(record.nodeName || '').trim(); + return code === 'load' || name.includes('装货'); + }, + isUnloadPunchRecord(record = {}) { + if (!this.isPunchRecordDone(record)) return false; + const code = String(record.nodeCode || '') + .trim() + .toLowerCase(); + const name = String(record.nodeName || '').trim(); + return ( + code === 'unload' || + name.includes('卸货') || + code === 'arrival' || + name.includes('到货') || + code === 'sign' || + name.includes('签收') + ); + }, + waybillPunchRecords(row = {}) { + const key = String(row?.id || ''); + if (!key) return []; + const records = this.waybillPunchMap[key]; + return Array.isArray(records) ? records : []; + }, + isWaybillLoadPunched(row = {}) { + return this.waybillPunchRecords(row).some(record => this.isLoadPunchRecord(record)); + }, + isWaybillUnloadPunched(row = {}) { + return this.waybillPunchRecords(row).some(record => this.isUnloadPunchRecord(record)); + }, + isWaybillPunchLocked(row = {}) { + return this.isWaybillLoadPunched(row) || this.isWaybillUnloadPunched(row); + }, + isSelectedWaybillRemoveLocked(row = {}) { + if (this.isWaybillCompleted(row)) return true; + return this.isRunningEdit && this.isWaybillPunchLocked(row); + }, + isRouteNodeTagLocked(tag, rows = this.selectedWaybillRows) { + const match = String(tag || '').match(/^(装|卸)(\d+)$/); + if (!match) return false; + const row = rows[Number(match[2]) - 1]; + if (!row) return false; + if (this.isRunningEdit) { + return match[1] === '装' ? this.isWaybillLoadPunched(row) : this.isWaybillUnloadPunched(row); + } + return this.isWaybillCompleted(row); + }, isRouteChangeNodeLocked(node = {}) { return Boolean(node?.locked); }, syncRouteNodeLockedFlags(nodes = [], rows = this.selectedWaybillRows) { return (nodes || []).map(node => { - const locked = (node.tags || []).some(tag => { - const match = String(tag).match(/^(装|卸)(\d+)$/); - if (!match) return false; - const row = rows[Number(match[2]) - 1]; - return row ? this.isWaybillCompleted(row) : false; - }); + const locked = (node.tags || []).some(tag => this.isRouteNodeTagLocked(tag, rows)); return { ...node, locked }; }); }, + async refreshWaybillPunchMap() { + const rows = (this.selectedWaybillRows || []).filter(row => row?.id); + if (!rows.length) { + this.waybillPunchMap = {}; + return; + } + const entries = await Promise.all( + rows.map(async row => { + try { + const res = await getWaybillPunchRecords(row.id); + const data = res?.data?.data || res?.data || {}; + return [String(row.id), Array.isArray(data.records) ? data.records : []]; + } catch (error) { + return [String(row.id), []]; + } + }) + ); + this.waybillPunchMap = Object.fromEntries(entries); + }, + /** + * 进行中编辑:已打卡地点保持在前且不可插入,新增地址只能排在已打卡节点之后。 + */ + mergeRouteNodesKeepingPunched(prevNodes = [], builtNodes = []) { + const prev = Array.isArray(prevNodes) ? prevNodes : []; + const built = Array.isArray(builtNodes) ? builtNodes : []; + if (!this.isRunningEdit) { + return built; + } + const builtByAddress = new Map(); + built.forEach(node => { + if (node?.address && !builtByAddress.has(node.address)) { + builtByAddress.set(node.address, node); + } + }); + const lockedAddresses = new Set( + built.filter(node => node?.locked && node.address).map(node => node.address) + ); + const used = new Set(); + const lockedResult = []; + prev.forEach(node => { + if (!node?.address || !lockedAddresses.has(node.address) || used.has(node.address)) { + return; + } + const latest = builtByAddress.get(node.address); + if (!latest) return; + lockedResult.push({ ...latest, locked: true }); + used.add(node.address); + }); + built.forEach(node => { + if (!node?.locked || !node.address || used.has(node.address)) return; + lockedResult.push({ ...node, locked: true }); + used.add(node.address); + }); + const unlockedResult = []; + prev.forEach(node => { + if (!node?.address || lockedAddresses.has(node.address) || used.has(node.address)) { + return; + } + const latest = builtByAddress.get(node.address); + if (!latest) return; + unlockedResult.push({ ...latest, locked: false }); + used.add(node.address); + }); + built.forEach(node => { + if (node?.locked || !node?.address || used.has(node.address)) return; + unlockedResult.push({ ...node, locked: false }); + used.add(node.address); + }); + return [...lockedResult, ...unlockedResult].map((node, index) => ({ + ...node, + key: `${node.address}-${index}`, + })); + }, openRouteChangeMapDialog(row, field) { if (this.isWaybillCompleted(row)) return; this.routeChangeMapTarget = { row, field }; @@ -3701,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( @@ -3766,6 +3975,33 @@ export default { } }) ); + if (this.isRunningEdit && String(this.dialogForm.carrierType || '').trim() === '承运商') { + const contractId = this.dialogForm.carrierContractId; + if (contractId) { + const projectIds = [ + ...new Set( + [...this.selectedWaybillRows, ...selectedRows] + .map(row => row.projectId) + .filter(Boolean) + ), + ]; + if (projectIds.length) { + try { + const response = await loadingApi.getCarrierContracts(projectIds); + const contracts = response?.data?.data || response?.data || []; + const stillAllowed = (Array.isArray(contracts) ? contracts : []).some( + item => String(item.id) === String(contractId) + ); + if (!stillAllowed) { + ElMessage.warning('新增子运单须兼容当前配载单已选定的承运商合同'); + return; + } + } catch (error) { + // 合同校验失败时仍允许添加,最终由后端确认拦截 + } + } + } + } const exists = new Set( this.selectedWaybillRows.map(row => String(row.id || row.waybillNo)) ); @@ -3777,8 +4013,12 @@ export default { exists.add(key); } }); + const prevRouteNodes = [...this.routeNodes]; this.selectedWaybillRows = nextRows; - this.rebuildSummaryFromWaybills(); + if (this.isRunningEdit) { + await this.refreshWaybillPunchMap(); + } + this.rebuildSummaryFromWaybills(true, prevRouteNodes); await this.loadTaskCarrierOptions(); this.$refs.candidateTableRef?.clearSelection?.(); this.candidateSelection = []; @@ -3790,11 +4030,23 @@ export default { await this.addWaybillRows(this.candidateSelection); }, removeSelectedWaybill(index) { + const row = this.selectedWaybillRows[index]; + if (this.isSelectedWaybillRemoveLocked(row)) { + ElMessage.warning('已打卡的子运单不可移除'); + return; + } this.selectedWaybillRows.splice(index, 1); + if (this.isRunningEdit) { + this.refreshWaybillPunchMap().then(() => { + this.rebuildSummaryFromWaybills(); + this.loadTaskCarrierOptions(); + }); + return; + } this.rebuildSummaryFromWaybills(); this.loadTaskCarrierOptions(); }, - rebuildSummaryFromWaybills(rebuildRoute = true) { + rebuildSummaryFromWaybills(rebuildRoute = true, prevRouteNodes = null) { const rows = this.selectedWaybillRows; this.dialogForm.loadingSubNos = rows .map(row => row.waybillNo) @@ -3819,7 +4071,13 @@ export default { this.dialogForm.transportType = rows.find(row => row.transportType)?.transportType || ''; } if (rebuildRoute) { - this.routeNodes = this.buildRouteNodes(rows); + const builtNodes = this.buildRouteNodes(rows); + this.routeNodes = this.isRunningEdit + ? this.mergeRouteNodesKeepingPunched( + prevRouteNodes || this.routeNodes, + builtNodes + ) + : builtNodes; } this.cargoRows = this.buildCargoRows(rows); // 编辑回填阶段 routeNodes 尚未恢复时跳过,避免清空已有发到货地 @@ -3839,7 +4097,9 @@ export default { if (locked) node.locked = true; }; rows.forEach((row, rowIndex) => { - const locked = this.isWaybillCompleted(row); + const locked = this.isRunningEdit + ? this.isWaybillLoadPunched(row) + : this.isWaybillCompleted(row); const departure = row.departureAddress || row.departureName; appendNode( departure, @@ -3849,7 +4109,9 @@ export default { ); }); rows.forEach((row, rowIndex) => { - const locked = this.isWaybillCompleted(row); + const locked = this.isRunningEdit + ? this.isWaybillUnloadPunched(row) + : this.isWaybillCompleted(row); const arrival = row.arrivalAddress || row.arrivalName; appendNode( arrival, @@ -3901,7 +4163,9 @@ export default { if ( originalLockedKeys.some((key, lockedIndex) => key && key !== nextLockedKeys[lockedIndex]) ) { - ElMessage.warning('已完成运单对应的路线不可调整'); + ElMessage.warning( + this.isRunningEdit ? '已打卡地点不可调整,新地址只能在未打卡节点间拖拽' : '已完成运单对应的路线不可调整' + ); this.routeDragIndex = -1; return; } @@ -3941,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 || @@ -4145,15 +4423,15 @@ export default { try { const payload = { ...this.normalizePayload(), - // 确认生成正式配载单(待执行),覆盖草稿状态 - businessStatus: 'pending', + // 进行中编辑保持 running;新建/草稿确认由后端按项目是否需接单写入 pending/running + ...(this.isRunningEdit ? { businessStatus: 'running' } : {}), }; if (this.dialogMode === 'reassign') { await loadingApi.reassign(payload); ElMessage.success('派单成功'); } else { await loadingApi.submit(payload); - ElMessage.success('确认成功'); + ElMessage.success(this.isRunningEdit ? '保存成功' : '确认成功'); } sessionStorage.setItem(loadingManageListRefreshKey, '1'); this.closeLoadingDialog(); @@ -4715,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; @@ -4787,6 +5089,16 @@ export default { color: #909399; } + .loading-manage-dialog__route-hint { + color: #909399; + font-size: 12px; + } + + .loading-manage-dialog__locked-tip { + color: #909399; + font-size: 12px; + } + .loading-manage-dialog__route-type { width: 28px; height: 28px; diff --git a/src/views/business/project-apply.vue b/src/views/business/project-apply.vue index 1b0805d..965f9a0 100644 --- a/src/views/business/project-apply.vue +++ b/src/views/business/project-apply.vue @@ -955,7 +955,7 @@ import { getList as getCustomerArchiveList, getDetail as getCustomerArchiveDetail, } from '@/api/vehicle/customer-archive'; -import { getDeptTree, getPlatformCompanySelect } from '@/api/system/dept'; +import { getBusinessDeptSelect, getDeptTree, getPlatformCompanySelect } from '@/api/system/dept'; import { getDictionary as getSystemDictionary } from '@/api/system/dict'; import { getDictionary as getBizDictionary } from '@/api/system/dictbiz'; import { getList as getUserList } from '@/api/system/user'; @@ -1596,6 +1596,7 @@ export default { projectType: type === 'majorSupplement' ? '重大项目' : '普通项目', }); this.fillDefaultUsers(); + this.fillDefaultBusinessDept(); return; } if (!id) return; @@ -2032,6 +2033,7 @@ export default { .filter(Boolean); this.syncSelectedCustomerOptions(); this.ensureCurrentPlatformOption(); + this.ensureCurrentBusinessDeptOption(); }, fillDefaultUsers() { const userId = this.userInfo?.userId || this.userInfo?.id || ''; @@ -2050,6 +2052,25 @@ export default { this.form.principalUserName = userName; } }, + /** + * 新增/重大补录:业务部门默认取当前用户所属部门中第一个「是否业务部门=是」的组织。 + */ + fillDefaultBusinessDept() { + if (!['add', 'majorSupplement'].includes(this.dialogType)) return; + if (this.form.businessDeptId) return; + const raw = this.userInfo?.deptId || this.userInfo?.dept_id || ''; + const userDeptIds = String(raw) + .split(/[,,]/) + .map(item => String(item || '').trim()) + .filter(item => item && item !== '-1'); + if (!userDeptIds.length || !this.deptOptions.length) return; + const matched = userDeptIds.find(id => + this.deptOptions.some(item => String(item.value) === String(id)) + ); + if (!matched) return; + this.form.businessDeptId = matched; + this.handleBusinessDeptChange(matched); + }, resolveUserRealName(row = {}, role) { return ( row[`${role}RealName`] || @@ -2632,17 +2653,61 @@ export default { this.$refs.projectForm?.validateField('projectType'); }, loadDeptOptions() { - getDeptTree().then(res => { - const deptTree = res.data.data || []; - // 新增/编辑/重大补录/项目变更均可选择业务部门,排除外部组织 - const businessDeptTree = ['add', 'edit', 'majorSupplement', 'change'].includes(this.dialogType) - ? this.excludeExternalOrganization(deptTree) - : deptTree; - this.deptOptions = this.flattenDept(businessDeptTree); - this.businessDeptTreeOptions = this.toDeptCascaderOptions(businessDeptTree); - }); + Promise.all([getDeptTree(), getBusinessDeptSelect()]) + .then(([treeRes, bizRes]) => { + const deptTree = this.excludeExternalOrganization(treeRes.data.data || []); + const bizList = bizRes.data.data || []; + const bizIds = new Set( + bizList + .map(item => (item.id == null || item.id === '' ? '' : String(item.id))) + .filter(Boolean) + ); + // 仅保留「是否业务部门=是」的节点及其祖先,祖先不可选 + const businessDeptTree = this.filterBusinessDeptTree(deptTree, bizIds); + this.deptOptions = this.flattenDept(businessDeptTree).filter(item => + bizIds.has(String(item.value)) + ); + this.businessDeptTreeOptions = this.toDeptCascaderOptions(businessDeptTree, bizIds); + this.fillDefaultBusinessDept(); + this.ensureCurrentBusinessDeptOption(); + }) + .catch(() => { + this.deptOptions = []; + this.businessDeptTreeOptions = []; + }); this.loadPlatformCompanyOptions(); }, + /** + * 剪枝组织树:只保留业务部门节点及其祖先路径。 + */ + filterBusinessDeptTree(list = [], bizIds = new Set()) { + return (list || []).reduce((result, item) => { + const children = item.children?.length + ? this.filterBusinessDeptTree(item.children, bizIds) + : []; + const isBiz = bizIds.has(String(item.id)); + if (!isBiz && !children.length) { + return result; + } + result.push({ + ...item, + children: children.length ? children : undefined, + }); + return result; + }, []); + }, + ensureCurrentBusinessDeptOption() { + const id = this.form.businessDeptId; + if (!id) return; + const exists = this.deptOptions.some(item => String(item.value) === String(id)); + if (exists) return; + const label = this.form.businessDeptName || String(id); + this.deptOptions.unshift({ label, rawLabel: label, value: id }); + const pathExists = this.findDeptPath(id, this.businessDeptTreeOptions); + if (!pathExists) { + this.businessDeptTreeOptions.unshift({ label, value: id }); + } + }, loadPlatformCompanyOptions() { getPlatformCompanySelect().then(res => { const list = res.data.data || []; @@ -2686,12 +2751,22 @@ export default { return result; }, []); }, - toDeptCascaderOptions(list = []) { - return list.map(item => ({ - label: item.title || item.deptName || item.name, - value: item.id, - children: item.children?.length ? this.toDeptCascaderOptions(item.children) : undefined, - })); + toDeptCascaderOptions(list = [], bizIds = null) { + return list.map(item => { + const value = item.id == null || item.id === '' ? item.value : item.id; + const option = { + label: item.title || item.deptName || item.name || item.label, + value: value == null || value === '' ? '' : String(value), + children: item.children?.length + ? this.toDeptCascaderOptions(item.children, bizIds) + : undefined, + }; + // 仅业务部门可选;祖先节点保留路径但禁用 + if (bizIds && !bizIds.has(String(option.value))) { + option.disabled = true; + } + return option; + }); }, flattenDept(list = [], level = 0) { return list.reduce((result, item) => { diff --git a/src/views/business/voucher-manage.vue b/src/views/business/voucher-manage.vue index 79645e0..54fb8b2 100644 --- a/src/views/business/voucher-manage.vue +++ b/src/views/business/voucher-manage.vue @@ -203,7 +203,11 @@ @click="openBatchDialog(row)" >更换运单批次更换运单批次上传 @@ -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 = ''; diff --git a/src/views/payment/bill-payment-form.vue b/src/views/payment/bill-payment-form.vue index 77297ed..d848ffd 100644 --- a/src/views/payment/bill-payment-form.vue +++ b/src/views/payment/bill-payment-form.vue @@ -271,7 +271,7 @@ export default { this.unwrapData( await billLedgerApi.getAvailableOptions( this.billDialog.keyword, - this.form.deptId, + undefined, this.form.billLedgerId ) ) || []; diff --git a/src/views/payment/payment-application-form.vue b/src/views/payment/payment-application-form.vue index 836f9fd..3e91449 100644 --- a/src/views/payment/payment-application-form.vue +++ b/src/views/payment/payment-application-form.vue @@ -249,7 +249,7 @@ @@ -476,6 +484,7 @@ {{ formatMoney(getFeeItemAmount(row, column.feeItemName)) }} {{ formatMileage(row.mileage) }} {{ formatDate(row[column.prop]) }} + {{ formatOptionalMoney(row.unitPrice) }} {{ formatMoney(row[column.prop]) }} {{ displayValue(row[column.prop]) }} @@ -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; diff --git a/src/views/settlement/settlement-import-form.vue b/src/views/settlement/settlement-import-form.vue index 6d0799f..fcbc784 100644 --- a/src/views/settlement/settlement-import-form.vue +++ b/src/views/settlement/settlement-import-form.vue @@ -6,15 +6,33 @@ diff --git a/src/views/system/user.vue b/src/views/system/user.vue index dcdc6e3..ae2f53a 100644 --- a/src/views/system/user.vue +++ b/src/views/system/user.vue @@ -115,9 +115,10 @@ - + @@ -1155,7 +1147,14 @@ /> - + @@ -1656,8 +1655,10 @@ export default { const nonNegativeLabelMap = { invoiceTaxRate: '开票税点', registeredCapital: '注册资金', + paidInCapital: '实缴资本', maxCreditLimit: '最大资金使用额度', applyCreditLimit: '申请总资金使用额度', + usedCreditLimit: '已使用额度', }; const validateNonNegative = (rule, value, callback) => { if (value === undefined || value === null || value === '') { @@ -1668,15 +1669,6 @@ export default { callback(); } }; - const validateInvoiceTaxRate = (rule, value, callback) => { - if (value === undefined || value === null || value === '') { - callback(); - } else if (Number(value) < 0 || Number(value) > 13) { - callback(new Error('开票税点必须在0到13之间')); - } else { - callback(); - } - }; const validateUnifiedCreditCode = (rule, value, callback) => { const code = String(value || '') .trim() @@ -1806,7 +1798,7 @@ export default { { label: '是', value: 1 }, { label: '否', value: 0 }, ], - businessScopeOptions: [], + bankListOptions: [], qualificationTypeOptions: [], creditLevelOptions: ['A', 'B', 'C', 'D', 'E', 'F'].map(item => ({ label: `${item}级`, @@ -1845,15 +1837,12 @@ export default { ], customerType: [{ required: true, message: '请选择客商分类', trigger: 'change' }], legalPerson: [{ required: true, message: '请输入企业法人', trigger: 'blur' }], - contactPhone: [ - { required: true, message: '请输入联系电话', trigger: 'blur' }, - { pattern: /^1\d{10}$/, message: '联系电话必须为11位手机号', trigger: 'blur' }, - ], - deptName: [{ required: true, message: '请选择所属组织', trigger: 'change' }], - invoiceTaxRate: [{ validator: validateInvoiceTaxRate, trigger: 'blur' }], registeredCapital: [{ validator: validateNonNegative, trigger: 'blur' }], + paidInCapital: [{ validator: validateNonNegative, trigger: 'blur' }], maxCreditLimit: [{ validator: validateNonNegative, trigger: 'blur' }], applyCreditLimit: [{ validator: validateNonNegative, trigger: 'blur' }], + usedCreditLimit: [{ validator: validateNonNegative, trigger: 'blur' }], + businessScope: [{ max: 2000, message: '经营范围最多2000个字符', trigger: 'blur' }], registeredRegionName: [{ required: true, message: '请选择省市区', trigger: 'change' }], registeredDetailAddress: [ { required: true, message: '请输入详细地址', trigger: 'blur' }, @@ -2296,7 +2285,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; @@ -2346,10 +2336,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, @@ -2406,7 +2403,6 @@ export default { ensurePublicViewOptions(archive = {}) { this.mergeSelectOptions('customerNatureOptions', archive.customerNature); this.mergeSelectOptions('customerTypeOptions', archive.customerType); - this.mergeSelectOptions('businessScopeOptions', archive.businessScope); this.mergeSelectOptions( 'qualificationTypeOptions', (this.qualificationFiles || []).map(item => item.type) @@ -2464,9 +2460,11 @@ export default { form.fullName = ''; form.legalPerson = ''; form.registeredCapital = ''; + form.paidInCapital = ''; form.registeredDetailAddress = ''; - form.businessScope = []; + form.businessScope = ''; form.businessTermType = '固定期限'; + form.businessStartDate = ''; form.businessEndDate = ''; }, // 新增态下统一社会信用代码录入完成后自动查询金蝶(A902-1),防抖 500ms; @@ -2525,14 +2523,7 @@ export default { } if (customer.businessterm) this.applyBusinessTermRecognition(customer.businessterm); if (customer.businessscope) { - const matchedScopes = this.businessScopeOptions - .filter( - item => - customer.businessscope.includes(item.label) || - customer.businessscope.includes(item.value) - ) - .map(item => item.value); - if (matchedScopes.length) form.businessScope = matchedScopes; + form.businessScope = String(customer.businessscope).trim(); } if (!form.shortName && customer.simplename) form.shortName = customer.simplename; this.$nextTick(() => { @@ -2541,6 +2532,8 @@ export default { 'fullName', 'legalPerson', 'registeredCapital', + 'paidInCapital', + 'businessStartDate', 'businessEndDate', 'businessScope', 'registeredDetailAddress', @@ -2563,8 +2556,13 @@ export default { deptIds: '', deptName: '', customerType: [], - businessScope: [], + businessScope: '', businessTermType: '固定期限', + businessStartDate: '', + businessEndDate: '', + registeredCapital: '', + paidInCapital: '', + usedCreditLimit: '', networkFreightPlatform: null, guangxiTop100: null, registeredRegionPath: [], @@ -2699,6 +2697,12 @@ export default { approvedTime: '审核通过时间', status: '状态', businessTermType: '营业期限类型', + businessStartDate: '营业期限开始日', + businessEndDate: '营业期限截止日', + registeredCapital: '注册资金(万元)', + paidInCapital: '实缴资本(万元)', + usedCreditLimit: '已使用额度(万元)', + businessScope: '经营范围', networkFreightPlatform: '网络货运平台', registeredRegionName: '注册地址', registeredDetailAddress: '详细地址', @@ -3147,11 +3151,21 @@ export default { const customerTypeColumn = this.findColumn(this.option.column, 'customerType'); customerTypeColumn.dicData = [{ label: '全部', value: '' }, ...options]; }); - this.loadDictOptions('scope_of_business', 'businessScopeOptions'); + this.loadDictOptions('bank_list', 'bankListOptions'); this.loadDictOptions('customer_attachment_types', 'qualificationTypeOptions', [], () => { this.sortQualificationFiles(); }); }, + queryBankNameSuggestions(queryString, callback) { + const query = String(queryString || '') + .trim() + .toLowerCase(); + const options = Array.isArray(this.bankListOptions) ? this.bankListOptions : []; + const matched = query + ? options.filter(item => String(item.value || '').toLowerCase().includes(query)) + : options; + callback(matched.map(item => ({ value: item.value }))); + }, initMkEnabled() { this._mkEnabledResolved = false; loadMkEnabled() @@ -3405,8 +3419,15 @@ export default { }); }, formatBusinessLicenseValidPeriod(row = {}) { - if (row.businessTermType === '长期') return '无固定期限'; - return row.businessEndDate || ''; + if (row.businessTermType === '长期') { + return row.businessStartDate + ? `${row.businessStartDate} 至 无固定期限` + : '无固定期限'; + } + if (row.businessStartDate && row.businessEndDate) { + return `${row.businessStartDate} 至 ${row.businessEndDate}`; + } + return row.businessEndDate || row.businessStartDate || ''; }, formatFundUseRate(row = {}) { if (row.fundUseRate === undefined || row.fundUseRate === null || row.fundUseRate === '') { @@ -3939,8 +3960,13 @@ export default { const legalPerson = this.getBusinessLicenseOcrValue(result, ['法定代表人', '法人']); const address = this.getBusinessLicenseOcrValue(result, ['地址', '住所']); const capital = this.getBusinessLicenseOcrValue(result, ['注册资本']); + const paidInCapital = this.getBusinessLicenseOcrValue(result, ['实缴资本', '实收资本']); const term = this.getBusinessLicenseOcrValue(result, ['营业期限', '有效期']); - const termStartDate = this.getBusinessLicenseOcrValue(result, ['有效期起始日期']); + const termStartDate = this.getBusinessLicenseOcrValue(result, [ + '有效期起始日期', + '成立日期', + '核准日期', + ]); const scope = this.getBusinessLicenseOcrValue(result, ['经营范围']); if (code) this.archiveForm.unifiedCreditCode = code.replace(/\s/g, '').toUpperCase(); if (name) this.archiveForm.fullName = name; @@ -3955,15 +3981,16 @@ export default { const normalizedCapital = capital.replace(/,/g, '').match(/\d+(?:\.\d+)?/); if (normalizedCapital) this.archiveForm.registeredCapital = normalizedCapital[0]; } - this.applyBusinessTermRecognition(term); + if (paidInCapital) { + const normalizedPaidIn = paidInCapital.replace(/,/g, '').match(/\d+(?:\.\d+)?/); + if (normalizedPaidIn) this.archiveForm.paidInCapital = normalizedPaidIn[0]; + } + this.applyBusinessTermRecognition(term, termStartDate); if (this.isNoFixedBusinessTerm(term) || this.isNoFixedBusinessTerm(termStartDate)) { this.handleNoFixedTermChange(true); } if (scope) { - const matchedScopes = this.businessScopeOptions - .filter(item => scope.includes(item.label) || scope.includes(item.value)) - .map(item => item.value); - if (matchedScopes.length) this.archiveForm.businessScope = matchedScopes; + this.archiveForm.businessScope = scope; } this.$nextTick(() => { [ @@ -3971,27 +3998,49 @@ export default { 'fullName', 'legalPerson', 'registeredCapital', + 'paidInCapital', + 'businessStartDate', 'businessEndDate', 'businessScope', 'registeredDetailAddress', ].forEach(prop => this.$refs.archiveForm?.validateField(prop)); }); }, - applyBusinessTermRecognition(term) { + normalizeOcrDate(value) { + if (!value) return ''; + const match = String(value).match(/(\d{4})[年./-](\d{1,2})[月./-](\d{1,2})/); + if (!match) return ''; + const month = String(match[2]).padStart(2, '0'); + const day = String(match[3]).padStart(2, '0'); + return `${match[1]}-${month}-${day}`; + }, + applyBusinessTermRecognition(term, termStartDate = '') { + const startFromField = this.normalizeOcrDate(termStartDate); + if (startFromField) { + this.archiveForm.businessStartDate = startFromField; + } if (!term) return; - if (/长期|永久/.test(term)) { + if (/长期|永久|无固定/.test(term) || this.isNoFixedBusinessTerm(term)) { this.archiveForm.businessTermType = '长期'; this.archiveForm.businessEndDate = ''; return; } - const match = term.match(/(\d{4}[年./-]\d{1,2}[月./-]\d{1,2})/); - if (!match) return; - const date = match[1] - .replace(/[年./]/g, '-') - .replace('月', '-') - .replace('日', ''); - this.archiveForm.businessTermType = '固定期限'; - this.archiveForm.businessEndDate = date; + const dateMatches = String(term).match(/\d{4}[年./-]\d{1,2}[月./-]\d{1,2}/g) || []; + const startDate = this.normalizeOcrDate(dateMatches[0]) || startFromField; + const endDate = this.normalizeOcrDate(dateMatches[1]); + if (startDate) { + this.archiveForm.businessStartDate = startDate; + } + if (endDate) { + this.archiveForm.businessTermType = '固定期限'; + this.archiveForm.businessEndDate = endDate; + return; + } + if (startDate && dateMatches.length === 1 && !/至|到|-|—|~/.test(term)) { + // 仅识别到一个日期且无区间分隔时,按截止日处理(兼容旧 OCR 结果) + this.archiveForm.businessTermType = '固定期限'; + this.archiveForm.businessEndDate = startDate; + } }, getQualificationFileUrl(file = {}) { return file.url || file.link || file.domain || ''; @@ -4159,7 +4208,6 @@ export default { return value; }, normalizeDetail(detail) { - const businessScope = this.splitValue(detail.businessScope); const registeredDetailAddress = detail.registeredDetailAddress || (detail.registeredRegionName ? '' : detail.registeredAddress) || @@ -4172,10 +4220,14 @@ export default { customerKind: detail.customerKind || 'external', invoiceTaxRate: this.normalizeOptionalAmount(detail.invoiceTaxRate), registeredCapital: this.normalizeOptionalAmount(detail.registeredCapital), + paidInCapital: this.normalizeOptionalAmount(detail.paidInCapital), maxCreditLimit: this.normalizeOptionalAmount(detail.maxCreditLimit), applyCreditLimit: this.normalizeOptionalAmount(detail.applyCreditLimit), - businessScope, + usedCreditLimit: this.normalizeOptionalAmount(detail.usedCreditLimit), + businessScope: detail.businessScope || '', businessTermType: detail.businessTermType || '固定期限', + businessStartDate: detail.businessStartDate || '', + businessEndDate: detail.businessEndDate || '', networkFreightPlatform: detail.networkFreightPlatform === 0 || detail.networkFreightPlatform === 1 ? detail.networkFreightPlatform @@ -4211,6 +4263,7 @@ export default { scoreDate: this.$dayjs().format('YYYY-MM-DD'), applyCreditLimit: this.archiveForm.applyCreditLimit || '', maxCreditLimit: '', + maxMinusScore: '', creditLevel: '', standards: [], tempApplyCreditLimit: '', @@ -4230,6 +4283,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 => ({ @@ -4268,7 +4325,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 || ''; @@ -4397,16 +4461,21 @@ export default { if (archive.businessTermType === '长期') { archive.businessEndDate = null; } + if (!archive.businessStartDate) { + archive.businessStartDate = null; + } const invoiceTaxRate = this.normalizeOptionalAmount(archive.invoiceTaxRate); const registeredCapital = this.normalizeOptionalAmount(archive.registeredCapital); + const paidInCapital = this.normalizeOptionalAmount(archive.paidInCapital); + const usedCreditLimit = this.normalizeOptionalAmount(archive.usedCreditLimit); archive.invoiceTaxRate = invoiceTaxRate === '' ? null : invoiceTaxRate; archive.registeredCapital = registeredCapital === '' ? null : registeredCapital; + archive.paidInCapital = paidInCapital === '' ? null : paidInCapital; + archive.usedCreditLimit = usedCreditLimit === '' ? null : usedCreditLimit; archive.customerType = Array.isArray(archive.customerType) ? archive.customerType.join(',') : archive.customerType; - archive.businessScope = Array.isArray(archive.businessScope) - ? archive.businessScope.join(',') - : archive.businessScope; + archive.businessScope = String(archive.businessScope || '').trim(); archive.networkFreightPlatform = archive.networkFreightPlatform === 0 || archive.networkFreightPlatform === 1 ? archive.networkFreightPlatform @@ -4437,6 +4506,11 @@ export default { return contact; }) .filter(item => item.contactName || item.contactPhone); + // 主表联系电话改由联系人维护,取第一条有效手机号回填 + const firstContactPhone = (archive.contacts || []) + .map(item => String(item.contactPhone || '').trim()) + .find(Boolean); + archive.contactPhone = firstContactPhone || null; archive.receiptAccounts = (archive.receiptAccounts || []) .map(item => this.normalizeReceipt(item)) .filter(item => item.accountName || item.accountHolderName || item.bankAccount || item.cnapsCode); @@ -4475,16 +4549,29 @@ export default { archive.applyCreditLimit = applyCreditLimit === '' ? null : applyCreditLimit; return archive; }, + validateContactsRequired(archive = this.archiveForm) { + const contacts = (archive.contacts || []).filter( + item => String(item.contactName || '').trim() || String(item.contactPhone || '').trim() + ); + if (!contacts.length) { + this.$message.warning('请至少维护一条联系人'); + this.activeTab = 'contacts'; + return false; + } + return true; + }, saveArchive() { if (this.kingdeeBlocked) { this.$message.warning('金蝶审核中,请稍后重新添加'); return; } + this.applyFixedOwnerDept(); this.$refs.archiveForm.validate(valid => { if (!valid) { this.$message.warning('请先完整填写必填项'); return; } + if (!this.validateContactsRequired()) return; submit(this.normalizeArchive()).then(() => { this.closeArchive(); this.onLoad(this.page); @@ -4497,11 +4584,13 @@ export default { this.$message.warning('金蝶审核中,请稍后重新添加'); return; } + this.applyFixedOwnerDept(); this.$refs.archiveForm.validate(valid => { if (!valid) { this.$message.warning('请先完整填写必填项'); return; } + if (!this.validateContactsRequired()) return; const archive = this.normalizeArchive(); // 仅提交时落变更记录;保存不记录 archive.recordChange = true; @@ -4577,9 +4666,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; @@ -4600,9 +4711,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; @@ -4637,6 +4748,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, @@ -4653,6 +4769,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 => ({ @@ -4797,13 +4924,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 ?? '') @@ -4984,14 +5122,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) ); @@ -5065,7 +5206,11 @@ export default { return pending; }, deleteScore(index) { - this.$confirm('确定删除该评分记录?', '提示', { + const freezeCredit = this.shouldFreezeEffectiveCredit(); + const confirmText = freezeCredit + ? '确定删除该评分记录?删除后可重新添加评分表;原评分数据会保留,新评分再次审核通过前,客商等级与额度仍按原评分暂时生效。' + : '确定删除该评分记录?'; + this.$confirm(confirmText, '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning', @@ -5078,16 +5223,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(() => {}); }, @@ -5223,6 +5375,7 @@ export default { getDetail(row.id).then(res => { const archive = this.normalizeDetail(res.data.data); archive.qualificationAttachments = archive.qualificationAttachments || ''; + if (!this.validateContactsRequired(archive)) return; if (!this.validateScoreForApproval(archive)) return; this.$confirm('是否提交客商准入审批?', '提示', { confirmButtonText: '确定', @@ -5604,7 +5757,8 @@ export default { display: flex; align-items: center; gap: 8px; - flex-wrap: nowrap; + flex-wrap: wrap; + width: 100%; :deep(.el-date-editor) { flex: 0 0 140px; @@ -5612,6 +5766,11 @@ export default { min-width: 0; } + .business-term-separator { + flex: 0 0 auto; + color: #606266; + } + :deep(.el-checkbox) { flex: 0 0 auto; margin-right: 0; @@ -5619,6 +5778,10 @@ export default { } } +.invoice-bank-autocomplete { + width: 100%; +} + .archive-form__address { :deep(.el-cascader) { flex: 0 0 240px; @@ -5827,6 +5990,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 { @@ -5846,11 +6017,13 @@ export default { :deep(.el-input), :deep(.el-cascader), - :deep(.el-select) { + :deep(.el-select), + :deep(.el-autocomplete) { width: 100%; } - :deep(.el-cascader .el-input) { + :deep(.el-cascader .el-input), + :deep(.el-autocomplete .el-input) { width: 100%; } } diff --git a/vite.config.mjs b/vite.config.mjs index c2f339c..3e0426e 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -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, './'),