diff --git a/.workbuddy/memory/2026-10-20.md b/.workbuddy/memory/2026-10-20.md new file mode 100644 index 0000000..cd8d847 --- /dev/null +++ b/.workbuddy/memory/2026-10-20.md @@ -0,0 +1,31 @@ +# 2026-10-20 + +## settlement-import 列表页工具栏对齐 receipt-settlement + +- `src/views/settlement/settlement-import.vue`: + - 左侧只保留「新增」(`type="primary"`),删掉原来的 `plain` 文字「刷新」按钮(刷新统一收进右侧图标组,与 receipt-settlement / settlement-adjustment 一致)。 + - 右侧接入 ``(`./components/settlement-table-tools.vue`,即 `table-header-tools` 包装):刷新 / 列设置 / 搜索显隐 / 表格尺寸 4 键,`@refresh="refreshTable"`。 + - 表格列改为配置化,新增 `src/option/settlement/settlementImportTable.js`(`settlementImportTableColumns`),页面加 `tableColumns / visibleColumnKeys / tableSize / columnSettingOptions / visibleTableColumns`,`el-table` 加 `:size="tableSize"`。 + - 列渲染标志:`link / status / enum / billType / count`(注意**不能用 `type` 作 key**,`type` 是 el-table-column 真实 prop,会把列变成 selection/index 列)。 + - 新增 `displayValue()`;`settlementBillTypeLabel('')` 返回 `-`。 + - 序号列改 `fixed="left"`;数据列与操作列统一 `align: 'center'`,操作链接 `justify-content: center`。 +- 校验方式(无 dev server 时):项目根目录临时 `sfc-check.mjs` 用 `vue/compiler-sfc` 的 `parse + compileTemplate + compileScript`;SCSS 用 `sass.compileString`。用完即删。 + +## 追加:用户要求「列显隐 + 显示方式」→ 两个页面改 avue-crud + +用户反馈上面那版不对:右侧工具按钮必须是 **avue-crud 原生**的 4 键,第 2 键是「列显隐」抽屉(不是「列设置」勾选弹窗),第 4 键是「显示方式」(表格↔卡片),不是表格尺寸循环。确认后范围:`settlement-import` + `formal-settlement`(receipt/payment 共用)都改 avue-crud。 + +- 关键结论:**「列显隐 + 显示方式」只有 avue-crud 原生才有**,avue 的 `dialog-column` 依赖 `inject: ['crud']`,脱离 avue-crud 拿不出来;`table-header-tools` 只是仿制件,做不到。所以目标页只能整页改 avue-crud。 +- avue 右侧按钮源码:`node_modules/@smallwei/avue/lib/packages/element-plus/crud/menu/header-menu3.js`(refreshBtn/columnBtn/searchBtn/gridBtn,`gridBtn` → `handleGridShow()` 切 `gridShow` 表格↔卡片);图标默认配置在同目录 `config.js`。 +- 新增 `src/option/settlement/settlementImport.js`(`settlementImportOption`)、`src/option/settlement/formalSettlement.js`(`formalSettlementOption`,表格列复用 `formalSettlementTable.js` 的 `formalSettlementTableColumns` + `COLUMN_EXTRA` 增量配置,避免两处维护)。 +- `settlement-import.vue` 整页改 avue-crud(删除手写的搜索区/表格/分页);`formal-settlement-table-panel.vue` 由 el-table 改成包一层 ``,父页面保留数据加载与全部业务动作。 +- ⚠️ **avue 的搜索自定义控件要用 `column.renderSearch`(`h()` 渲染),不能用 `searchslot`** —— avue 3.9.2 根本不读 `searchslot`(全库无此字段),而 `searchslot: true` 还会被 `header-search2.js` 的 `searchXxx → xxx` 映射成 `slot: true`。真正的搜索插槽是 avue-crud 上 `${prop}-search`(`crud.searchSlot`,见 `index2.js` `getSlotList(["-search"])`),但其作用域**没有 `row`**(只有 value/column/label/size/readonly/disabled/dic),`mileage-record.vue` 用 `{ row }` 是错的。已用于批次号下拉(filterable + allowCreate)。 +- avue `formatter` 签名确认:`detail.js` → `column.formatter(row, row[column.prop], result, column)`。 +- `on-load` 由 `table-page2.js` 在 created / 翻页 / 改页容量时触发,参数是内部 `defaultPage`(已 `Object.assign` 合并外部 page)。 +- 分页透传写法(父持 page、子包 avue-crud):子组件用 computed `crudPage{get/set}`,setter 里 `Object.assign(this.page, value)` 原地回写,避免 prop 直接赋值告警。 +- ⚠️ `src/option/settlement/formalSettlementTable.js` **不能删** —— `formal-settlement-editor.vue` 还从它导入 `candidateColumns/candidateDetailColumns/detailColumns/sourceColumns/summaryColumns`(我一度删掉,Vite 转换报错后从 HEAD 恢复)。 +- 校验方式升级:沙箱里 curl 不通 dev server(502),改用 `vite.createServer({ middlewareMode:true })` + `server.transformRequest(path)` 就地转换,能同时暴露依赖解析失败(如上面误删文件)。脚本用完即删。 + +## 遗留未改(仍用 table-header-tools 仿制件) + +pre-settlement、settlement-adjustment、receivable-payable-detail、transport-reconciliation-table-panel、business/{master-order,loading-manage,voucher-manage}、payment/* 手写页。若用户要全站一致,需逐页改 avue-crud。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 0784184..647e2d9 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -69,6 +69,18 @@ - 自检:`curl "localhost:2888/src/xxx.vue"` 与 `"...?vue&type=style&index=0&scoped=true&lang.scss"`(200 / 500)。`vite build` 在 `facelogin.vue` 被沙箱中断,不能作唯一校验。dev `VITE_APP_API=/api` 代理 `172.16.203.228:8000`。 - 导出:前端 `exportColumns` 后端未用,以 `XxxExportExcel.java` 为准;BladeX `BeanUtil` 类型不兼容静默跳过(`Date` → `LocalDateTime` 丢值,用 `DateUtil.fromDate`)。 +## avue-crud 才是「右侧 4 键」的唯一来源(2026-10-20 定论) +用户要的右侧工具栏是 **avue-crud 原生**:刷新 / **列显隐**(el-drawer,含 隐藏/冻结/过滤/排序 + 行拖拽)/ 搜索显隐 / **显示方式**(表格↔卡片)。 +- 源码:`node_modules/@smallwei/avue/lib/packages/element-plus/crud/menu/header-menu3.js`(refreshBtn/columnBtn/searchBtn/gridBtn),`gridBtn` → `handleGridShow()` 切 `gridShow`;图标默认在同目录 `config.js`。「列显隐」文案来自 avue 语言包 `crud.showTitle`(项目 `src/lang/zh.js` 已覆盖)。 +- **列显隐抽屉依赖 `inject: ['crud']`,脱离 avue-crud 拿不出来**;`components/table-header-tools` 的「列设置勾选弹窗 + 表格尺寸循环」是仿制件,**做不到这两点**。所以「要和 avue 一致」= 整页改用 avue-crud,没有中间方案。 +- ⚠️ 搜索自定义控件用 `column.renderSearch = scope => h(...)`(`scope.row` 可用,同 `mixins/organization-search.js`)。**不要用 `searchslot`**:avue 3.9.2 全库无此字段,且 `header-search2.js` 的 `searchXxx → xxx` 映射会把 `searchslot` 变成 `slot: true`。 +- avue-crud 上的 `${prop}-search` 插槽确实存在(`index2.js` 的 `getSlotList(["-search"])`),但**作用域没有 `row`**(只有 value/column/label/size/readonly/disabled/dic)——`mileage-record.vue` 里 `{ row }` 是错的写法,别照抄。 +- `formatter` 签名:`detail.js` → `column.formatter(row, row[column.prop], result, column)`。 +- `on-load` 由 `table-page2.js` 在 created / 翻页 / 改页容量时触发,参数是内部 `defaultPage`(已合并外部 page)。分页若由父组件持有、子组件包一层 avue-crud,用 computed `crudPage{get/set}` + setter 里 `Object.assign(this.page, value)` 原地回写。 +- 已改:settlement-import.vue、formal-settlement.vue + components/formal-settlement-table-panel.vue(receipt/payment 共用)。未改:pre-settlement、settlement-adjustment、receivable-payable-detail、transport-reconciliation-table-panel、business 与 payment 的手写页。 +- ⚠️ `src/option/settlement/formalSettlementTable.js` 别删:`formal-settlement-editor.vue` 还依赖它的 candidate/detail/source/summary 列。 +- 沙箱里 curl 打不通本地 dev server(一律 502)。改用 `vite.createServer({middlewareMode:true})` + `server.transformRequest(path)` 就地转换校验,还能暴露依赖解析失败。 + ## Element Plus 图标两种用法的机制差异(易误判) `main.js` 里 `for (...ElementPlusIconsVue) app.component(key, component)` 全局注册了全部图标,因此模板里可以写标签形式 ``(靠全局注册解析)。 但 `:icon="OfficeBuilding"` 是**表达式**,编译成 `_ctx.OfficeBuilding`,只会从 data / props / computed / setup 返回值里取 —— **组件对象必须放进 `data()`**;放进 `components: {}` 注册里模板取不到,会静默渲染不出图标(不报错,只是空白)。 diff --git a/src/api/payment/billLedger.js b/src/api/payment/billLedger.js index d268f2b..cace3bb 100644 --- a/src/api/payment/billLedger.js +++ b/src/api/payment/billLedger.js @@ -6,17 +6,17 @@ export const getList = (current, size, params) => request({ url: `${baseUrl}/list`, method: 'get', params: { current, size, ...params } }); export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } }); export const getExpiryCounts = () => request({ url: `${baseUrl}/expiry-counts`, method: 'get' }); -export const getAvailableOptions = (keyword, deptId, selectedId) => +export const getAvailableOptions = (keyword, deptId, selectedId, payerName, payeeName) => request({ url: `${baseUrl}/available-options`, method: 'get', - params: { keyword, deptId, selectedId }, + params: { keyword, deptId, selectedId, payerName, payeeName }, }); -export const getAvailablePage = (current, size, keyword, deptId, selectedId) => +export const getAvailablePage = (current, size, keyword, deptId, selectedId, payerName, payeeName) => request({ url: `${baseUrl}/available-page`, method: 'get', - params: { current, size, keyword, deptId, selectedId }, + params: { current, size, keyword, deptId, selectedId, payerName, payeeName }, }); export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post', data }); export const remove = id => request({ url: `${baseUrl}/remove`, method: 'post', params: { id } }); diff --git a/src/option/business/contract-manage.js b/src/option/business/contract-manage.js index 0d1e912..e59adf3 100644 --- a/src/option/business/contract-manage.js +++ b/src/option/business/contract-manage.js @@ -252,8 +252,8 @@ export const option = createCrudOption([ searchOrder: 6, searchPlaceholder: '请输入', order: 185, - disabled: true, - placeholder: '不填写,则系统自动生成', + maxlength: 100, + placeholder: '不填写则按部门规则自动生成', minWidth: 200, }, { diff --git a/src/option/settlement/formalSettlement.js b/src/option/settlement/formalSettlement.js new file mode 100644 index 0000000..e9523be --- /dev/null +++ b/src/option/settlement/formalSettlement.js @@ -0,0 +1,173 @@ +/** + * 正式结算单列表(avue-crud)配置 + * ------------------------------------------------------------------ + * 列表页与其它模块统一使用 avue-crud,头部 4 个按钮(刷新 / 列显隐 / 搜索显隐 / + * 显示方式)由 avue-crud 自带,不做二次封装。 + * + * 表格列复用 formalSettlementTable.js 的 formalSettlementTableColumns(与弹窗内 + * 明细表共用同一份列定义),此处只补充 avue 需要的展示/搜索属性,避免两处维护。 + * + * ⚠️ searchOrder 为降序排序,值越大越靠前。 + * ⚠️ 仅用于搜索的列必须 hide + showColumn: false,否则会混进「列显隐」抽屉。 + * ⚠️ column 数组顺序 = 表格列顺序,搜索专用列统一放在数组最前面。 + */ + +import { + approvalStatusOptions, + invoiceStatusOptions, + kingdeeSyncStatusOptions, + paymentStatusOptions, +} from './formalSettlementSearch'; +import { formalSettlementTableColumns } from './formalSettlementTable'; + +const textFormatter = (row, value) => + value === null || value === undefined || value === '' ? '-' : value; + +/** 金额统一展示:两位小数 + 币种 */ +const moneyFormatter = (row, value) => + `${Number(value || 0).toFixed(2)} ${row?.currency || 'RMB'}`; + +const pickLabel = (options, value) => + options.find(item => item.value === value)?.label || value || '-'; + +const searchOnlyColumn = ({ label, prop, searchOrder, type = 'input', dicData }) => { + const column = { + label, + prop, + search: true, + searchOrder, + searchSpan: 5, + hide: true, + showColumn: false, + addDisplay: false, + editDisplay: false, + viewDisplay: false, + }; + if (type === 'select') { + column.type = 'select'; + column.searchPlaceholder = '请选择'; + column.dicData = dicData || []; + } else if (type === 'daterange') { + column.type = 'daterange'; + column.searchType = 'daterange'; + column.searchPlaceholder = '请选择'; + } else { + column.searchPlaceholder = '请输入'; + } + return column; +}; + +/** 表格列的 avue 增量配置:搜索项、插槽、覆盖宽度等 */ +const COLUMN_EXTRA = { + formalSettlementNo: { + slot: true, + search: true, + searchSpan: 5, + searchOrder: 12, + searchPlaceholder: '请输入', + }, + payerName: { search: true, searchSpan: 5, searchOrder: 7, searchPlaceholder: '请输入' }, + payeeName: { search: true, searchSpan: 5, searchOrder: 6, searchPlaceholder: '请输入' }, + projectName: { search: true, searchSpan: 5, searchOrder: 11, searchPlaceholder: '请输入' }, + deptName: { search: true, searchslot: true, searchSpan: 5, searchOrder: 10 }, + contractNo: { search: true, searchSpan: 5, searchOrder: 9, searchPlaceholder: '请输入' }, + invoiceStatusName: { slot: true }, + paymentStatusName: { slot: true }, + approvalStatusName: { slot: true }, + adjustFlag: { + formatter: row => row.adjustStatusName || (row.originFormalSettlementNo ? '调整后单' : '-'), + }, + kingdeeSyncStatus: { + slot: true, + search: true, + type: 'select', + dicData: kingdeeSyncStatusOptions, + searchSpan: 5, + searchOrder: 2, + searchPlaceholder: '请选择', + }, + kingdeeLockStatus: { slot: true }, + createTime: { width: 180 }, +}; + +const buildTableColumns = () => + formalSettlementTableColumns.map(column => { + const extra = { ...(COLUMN_EXTRA[column.prop] || {}) }; + if (column.money) { + extra.formatter = moneyFormatter; + extra.align = 'right'; + } + // link / status 标记的列统一走单元格插槽 + if (column.link || column.status) extra.slot = true; + if (!extra.formatter) extra.formatter = textFormatter; + return { ...column, ...extra }; + }); + +export const formalSettlementOption = () => ({ + height: 'auto', + calcHeight: 32, + tip: false, + searchBtnText: '查询', + emptyBtnText: '重置', + searchShow: true, + searchIcon: true, + searchLabelWidth: 88, + // 查询/重置紧跟首行 4 个条件(4×5 + 菜单 4 = 24),不再独占下一整行 + searchMenuSpan: 4, + searchIndex: 4, + searchMenuPosition: 'right', + border: true, + index: true, + indexLabel: '序号', + indexWidth: 64, + indexFixed: 'left', + selection: true, + selectionWidth: 52, + selectionFixed: 'left', + addBtn: false, + viewBtn: false, + delBtn: false, + editBtn: false, + menuWidth: 320, + menuFixed: 'right', + column: [ + // ---------- 仅搜索列 ---------- + searchOnlyColumn({ + label: '预付结算单号', + prop: 'preSettlementNo', + searchOrder: 8, + }), + searchOnlyColumn({ + label: '发票状态', + prop: 'invoiceStatus', + searchOrder: 5, + type: 'select', + dicData: invoiceStatusOptions, + }), + searchOnlyColumn({ + label: '收付款状态', + prop: 'paymentStatus', + searchOrder: 4, + type: 'select', + dicData: paymentStatusOptions, + }), + searchOnlyColumn({ + label: '审核状态', + prop: 'approvalStatus', + searchOrder: 3, + type: 'select', + dicData: approvalStatusOptions, + }), + searchOnlyColumn({ + label: '生成日期', + prop: 'createDateRange', + searchOrder: 1, + type: 'daterange', + }), + // ---------- 表格列(部分兼搜索项) ---------- + ...buildTableColumns(), + ], +}); + +export const kingdeeSyncName = value => pickLabel(kingdeeSyncStatusOptions, value); +export const paymentStatusName = value => pickLabel(paymentStatusOptions, value); diff --git a/src/option/settlement/formalSettlementTable.js b/src/option/settlement/formalSettlementTable.js index 92713ca..f53ee46 100644 --- a/src/option/settlement/formalSettlementTable.js +++ b/src/option/settlement/formalSettlementTable.js @@ -55,7 +55,6 @@ export const detailColumns = [ { label: '运费', prop: 'freightAmount', minWidth: 120, money: true }, { label: '调整金额', prop: 'adjustAmount', minWidth: 120, money: true }, { label: '结算金额(含税)', prop: 'settlementAmountTax', minWidth: 155, money: true }, - { label: '结算金额(不含税)', prop: 'settlementAmountNoTax', minWidth: 170, money: true }, { label: '备注', prop: 'remark', minWidth: 180 }, ]; diff --git a/src/option/settlement/settlementImport.js b/src/option/settlement/settlementImport.js new file mode 100644 index 0000000..49f4dce --- /dev/null +++ b/src/option/settlement/settlementImport.js @@ -0,0 +1,157 @@ +/** + * 导入结算单列表(avue-crud)配置 + * ------------------------------------------------------------------ + * 列表页与其它模块统一使用 avue-crud,头部 4 个按钮(刷新 / 列显隐 / 搜索显隐 / + * 显示方式)由 avue-crud 自带,不做二次封装。 + * + * ⚠️ searchOrder 为降序排序,值越大越靠前。 + * ⚠️ 仅用于搜索的列必须 hide + showColumn: false,否则会混进「列显隐」抽屉。 + */ + +const IMPORT_STATUS_OPTIONS = [ + { label: '草稿', value: 'draft' }, + { label: '已完成', value: 'completed' }, + { label: '已作废', value: 'voided' }, +]; + +const SETTLEMENT_TYPE_OPTIONS = [ + { label: '应收', value: 'receivable' }, + { label: '应付', value: 'payable' }, +]; + +const textFormatter = (row, value) => + value === null || value === undefined || value === '' ? '-' : value; + +const pickLabel = (options, value) => + options.find(item => item.value === value)?.label || value || '-'; + +const settlementTypeLabel = value => pickLabel(SETTLEMENT_TYPE_OPTIONS, value); + +const billTypeLabel = value => (value === 'pre' ? '预结算单' : value ? '正式结算单' : '-'); + +export const importStatusLabel = value => pickLabel(IMPORT_STATUS_OPTIONS, value); + +export const settlementImportOption = () => ({ + height: 'auto', + calcHeight: 32, + tip: false, + searchBtnText: '查询', + emptyBtnText: '重置', + searchShow: true, + searchIcon: true, + searchLabelWidth: 88, + // 查询/重置紧跟首行 4 个条件(4×5 + 菜单 4 = 24),不再独占下一整行 + searchMenuSpan: 4, + searchIndex: 4, + searchMenuPosition: 'right', + border: true, + index: true, + indexLabel: '序号', + indexWidth: 64, + indexFixed: 'left', + selection: false, + addBtn: false, + viewBtn: false, + delBtn: false, + editBtn: false, + menuWidth: 220, + menuFixed: 'right', + column: [ + // ---------- 搜索项(前 4 项默认展示,其余折叠;searchOrder 越大越靠前) ---------- + { + label: '状态', + prop: 'importStatus', + type: 'select', + search: true, + searchSpan: 5, + searchOrder: 6, + searchPlaceholder: '请选择', + dicData: IMPORT_STATUS_OPTIONS, + hide: true, + showColumn: false, + addDisplay: false, + editDisplay: false, + viewDisplay: false, + }, + { + label: '导入类型', + prop: 'settlementType', + type: 'select', + search: true, + searchSpan: 5, + searchOrder: 5, + searchPlaceholder: '请选择', + dicData: SETTLEMENT_TYPE_OPTIONS, + hide: true, + showColumn: false, + addDisplay: false, + editDisplay: false, + viewDisplay: false, + }, + // ---------- 表格列 ---------- + { + label: '导入批次号', + prop: 'batchNo', + slot: true, + search: true, + searchSpan: 5, + searchOrder: 7, + searchPlaceholder: '请选择', + minWidth: 160, + }, + { label: '状态', prop: 'importStatusName', slot: true, width: 100, align: 'center' }, + { + label: '导入类型', + prop: 'settlementTypeName', + minWidth: 100, + align: 'center', + formatter: (row, value) => value || settlementTypeLabel(row.settlementType), + }, + { + label: '结算单', + prop: 'settlementBillTypeName', + minWidth: 110, + align: 'center', + formatter: (row, value) => value || billTypeLabel(row.settlementBillType), + }, + { + label: '项目', + prop: 'projectName', + search: true, + searchSpan: 5, + searchOrder: 4, + searchPlaceholder: '请输入', + minWidth: 140, + formatter: textFormatter, + }, + { + label: '客户', + prop: 'customerName', + search: true, + searchSpan: 5, + searchOrder: 3, + searchPlaceholder: '请输入', + minWidth: 140, + formatter: textFormatter, + }, + { + label: '承运商', + prop: 'carrierName', + search: true, + searchSpan: 5, + searchOrder: 2, + searchPlaceholder: '请输入', + minWidth: 140, + formatter: textFormatter, + }, + { + label: '明细行数', + prop: 'detailCount', + width: 100, + align: 'center', + formatter: row => row.detailCount ?? row.waybillCount ?? 0, + }, + { label: '创建时间', prop: 'createTime', width: 180, align: 'center', formatter: textFormatter }, + { label: '创建人', prop: 'createUserName', minWidth: 110, formatter: textFormatter }, + ], +}); diff --git a/src/option/system/user.js b/src/option/system/user.js index 93e3152..cd0a0ee 100644 --- a/src/option/system/user.js +++ b/src/option/system/user.js @@ -72,6 +72,7 @@ export const userOption = safe => { }, { label: '岗位', prop: 'postName', overHidden: true, display: false }, { label: '性别', prop: 'sexName', width: 80, display: false }, + { label: 'OA登录名', prop: 'oaLoginId', minWidth: 140, overHidden: true, display: false }, { label: 'OA人员id', prop: 'oaPersonId', minWidth: 120, overHidden: true, display: false }, { label: '备注', prop: 'remark', minWidth: 160, overHidden: true, display: false }, { diff --git a/src/views/business/components/business-crud-page.vue b/src/views/business/components/business-crud-page.vue index 484ead8..eb23da3 100644 --- a/src/views/business/components/business-crud-page.vue +++ b/src/views/business/components/business-crud-page.vue @@ -3774,7 +3774,7 @@ @@ -4031,7 +4031,7 @@
剩余数量:{{ - formatDispatchQuantity(dispatchItemHintRemainingQuantity(dispatchItemForm)) + formatDispatchQuantity(dispatchItemDisplayRemainingQuantity(dispatchItemForm)) }} {{ dispatchItemForm.quantityUnit || '吨' }}
@@ -5740,6 +5740,8 @@ export default { dispatchItemIndex: -1, dispatchItemForm: defaultDispatchRow(), dispatchItemCargoRows: [], + // 本次数量变更版本号:驱动同表其他行「剩余数量」同步重算 + dispatchCargoQuantityVersion: 0, dispatchItemAttachmentRows: [], selectedDispatchItemAttachmentRows: [], attachmentRows: [], @@ -12776,18 +12778,23 @@ export default { row.cargoName = ''; row.specification = ''; row.model = ''; + this.dispatchCargoQuantityVersion += 1; }, handleDispatchConfiguredCargoNameChange(row, value) { const option = this.getDispatchConfiguredCargoNameOptions(row).find( item => String(item.value) === String(value || '') ); row.cargoName = option?.value || ''; - if (!option) return; + if (!option) { + this.dispatchCargoQuantityVersion += 1; + return; + } row.cargoType = option.cargoTypeLabel || row.cargoType || ''; row.cargoTypeCode = option.cargoTypeCode || row.cargoTypeCode || ''; row.cargoTypePath = option.cargoTypePath || row.cargoTypePath || []; row.specification = option.specification || row.specification || ''; row.model = option.model || row.model || ''; + this.dispatchCargoQuantityVersion += 1; }, openDispatchItemDialog(index = -1, row = defaultDispatchRow()) { const defaultSummaryItem = @@ -13119,6 +13126,7 @@ export default { const parts = text.split('.'); row.quantity = parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 3)}` : parts[0]; + this.dispatchCargoQuantityVersion += 1; }, handleDispatchCargoNumberInput(row, prop, value) { this.handleTaskFullFreightNumberInput(row, prop, value); @@ -13138,12 +13146,14 @@ export default { const nextRow = this.normalizeTransportCargoRow({ priceUnit: '元/吨' }); if (index > -1) this.dispatchItemCargoRows.splice(index + 1, 0, nextRow); else this.dispatchItemCargoRows.push(nextRow); + this.dispatchCargoQuantityVersion += 1; }, removeDispatchCargoRow(index) { this.dispatchItemCargoRows.splice(index, 1); if (!this.dispatchItemCargoRows.length) { this.dispatchItemCargoRows.push(this.normalizeTransportCargoRow()); } + this.dispatchCargoQuantityVersion += 1; }, dispatchCargoFreightAmount(cargo = {}) { const quantity = Number(cargo.quantity || 0); @@ -13217,11 +13227,52 @@ export default { }, 0) ); }, - /** 精简录入提示:可调度余量扣减当前本次数量后的剩余 */ - dispatchItemHintRemainingQuantity(row = {}) { + /** + * 当前编辑表单中,与指定行同货物(有身份)或同单位(无身份)已占用的本次数量合计。 + * 用于剩余数量展示联动,不参与提交校验(校验仍按整单合计对比计划余量)。 + */ + getDispatchFormOccupiedQuantity(row = {}) { + const goodsRows = + this.dispatchItemForm?.taskEntryMode === 'full' + ? this.dispatchItemCargoRows || [] + : [this.dispatchItemForm || row]; + const hasCargoIdentity = Boolean( + String(row.cargoName || row.goodsName || row.name || '').trim() || + String( + row.cargoType || row.secondCargoTypeName || row.goodsType || row.type || '' + ).trim() + ); + const unit = this.getDispatchQuantityUnit(row); + const cargoKey = this.getDispatchCargoIdentity(row); + return goodsRows.reduce((sum, goods) => { + if (!goods) return sum; + if (hasCargoIdentity) { + const goodsHasIdentity = Boolean( + String(goods.cargoName || goods.goodsName || goods.name || '').trim() || + String( + goods.cargoType || goods.secondCargoTypeName || goods.goodsType || goods.type || '' + ).trim() + ); + if (!goodsHasIdentity || this.getDispatchCargoIdentity(goods) !== cargoKey) { + return sum; + } + } else if (this.getDispatchQuantityUnit(goods) !== unit) { + return sum; + } + return sum + this.parseDispatchQuantity(goods.quantity); + }, 0); + }, + /** 展示用剩余数量:计划可调度余量扣减本单已填本次数量(含当前行) */ + dispatchItemDisplayRemainingQuantity(row = {}) { + // 依赖版本号,确保一行本次数量变化后其他行剩余数量一并刷新 + void this.dispatchCargoQuantityVersion; const available = this.dispatchItemRemainingQuantity(row); - const current = this.parseDispatchQuantity(row.quantity); - return Math.max(available - current, 0); + const occupied = this.getDispatchFormOccupiedQuantity(row); + return Math.max(available - occupied, 0); + }, + /** 精简录入提示:与完整录入剩余数量展示规则一致 */ + dispatchItemHintRemainingQuantity(row = {}) { + return this.dispatchItemDisplayRemainingQuantity(row); }, pruneDispatchPendingRows(rows = []) { const planQuantities = new Map(); diff --git a/src/views/business/components/shipping-template-page.vue b/src/views/business/components/shipping-template-page.vue index 21f0015..5b4df82 100644 --- a/src/views/business/components/shipping-template-page.vue +++ b/src/views/business/components/shipping-template-page.vue @@ -388,7 +388,7 @@ :disabled="dialogReadonly" @visible-change="visible => visible && loadFeeUnitOptions()" > sum + Number(this.freightAmount(item) || 0), @@ -1530,6 +1552,18 @@ export default { }, watch: { $route(to, from) { + // 列表实例:从其他页进入列表时刷新(activated 负责标签切回)。 + if (!this.isFormPageInstance) { + const enteringList = + (to.path === this.routePathLocked || to.path === shippingTemplateListRoute) && + from?.path && + from.path !== to.path; + if (enteringList) { + sessionStorage.removeItem(shippingTemplateListRefreshKey); + this.$nextTick(() => this.onLoad(this.page, this.query)); + } + return; + } if ( this.isStandaloneFormPage && (to.query.mode !== from?.query?.mode || @@ -1550,6 +1584,10 @@ export default { this.handleTransportTypeChange(value); this.syncFreightItems(); }, + 'form.templateType'() { + // 切换到运单模板时立即按货物数量单位生成公路运费明细。 + this.$nextTick(() => this.syncFreightItems()); + }, }, created() { this.loadProjectOptions(); @@ -1559,8 +1597,16 @@ export default { this.loadCurrencyOptions(); if (this.isStandaloneFormPage) this.$nextTick(() => this.initStandaloneFormPage()); }, + activated() { + if (this.isFormPageInstance) return; + sessionStorage.removeItem(shippingTemplateListRefreshKey); + this.$nextTick(() => this.onLoad(this.page, this.query)); + }, methods: { formatContractOptionLabel, + markShippingTemplateListNeedRefresh() { + sessionStorage.setItem(shippingTemplateListRefreshKey, '1'); + }, loadQuantityUnitOptions() { if (this.quantityUnitLoading) return this.quantityUnitLoadPromise; this.quantityUnitLoading = true; @@ -1591,12 +1637,8 @@ export default { }); } if (this.menuWidth != null) next.menuWidth = this.menuWidth; - if ( - this.standaloneFormPage && - ['/business/shipping-template', '/business/shipping-template/form'].includes( - this.$route.path - ) - ) { + // 仅表单页实例套用全屏表单弹窗样式,避免列表页误用后表格区域异常。 + if (this.standaloneFormPage && this.$route.path === shippingTemplateFormRoute) { next.dialogAppendToBody = false; next.dialogModal = false; next.dialogCustomClass = 'shipping-template-form-page-dialog'; @@ -1776,13 +1818,22 @@ export default { this.onLoad(this.page, this.query); }, onLoad(page, params = {}) { + if (this.isFormPageInstance) return; this.loading = true; + const currentPage = page?.currentPage || this.page.currentPage || 1; + const pageSize = page?.pageSize || this.page.pageSize || 10; this.api - .getList(page.currentPage, page.pageSize, { ...this.query, ...params }) + .getList(currentPage, pageSize, { ...this.query, ...params }) .then(res => { - const data = res?.data?.data || {}; - this.data = extractRecords(res); - this.page.total = data.total || this.data.length; + const data = res?.data?.data || res?.data || {}; + const records = extractRecords(res); + this.data = records; + this.page.total = Number(data.total ?? records.length) || 0; + }) + .catch(error => { + window.console.error(error); + this.data = []; + this.page.total = 0; }) .finally(() => { this.loading = false; @@ -1821,9 +1872,10 @@ export default { .submit(submit) .then(() => { this.$message.success('操作成功'); + this.markShippingTemplateListNeedRefresh(); done?.(); this.closeCrudDialog(); - this.onLoad(this.page, this.query); + if (!this.isFormPageInstance) this.onLoad(this.page, this.query); }) .catch(() => loading?.()); }, @@ -1837,9 +1889,10 @@ export default { .submit(submit) .then(() => { this.$message.success('操作成功'); + this.markShippingTemplateListNeedRefresh(); done?.(); this.closeCrudDialog(); - this.onLoad(this.page, this.query); + if (!this.isFormPageInstance) this.onLoad(this.page, this.query); }) .catch(() => loading?.()); }, @@ -1883,6 +1936,8 @@ export default { this.attachmentRows = []; this.shippingTemplateFreight = defaultFreight(); this.loadCurrencyOptions(); + this.loadFeeUnitOptions(); + this.$nextTick(() => this.syncFreightItems()); if (typeof this.api.nextCode === 'function') this.api .nextCode() @@ -2005,20 +2060,47 @@ export default { return false; } if (this.transportMode === 'road') { - const invalid = this.shippingTemplateFreight.freightItems.findIndex( - item => - Number(item.unitPrice || 0) < 0 || - !item.priceUnit || - (!this.isFreightAutoCalculable(item) && - (item.freightAmount === '' || - item.freightAmount === null || - item.freightAmount === undefined)) || - Number(item.freightAmount || 0) < 0 - ); - if (invalid > -1) { - this.$message.warning(`第${invalid + 1}条货物的单价、计价单位或运费无效`); + if (!this.shippingTemplateFreight.freightItems.length) { + this.$message.warning('请先完善货物数量单位,以便生成运费明细'); return false; } + for (const [index, item] of this.shippingTemplateFreight.freightItems.entries()) { + const label = `第${index + 1}条运费明细`; + if ( + item.unitPrice === '' || + item.unitPrice === null || + item.unitPrice === undefined + ) { + this.$message.warning(`请填写${label}的单价`); + return false; + } + if (Number(item.unitPrice) < 0) { + this.$message.warning(`${label}的单价不能小于0`); + return false; + } + if (!String(item.priceUnit || '').trim()) { + this.$message.warning(`请选择${label}的计价单位`); + return false; + } + if (this.isFreightAutoCalculable(item)) { + if (!item.freightAmountManual) { + item.freightAmount = this.freightAmount(item); + } + } else if ( + item.freightAmount === '' || + item.freightAmount === null || + item.freightAmount === undefined + ) { + this.$message.warning( + `${label}的计价单位与货物数量单位不匹配,无法自动计算运费,请手动填写运费` + ); + return false; + } + if (Number(item.freightAmount || 0) < 0) { + this.$message.warning(`${label}的运费不能小于0`); + return false; + } + } } else if (Number(this.shippingTemplateFreight.freightAmount || 0) < 0) { this.$message.warning('运费不能小于0'); return false; @@ -2033,8 +2115,11 @@ export default { return true; }, closeCrudDialog() { - if (this.isStandaloneBusinessPage) { - this.$router.push({ path: '/business/shipping-template', query: {} }); + if (this.isStandaloneBusinessPage || this.isFormPageInstance) { + // 关闭独立表单页时标记列表需刷新(含直接点关闭未保存的场景) + this.markShippingTemplateListNeedRefresh(); + this.$router.$avueRouter?.closeTag?.(); + this.$router.push({ path: shippingTemplateListRoute, query: {} }); return; } this.$refs.crud?.closeDialog?.(); @@ -2294,7 +2379,16 @@ export default { }, loadFeeUnitOptions() { getDictionary({ code: 'unit_fee' }).then(res => { - this.feeUnitOptions = this.normalizeDictOptions(res?.data?.data || []); + // 计价单位必须保存可读文案(如 元/吨),才能与货物数量单位匹配并自动计算运费。 + this.feeUnitOptions = (res?.data?.data || []).map(item => { + const label = String(item.dictValue || item.label || item.value || '').trim(); + const key = String(item.dictKey || item.value || '').trim(); + return { + label: label || key, + value: label.includes('/') ? label : key || label, + remark: item.remark || '', + }; + }); }); }, loadCurrencyOptions() { @@ -3098,24 +3192,53 @@ export default { : []; const quantityUnits = []; this.transportCargoRows.forEach(row => { - const quantityUnit = row.quantityUnit || ''; + const quantityUnit = String(row.quantityUnit || '').trim(); + if (!quantityUnit) return; if (!quantityUnits.some(unit => unit === quantityUnit)) quantityUnits.push(quantityUnit); }); + const currency = this.shippingTemplateFreight.currency || 'RMB'; this.shippingTemplateFreight.freightItems = quantityUnits.map((quantityUnit, index) => { const matched = previousItems.find(item => String(item.quantityUnit || '') === String(quantityUnit)) || previousItems[index] || {}; + const defaultPriceUnit = resolvePriceUnitByQuantityUnit(quantityUnit, currency); + const priceUnit = this.normalizeFreightPriceUnit( + matched.priceUnit || defaultPriceUnit || '', + quantityUnit, + currency + ); return { quantityUnit, unitPrice: matched.unitPrice || '', - priceUnit: matched.priceUnit || '', + priceUnit, freightAmount: matched.freightAmount ?? '', freightAmountManual: matched.freightAmountManual === true || matched.manualFreightAmount === true, }; }); }, + normalizeFreightPriceUnit(priceUnit, quantityUnit, currency = 'RMB') { + const text = this.resolvePriceUnitText(priceUnit); + if (text && this.priceUnitMatchesQuantity(text, quantityUnit)) return text; + return ( + resolvePriceUnitByQuantityUnit(quantityUnit, currency) || + text || + String(priceUnit || '').trim() + ); + }, + resolvePriceUnitText(priceUnit) { + const raw = String(priceUnit || '').trim(); + if (!raw) return ''; + if (raw.includes('/')) return raw; + const options = this.feeUnitSelectOptions.length + ? this.feeUnitSelectOptions + : this.feeUnitOptions; + const matched = options.find( + item => String(item.value) === raw || String(item.label) === raw + ); + return matched?.label || raw; + }, quantityUnits() { return [ ...new Set( @@ -3131,7 +3254,7 @@ export default { .replace(/\s+/g, '') .replace(/立方米|立方公尺|方/g, '立方'); const unit = normalizeUnit(quantityUnit).replace(/[·*.]/g, ''); - const value = String(priceUnit || '') + const value = String(this.resolvePriceUnitText(priceUnit) || '') .trim() .toLowerCase() .replace(/\s+/g, ''); @@ -3163,6 +3286,10 @@ export default { return this.formatFreightNumber(item.freightAmount); } if (!this.isFreightAutoCalculable(item)) { + // 无法自动计算且未手工填写时返回空,避免把空值显示成 0 造成“已填写”的错觉。 + if (item.freightAmount === '' || item.freightAmount === null || item.freightAmount === undefined) { + return ''; + } return this.formatFreightNumber(item.freightAmount); } return this.formatFreightNumber( diff --git a/src/views/business/components/transport-plan-page.vue b/src/views/business/components/transport-plan-page.vue index 4d12b86..1eccc90 100644 --- a/src/views/business/components/transport-plan-page.vue +++ b/src/views/business/components/transport-plan-page.vue @@ -1677,7 +1677,7 @@ {{ isDispatchPlanQuantityUnlimited(row) ? '-' - : formatDispatchQuantity(dispatchItemRemainingQuantity(row)) + : formatDispatchQuantity(dispatchItemDisplayRemainingQuantity(row)) }}
@@ -1861,7 +1861,7 @@ /> - + @@ -1955,7 +1955,7 @@ class="transport-plan-page__dispatch-quantity-hint" > 剩余数量:{{ - formatDispatchQuantity(dispatchItemHintRemainingQuantity(dispatchItemForm)) + formatDispatchQuantity(dispatchItemDisplayRemainingQuantity(dispatchItemForm)) }} {{ dispatchItemForm.quantityUnit || '吨' }} @@ -2096,11 +2096,7 @@ /> - + - + { const opt = this.cloneOption(this.crudOption, estimateMenuButtonCount(this.config)); if (this.menuWidth != null) opt.menuWidth = this.menuWidth; - if ( - this.standaloneFormPage && - standaloneTransportPlanFormPaths.includes(this.$route.path) - ) { + // 仅表单页实例套用全屏表单弹窗样式,避免列表页误用后表格区域异常。 + const isFormInstance = Object.values(standaloneTransportPlanFormRoutes).includes( + this.$route.path + ); + if (this.standaloneFormPage && isFormInstance) { opt.dialogAppendToBody = false; opt.dialogModal = false; opt.dialogCustomClass = 'transport-plan-form-page-dialog'; @@ -3150,6 +3150,8 @@ export default { dispatchItemIndex: -1, dispatchItemForm: defaultDispatchRow(), dispatchItemCargoRows: [], + // 本次数量变更版本号:驱动同表其他行「剩余数量」同步重算 + dispatchCargoQuantityVersion: 0, dispatchItemAttachmentRows: [], selectedDispatchItemAttachmentRows: [], attachmentRows: [], @@ -3289,6 +3291,12 @@ export default { } this.loadTaskQuantityUnitOptions(); }, + // keep-alive 重新激活列表时强制刷新,避免缓存脏数据;标记位用于兼容旧跳转逻辑。 + activated() { + if (this.isFormPageInstance) return; + sessionStorage.removeItem(transportPlanListRefreshKey); + this.$nextTick(() => this.onLoad(this.page, this.query)); + }, computed: { ...mapGetters(['permission', 'userInfo']), permissionList() { @@ -3300,10 +3308,20 @@ export default { return this.config.detailSections || []; }, isStandaloneBusinessPage() { - return this.standaloneFormPage && standaloneTransportPlanFormPaths.includes(this.$route.path); + return ( + this.standaloneFormPage && + (this.isFormPageInstance || + standaloneTransportPlanFormPaths.includes(this.routePathLocked) || + standaloneTransportPlanFormPaths.includes(this.$route.path)) + ); }, isStandaloneFormPage() { - return this.isStandaloneBusinessPage && ['add', 'edit'].includes(this.$route.query.mode); + // 用实例锁定标志,避免列表 keep-alive 实例被全局路由误判成表单页。 + return ( + this.standaloneFormPage && + this.isFormPageInstance && + ['add', 'edit'].includes(this.$route.query.mode) + ); }, formPageTitle() { if (this.isStandaloneFormPage) { @@ -3752,6 +3770,18 @@ export default { }, watch: { $route(to, from) { + // 列表实例:从其他页进入列表时刷新(activated 负责标签切回)。 + if (!this.isFormPageInstance) { + const enteringList = + (to.path === this.routePathLocked || to.path === transportPlanListRoute) && + from?.path && + from.path !== to.path; + if (enteringList) { + sessionStorage.removeItem(transportPlanListRefreshKey); + this.$nextTick(() => this.onLoad(this.page, this.query)); + } + return; + } if (!this.isStandaloneFormPage) return; if ( to.query.mode !== from?.query?.mode || @@ -4484,6 +4514,9 @@ export default { loading(); } }, + markTransportPlanListNeedRefresh() { + sessionStorage.setItem(transportPlanListRefreshKey, '1'); + }, rowSave(row, done, loading) { const submitRow = this.normalizeRow(row); if (!submitRow) { @@ -4492,6 +4525,7 @@ export default { } this.getSaveRequest()(submitRow).then( () => { + this.markTransportPlanListNeedRefresh(); this.onLoad(this.page); this.$message({ type: 'success', message: '操作成功!' }); done(); @@ -4511,6 +4545,7 @@ export default { } this.getSaveRequest()(submitRow).then( () => { + this.markTransportPlanListNeedRefresh(); this.onLoad(this.page); this.$message({ type: 'success', message: '操作成功!' }); done(); @@ -4540,7 +4575,7 @@ export default { .submit(submitRow) .then(() => { this.$message.success(action === 'draft' ? '暂存成功' : '创建成功'); - this.onLoad(this.page, this.query); + this.markTransportPlanListNeedRefresh(); this.closeCrudDialog(); }) .finally(() => { @@ -4548,12 +4583,14 @@ export default { }); }, closeCrudDialog() { - if (this.isStandaloneBusinessPage) { + if (this.isStandaloneBusinessPage || this.isFormPageInstance) { + // 关闭独立表单页时标记列表需刷新(含直接点关闭未保存的场景) + this.markTransportPlanListNeedRefresh(); this.$router.$avueRouter?.closeTag?.(); const listPath = Object.entries(standaloneTransportPlanFormRoutes).find( - ([, formPath]) => formPath === this.$route.path - )?.[0] || this.$route.path; + ([, formPath]) => formPath === this.$route.path || formPath === this.routePathLocked + )?.[0] || transportPlanListRoute; this.$router.push({ path: listPath, query: {} }); return; } @@ -6634,20 +6671,33 @@ export default { return query; }, onLoad(page, params = {}) { + if (this.isFormPageInstance) return; this.loading = true; + const currentPage = page?.currentPage || this.page.currentPage || 1; + const pageSize = page?.pageSize || this.page.pageSize || 10; const query = this.normalizeQueryParams({ ...params, ...this.query, allDept: 0, }); this.api - .getList(page.currentPage, page.pageSize, query) + .getList(currentPage, pageSize, query) .then(res => { - const result = res.data.data; - this.page.total = result.total; - this.data = result.records; + const result = res?.data?.data || res?.data || {}; + const records = Array.isArray(result.records) + ? result.records + : Array.isArray(result) + ? result + : []; + this.page.total = Number(result.total ?? records.length) || 0; + this.data = records; this.selectionClear(); }) + .catch(error => { + window.console.error(error); + this.data = []; + this.page.total = 0; + }) .finally(() => { this.loading = false; }); @@ -7652,6 +7702,7 @@ export default { row.cargoName = ''; row.specification = ''; row.model = ''; + this.dispatchCargoQuantityVersion += 1; }, handleDispatchConfiguredCargoNameChange(row, value) { const nextValue = String(value ?? row.cargoName ?? '').trim(); @@ -7660,6 +7711,7 @@ export default { ); if (!option) { row.cargoName = nextValue; + this.dispatchCargoQuantityVersion += 1; return; } const previousType = String(row.cargoType || '').trim(); @@ -7674,6 +7726,7 @@ export default { } row.specification = option.specification || row.specification || ''; row.model = option.model || row.model || ''; + this.dispatchCargoQuantityVersion += 1; }, openDispatchItemDialog(index = -1, row = defaultDispatchRow()) { const defaultSummaryItem = @@ -8002,6 +8055,7 @@ export default { const parts = text.split('.'); row.quantity = parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 3)}` : parts[0]; + this.dispatchCargoQuantityVersion += 1; }, handleDispatchCargoNumberInput(row, prop, value) { this.handleFreightNumberInput(row, prop, value); @@ -8021,12 +8075,14 @@ export default { const nextRow = this.normalizeTransportCargoRow({ priceUnit: this.defaultPriceUnit }); if (index > -1) this.dispatchItemCargoRows.splice(index + 1, 0, nextRow); else this.dispatchItemCargoRows.push(nextRow); + this.dispatchCargoQuantityVersion += 1; }, removeDispatchCargoRow(index) { this.dispatchItemCargoRows.splice(index, 1); if (!this.dispatchItemCargoRows.length) { this.dispatchItemCargoRows.push(this.normalizeTransportCargoRow()); } + this.dispatchCargoQuantityVersion += 1; }, dispatchCargoFreightAmount(cargo = {}) { if (cargo.freightAmount !== undefined && cargo.freightAmount !== null) { @@ -8176,11 +8232,50 @@ export default { }, 0) ); }, - /** 精简录入提示:可调度余量扣减当前本次数量后的剩余 */ - dispatchItemHintRemainingQuantity(row = {}) { + /** + * 当前编辑表单中,与指定行同货物(有身份)或同单位(无身份)已占用的本次数量合计。 + * 用于剩余数量展示联动,不参与提交校验(校验仍按整单合计对比计划余量)。 + */ + getDispatchFormOccupiedQuantity(row = {}) { + const goodsRows = + this.dispatchItemForm?.taskEntryMode === 'full' + ? this.dispatchItemCargoRows || [] + : [this.dispatchItemForm || row]; + const hasCargoIdentity = Boolean( + String(row.cargoName || row.goodsName || row.name || '').trim() || + String(row.cargoType || row.secondCargoTypeName || row.goodsType || row.type || '').trim() + ); + const unit = this.getDispatchQuantityUnit(row); + const cargoKey = this.getDispatchCargoIdentity(row); + return goodsRows.reduce((sum, goods) => { + if (!goods) return sum; + if (hasCargoIdentity) { + const goodsHasIdentity = Boolean( + String(goods.cargoName || goods.goodsName || goods.name || '').trim() || + String( + goods.cargoType || goods.secondCargoTypeName || goods.goodsType || goods.type || '' + ).trim() + ); + if (!goodsHasIdentity || this.getDispatchCargoIdentity(goods) !== cargoKey) { + return sum; + } + } else if (this.getDispatchQuantityUnit(goods) !== unit) { + return sum; + } + return sum + this.parseDispatchQuantity(goods.quantity); + }, 0); + }, + /** 展示用剩余数量:计划可调度余量扣减本单已填本次数量(含当前行) */ + dispatchItemDisplayRemainingQuantity(row = {}) { + // 依赖版本号,确保一行本次数量变化后其他行剩余数量一并刷新 + void this.dispatchCargoQuantityVersion; const available = this.dispatchItemRemainingQuantity(row); - const current = this.parseDispatchQuantity(row.quantity); - return Math.max(available - current, 0); + const occupied = this.getDispatchFormOccupiedQuantity(row); + return Math.max(available - occupied, 0); + }, + /** 精简录入提示:与完整录入剩余数量展示规则一致 */ + dispatchItemHintRemainingQuantity(row = {}) { + return this.dispatchItemDisplayRemainingQuantity(row); }, pruneDispatchPendingRows(rows = []) { const planQuantities = new Map(); @@ -8278,13 +8373,17 @@ export default { const fullEntry = row.taskEntryMode === 'full'; const roadTransport = this.resolveTransportMode(row.transportType) === 'road'; if (fullEntry) { - if (roadTransport && row.carrierType === '自运') { + // 公路运输时司机/手机号一律必填(含承运类型为承运商) + if (roadTransport && row.carrierType) { requiredFields.push(['driverName', '司机']); requiredFields.push(['driverPhone', '手机号']); } - } else if (row.carrierType && row.carrierType !== '承运商') { + } else if (row.carrierType) { requiredFields.push(['driverName', '司机']); - requiredFields.push(['driverPhone', '司机手机号']); + requiredFields.push([ + 'driverPhone', + row.carrierType === '承运商' ? '手机号' : '司机手机号', + ]); } const emptyField = requiredFields.find(([prop]) => this.isBillingFieldEmpty(row[prop])); if (emptyField) return requiredMessage(emptyField[1]); diff --git a/src/views/business/components/waybill-import-dialog.vue b/src/views/business/components/waybill-import-dialog.vue index 309467e..94972e9 100644 --- a/src/views/business/components/waybill-import-dialog.vue +++ b/src/views/business/components/waybill-import-dialog.vue @@ -96,22 +96,12 @@ prop="carrierName" label="承运商" min-width="140" - /> + 编辑 删除 { if (carrierValue) carrierChange(carrierValue); await loadBatchRows(snapshot.id); }; -/** 与行操作一致:仅草稿可删除 */ -const canRemoveBatch = row => String(row?.importStatus || '') === 'draft'; -const removeBatches = () => { +/** 与后端一致:仅未结算批次可整批删除 */ +const canRemoveBatch = row => String(row?.settlementStatus || 'unsettled') === 'unsettled'; +const removeBatches = async () => { if (!selected.value.length) { ElMessage.warning('请选择至少一条数据'); return; } - // 选中含不可删条目时整批拦截(含已完成/进行中),不静默跳过 + // 选中含部分结算/已结算时整批拦截,不静默跳过 if (selected.value.some(row => !canRemoveBatch(row))) { - ElMessage.warning('已完成运单不可删除'); + ElMessage.warning('仅未结算的运单批次可以删除'); return; } - return api.removeImportBatches(selected.value.map(item => item.id).join(',')).then(loadBatches); + try { + await ElMessageBox.confirm('确认删除选中的运单批次?删除后不可恢复。', '提示', { + type: 'warning', + }); + } catch { + return; + } + await api.removeImportBatches(selected.value.map(item => item.id).join(',')); + ElMessage.success('删除成功'); + return loadBatches(); }; -const removeBatch = row => { +const removeBatch = async row => { if (!canRemoveBatch(row)) { - ElMessage.warning('已完成运单不可删除'); + ElMessage.warning('仅未结算的运单批次可以删除'); return; } - return api.removeImportBatches(row.id).then(loadBatches); + try { + await ElMessageBox.confirm( + `确认删除运单批次「${row.batchNo || ''}」?删除后不可恢复。`, + '提示', + { type: 'warning' } + ); + } catch { + return; + } + await api.removeImportBatches(row.id); + ElMessage.success('删除成功'); + return loadBatches(); }; const projectChange = async id => { const requestId = ++projectRequestId; diff --git a/src/views/business/contract-manage.vue b/src/views/business/contract-manage.vue index 8076697..158b9cb 100644 --- a/src/views/business/contract-manage.vue +++ b/src/views/business/contract-manage.vue @@ -380,17 +380,15 @@ /> - + @@ -1268,7 +1266,23 @@ export default { { label: '已到期', value: 'expired', statKey: 'expired' }, ], form: defaultForm(), + contractNoManual: false, formRules: { + contractNo: [ + { + validator: (rule, value, callback) => { + const contractNo = String(value || '').trim(); + if (this.formMode === 'edit' && !contractNo) { + return callback(new Error('请输入合同编号')); + } + if (contractNo.length > 100) { + return callback(new Error('合同编号不能超过100个字符')); + } + callback(); + }, + trigger: 'blur', + }, + ], contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }], contractCategory: [{ required: true, message: '请选择合同类别', trigger: 'change' }], signType: [{ required: true, message: '请选择签约类型', trigger: 'change' }], @@ -1782,6 +1796,7 @@ export default { this.api.getDetail(id).then(res => this.applyDetail(res.data?.data || {})); }, resetFormState() { + this.contractNoManual = false; this.form = { ...defaultForm(), handlerUserName: @@ -1857,7 +1872,20 @@ export default { this.$router.$avueRouter?.closeTag?.(); this.$router.push(contractManageListRoute); }, + validateContractNo() { + const contractNo = String(this.form.contractNo || '').trim(); + if (this.formMode === 'edit' && !contractNo) { + this.$message.warning('请输入合同编号'); + return false; + } + if (contractNo.length > 100) { + this.$message.warning('合同编号不能超过100个字符'); + return false; + } + return true; + }, validateBase(action) { + if (!this.validateContractNo()) return false; if (action === 'temporary') { const required = [ ['signType', '签约类型'], @@ -1943,6 +1971,7 @@ export default { const projectName = projectId ? normalizeProjectValue(this.form.projectName) : ''; return { ...this.form, + contractNo: String(this.form.contractNo || '').trim(), projectId, projectName, copyCount: normalizeOptionalInteger(this.form.copyCount), @@ -2314,11 +2343,14 @@ export default { this.refreshPreviewContractNo(); } }, + handleContractNoInput(value) { + this.contractNoManual = String(value || '').trim() !== ''; + }, refreshPreviewContractNo() { if (this.formMode !== 'add') return; const organizationId = this.form.organizationId; if (!organizationId) { - this.form.contractNo = ''; + if (!this.contractNoManual) this.form.contractNo = ''; return; } const requestId = (this.contractNoPreviewRequestId = @@ -2326,11 +2358,11 @@ export default { api .nextContractNo(organizationId) .then(res => { - if (requestId !== this.contractNoPreviewRequestId) return; + if (requestId !== this.contractNoPreviewRequestId || this.contractNoManual) return; this.form.contractNo = res.data?.data || ''; }) .catch(() => { - if (requestId !== this.contractNoPreviewRequestId) return; + if (requestId !== this.contractNoPreviewRequestId || this.contractNoManual) return; this.form.contractNo = ''; }); }, @@ -2763,6 +2795,7 @@ export default { }, getContractChangeFieldLabel(field) { const labels = { + contractNo: '合同编号', contractName: '合同名称', partyB: '乙方', startDate: '开始日期', diff --git a/src/views/business/transport-plan-import.vue b/src/views/business/transport-plan-import.vue index 4944c48..f49195a 100644 --- a/src/views/business/transport-plan-import.vue +++ b/src/views/business/transport-plan-import.vue @@ -602,6 +602,8 @@ export default { if (result.code !== 200) throw new Error(result.msg || '导入失败'); this.$message.success('导入完成'); } + // 与运单/配载一致:通知 keep-alive 列表实例重新拉取 + sessionStorage.setItem('transport-plan-list-refresh', '1'); this.$router.push('/business/transport-plan'); } catch (error) { this.$message.error(error.message || '导入失败'); diff --git a/src/views/payment/bill-ledger-form.vue b/src/views/payment/bill-ledger-form.vue index 2c511d4..ccc8364 100644 --- a/src/views/payment/bill-ledger-form.vue +++ b/src/views/payment/bill-ledger-form.vue @@ -44,7 +44,8 @@ reserve-keyword :remote-method="searchCustomers" :loading="customerLoading" - placeholder="请选择客商基础档案" + placeholder="请选择" + @change="syncFeeBearer" > - + - @@ -140,17 +140,9 @@ - + ({ billNo: '', issuerId: null, receiverName: '', - billType: 'issued', + billType: '', faceAmount: null, availableBalance: 0, issueDate: '', maturityDate: '', - availableDeptIds: ['all'], - availableDeptNames: '全部', + availableDeptIds: [], + availableDeptNames: '', feeBearerId: null, confirmedDiscountRate: null, issuingBank: '', @@ -272,9 +264,17 @@ export default { receiverOptions: [], customerLoading: false, saving: false, - deptTree: [], + businessDeptTreeOptions: [], deptMap: new Map(), - deptProps: { label: 'title', value: 'id', children: 'children' }, + deptCascaderProps: { + label: 'label', + value: 'value', + children: 'children', + multiple: true, + checkStrictly: true, + emitPath: true, + expandTrigger: 'click', + }, bankOptions: [ '中国工商银行', '中国农业银行', @@ -309,7 +309,10 @@ export default { issuerId: [{ required: true, message: '请选择出票单位', trigger: 'change' }], receiverName: [{ required: true, message: '请选择或输入收票单位', trigger: 'change' }], billType: [{ required: true, message: '请选择汇票类型', trigger: 'change' }], - faceAmount: [{ validator: this.validateFaceAmount, trigger: 'change' }], + faceAmount: [ + { required: true, message: '请输入票面金额', trigger: 'change' }, + { validator: this.validateFaceAmount, trigger: 'change' }, + ], issueDate: [{ required: true, message: '请选择出票日期', trigger: 'change' }], maturityDate: [{ required: true, validator: this.validateMaturityDate, trigger: 'change' }], availableDeptIds: [{ validator: this.validateDepartments, trigger: 'change' }], @@ -340,6 +343,42 @@ export default { (Number(this.form.faceAmount || 0) * Number(this.form.confirmedDiscountRate || 0)) / 100 ); }, + // 可用部门:表单存部门 id,级联多选回显用根到节点的路径 + availableDeptCascaderValue: { + get() { + return (this.form.availableDeptIds || []) + .filter(id => id !== undefined && id !== null && String(id) !== 'all' && String(id) !== '') + .map(id => this.findDeptPath(id, this.businessDeptTreeOptions) || [String(id)]); + }, + set(paths) { + const ids = [ + ...new Set( + (paths || []) + .map(path => { + const nodes = Array.isArray(path) ? path.filter(item => item !== undefined && item !== null && item !== '') : []; + return nodes.length ? String(nodes.at(-1)) : ''; + }) + .filter(Boolean) + ), + ]; + this.form.availableDeptIds = ids; + this.form.availableDeptNames = ids + .map(id => this.deptMap.get(String(id))) + .filter(Boolean) + .join('、'); + }, + }, + feeBearerOptions() { + const issuer = this.customerOptions.find(item => String(item.id) === String(this.form.issuerId)); + const receiverName = String(this.form.receiverName || '').trim(); + const receiver = receiverName + ? this.customerOptions.find(item => this.customerName(item) === receiverName) + : null; + return [issuer, receiver].filter( + (item, index, rows) => + item && rows.findIndex(row => row && String(row.id) === String(item.id)) === index + ); + }, }, created() { this.initialize(); @@ -352,7 +391,6 @@ export default { async initialize() { await Promise.all([this.loadDepartments(), this.searchCustomers('')]); if (!this.recordId) { - this.form.issueDate = this.$dayjs().format('YYYY-MM-DD'); return; } const data = this.unwrapData(await api.getDetail(this.recordId)); @@ -361,14 +399,20 @@ export default { ...data, confirmedDiscountRate: this.normalizeDiscountRate(data.confirmedDiscountRate), bankDiscountReferenceRate: this.normalizeDiscountRate(data.bankDiscountReferenceRate), - availableDeptIds: this.parse(data.availableDeptIdsJson), + availableDeptIds: this.parse(data.availableDeptIdsJson) + .filter(id => String(id) !== 'all') + .map(id => String(id)), attachments: this.parse(data.attachmentsJson), }; this.usageRecords = data.usageRecords || []; this.receiverOptions = data.receiverName ? [data.receiverName] : []; this.ensureCustomerOption(data.issuerId, data.issuerName); this.ensureCustomerOption(data.feeBearerId, data.feeBearerName); - this.handleDeptChange(this.form.availableDeptIds); + this.form.availableDeptNames = (this.form.availableDeptIds || []) + .map(id => this.deptMap.get(String(id))) + .filter(Boolean) + .join('、'); + this.syncFeeBearer(); }, parse(value) { if (!value) return []; @@ -381,22 +425,52 @@ export default { }, async loadDepartments() { const response = await getDeptTree(this.userInfo?.tenantId); - const rows = this.unwrapData(response) || []; - this.deptTree = [{ id: 'all', title: '全部', children: rows }]; - this.deptMap = new Map([['all', '全部']]); - this.flattenDepartments(rows).forEach(item => this.deptMap.set(String(item.id), item.title)); + const rows = this.excludeExternalOrganization(this.unwrapData(response) || []); + this.businessDeptTreeOptions = this.toDeptCascaderOptions(rows); + this.deptMap = new Map(); + this.flattenDepartments(rows).forEach(item => + this.deptMap.set(String(item.id), item.title || item.deptName || item.name) + ); + }, + excludeExternalOrganization(list = []) { + return list.reduce((result, item) => { + const deptName = item.title || item.deptName || item.name; + if (String(deptName || '').trim() === '外部组织') return result; + result.push({ + ...item, + children: item.children?.length + ? this.excludeExternalOrganization(item.children) + : item.children, + }); + return result; + }, []); + }, + toDeptCascaderOptions(list = []) { + return list.map(item => ({ + label: item.title || item.deptName || item.name, + value: item.id == null || item.id === '' ? '' : String(item.id), + children: item.children?.length ? this.toDeptCascaderOptions(item.children) : undefined, + })); }, flattenDepartments(rows) { return (rows || []).flatMap(item => [item, ...this.flattenDepartments(item.children || [])]); }, - handleDeptChange(values) { - const ids = values || []; - this.form.availableDeptNames = ids.some(item => String(item) === 'all') - ? '全部' - : ids - .map(item => this.deptMap.get(String(item))) - .filter(Boolean) - .join('、'); + findDeptPath(id, tree = [], parents = []) { + const target = Array.isArray(id) ? id.at(-1) : id; + if (target === undefined || target === null || target === '') return null; + for (const node of tree || []) { + const path = [...parents, node.value]; + if (String(node.value) === String(target)) return path; + const matched = this.findDeptPath(target, node.children, path); + if (matched) return matched; + } + return null; + }, + syncFeeBearer() { + const allowed = this.feeBearerOptions.map(item => String(item.id)); + if (this.form.feeBearerId && !allowed.includes(String(this.form.feeBearerId))) { + this.form.feeBearerId = null; + } }, async searchCustomers(keyword) { this.customerLoading = true; @@ -545,7 +619,7 @@ export default { .bill-ledger-form-page__form :deep(.el-select), .bill-ledger-form-page__form :deep(.el-input-number), .bill-ledger-form-page__form :deep(.el-date-editor), -.bill-ledger-form-page__form :deep(.el-tree-select) { +.bill-ledger-form-page__form :deep(.el-cascader) { width: 100%; } .bill-ledger-form-page__form :deep(.el-input-number .el-input__inner) { diff --git a/src/views/payment/invoice-receipt-form.vue b/src/views/payment/invoice-receipt-form.vue index 200e758..b798e98 100644 --- a/src/views/payment/invoice-receipt-form.vue +++ b/src/views/payment/invoice-receipt-form.vue @@ -16,13 +16,22 @@ - - - + +
+ + + +
+
@@ -32,7 +41,16 @@ - + +
+ +
+
@@ -64,23 +82,54 @@ - + +
+ +
+
- + +
+ +
+
- - - + +
+ + + +
+
@@ -89,7 +138,12 @@ - +