Compare commits

..

100 Commits

Author SHA1 Message Date
b2894lxlx 8db52ef168 🐛 修复变更审批通过后列表不显示再次变更入口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:03:26 +08:00
b2894lxlx 38ca250242 ✨ 项目变更可编辑业务部门/平台公司,组织名称列左对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:01:27 +08:00
b2894lxlx f4542cd0b8 ⚡ 组织同步按公司刷新树并加宽组织名称列
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 16:22:39 +08:00
b2894lxlx d1e779bbcc ⚡ 优化组织全量同步流程,部门按公司轮询并去掉确认弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 15:35:20 +08:00
b2894lxlx acfbb4c54e 🐛 修复运输对账匹配日期格式与完成对账疑似重复误判
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 08:31:27 +08:00
b2894lxlx 96b675b3be Merge remote-tracking branch 'websoft/master' 2026-10-16 16:05:55 +08:00
b2894lxlx 6067d24e3b 🐛 修复列表二次搜索与结算导入回列表,并完善付款申请多结算单展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 16:03:26 +08:00
liuquanjia e051327f2d Merge pull request 'fix: 批量认领补充剩余可认领金额展示与流水维度分摊上限' (#5) from feature/bfi-api-rate into master
Reviewed-on: #5
2026-10-16 15:34:35 +08:00
刘泉佳 f7759fd9e7 fix: 批量认领补充剩余可认领金额展示与流水维度分摊上限 2026-10-16 15:26:56 +08:00
b2894lxlx 4287133f84 Merge remote-tracking branch 'websoft/dev' 2026-10-16 11:48:10 +08:00
b2894lxlx 4b01316f64 ✨ 新增项目预付核销 UI,并修复常用线路新增不显示与付款提交后刷新
结算单附件增加项目预付核销;常用线路超管默认查全部组织并按筛选归属新增;付款申请提交后关页并刷新列表;平台公司选项 ID 按字符串绑定。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 02:02:00 +08:00
b2894lxlx 0b170fe5c1 Merge remote-tracking branch 'websoft/master' 2026-10-16 00:57:59 +08:00
b2894lxlx 4f29b1e0eb Merge pull request '客商审核通过后推送金蝶失败原因以警告提示展示、以及 付款申请/开票申请/正式结算单列表展示金蝶解锁终态标识' (#4) from feature/bfi-api-rate into master
Reviewed-on: #4
2026-10-16 00:57:51 +08:00
b2894lxlx 6db599400e Merge remote-tracking branch 'websoft/master' 2026-10-16 00:57:19 +08:00
b2894lxlx c5a74acd00 ✨ 新增导入结算单页面,并优化 MK 开关缓存与客商同步入口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 00:56:55 +08:00
刘泉佳 f23d65e41e :fix: 客商审核通过后推送金蝶失败原因以警告提示展示 2026-10-15 21:14:06 +08:00
weicw-dshsa 6b0cb37699 ✨ 结算明细的单据号、运单号支持跳转应收应付明细与运单详情 2026-10-15 20:41:20 +08:00
weicw-dshsa c867c5d7a5 ⚡ 新增预结算与正式结算合同币种为人民币时隐藏汇率日期、结算汇率与本位币合计 2026-10-15 20:12:05 +08:00
weicw-dshsa 903e0a4d7b 📝 自检端口修正为 2888 2026-10-15 18:55:46 +08:00
weicw-dshsa 6461c3d80f 💄 正式结算单号改为蓝色显示 2026-10-15 18:04:56 +08:00
weicw-dshsa 8380351529 🐛 修复新增预结算页收付方、创建信息与预结算单号为空 2026-10-15 16:56:52 +08:00
weicw-dshsa f8a238d0c0 ⚡ 批量转结算弹窗去掉预结算单号与正式结算单号列 2026-10-15 15:32:06 +08:00
weicw-dshsa 5c6fcbe951 ⚡ 维修记录里程单位改由车船类型自动推导,表单去掉里程单位项
- 里程/航程数标签去掉「(公里)」,表头不再需要 renderHeader 覆盖
- 里程单位列不再出现在表单(display: false),无需人工选择
- 单位仍按车船类型推导:车辆=公里、船舶=海里,保存时再兜底推导一次
2026-10-15 15:14:03 +08:00
weicw-dshsa 7997089b16 🐛 总单列表悬浮气泡展示完整地址、创建时间只保留日期 2026-10-15 14:45:00 +08:00
gxwebsoft 089b74479b Merge remote-tracking branch 'origin/master' 2026-10-15 14:24:34 +08:00
gxwebsoft cf542313f7 style(contract-manage): 注释掉表单项底部外边距
- 将.el-form-item的margin-bottom属性注释,调整样式间距
- 保持对齐方式不变以确保布局稳定
- 便于后续样式调整和优化维护
2026-10-15 14:24:31 +08:00
b2894lxlx a19097d1be ✨ 组织同步文案与客商固定组织对齐白名单规则
更新部门全量同步说明为 id=1 与二级公司白名单,客商所属组织改为广西现代物流集团有限公司。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 12:47:23 +08:00
gxwebsoft 87aa64d0c1 fix(payment): 修复付款申请表单结算单号字段交互体验
- 结算单号由原来的 `el-select multiple` 改为不可编辑的 `el-input readonly`
- 点击输入框触发弹窗选择,保留右侧「选择」按钮
- 占位符改为「请输入」,纠正不可输入与提示不符的问题
- 根据已选单号数量动态显示单号或“已选 N 张结算单”,多张显示全称放 tooltip 提示
- 删除无用的选项选择及相关方法,简化组件逻辑和样式
- 调整样式避免宽度不足导致数据显示被截断问题
- 临时验证页面引入完整 Vue 编译版本确保字符串模板支持
- 更新依赖锁中的 TypeScript 版本等补丁信息
2026-10-15 11:33:10 +08:00
刘泉佳 fbad28b717 feat: 付款申请/开票申请/正式结算单列表展示金蝶解锁终态标识
- lockTag 增加解锁终态分支:金蝶删单红tag、金蝶解锁橙tag,tooltip 展示操作人与解锁时间
- 数据来自后端 fillUnlockMarks 回填的 kingdeeUnlockTime/kingdeeUnlockOperator/kingdeeUnlockOperationName
2026-10-15 11:29:29 +08:00
gxwebsoft 19972aa8d8 🐛 付款申请表单结算单号改为只读输入框+整框可点开选择弹窗,消除请输入歧义 2026-10-15 10:50:25 +08:00
b2894lxlx 5569f373c5 Merge pull request 'Tms对接金蝶接口-前端' (#3) from feature/bfi-api-rate into master
Reviewed-on: #3
2026-10-14 17:28:16 +08:00
刘泉佳 7887ae2342 Merge remote-tracking branch 'origin/master' into feature/bfi-api-rate
# Conflicts:
#	src/views/payment/receipt-flow.vue
2026-10-14 17:18:42 +08:00
gxwebsoft 9919fb9c89 style(settlement): 优化预结算单选择弹窗及支付申请表样式
- 在预结算单选择弹窗中添加el-card包裹form表单,提升视觉层次
- 修改支付申请表结算单选择的placeholder文本为“请输入”
- 使用el-card包裹支付申请表中的参考搜索区块,增强样式一致性
- 调整表单验证提示文本,将“请选择结算单”改为“请输入”
2026-10-14 16:53:20 +08:00
gxwebsoft 6eab96b620 style(settlement): 优化预结算单选择弹窗及支付申请表样式
- 在预结算单选择弹窗中添加el-card包裹form表单,提升视觉层次
- 修改支付申请表结算单选择的placeholder文本为“请输入”
- 使用el-card包裹支付申请表中的参考搜索区块,增强样式一致性
- 调整表单验证提示文本,将“请选择结算单”改为“请输入”
2026-10-14 16:53:13 +08:00
gxwebsoft b39501291c refactor(payment): 统一搜索栏“展开/收起”按钮实现
- 取消 el-tooltip 包裹,改用纯 el-button 实现带图标的展开收起按钮
- 按 avue-crud 规范控制按钮显示,新增 searchIndex (默认 4)和 searchItemCount 控制逻辑
- 搜索项数量未超出 searchIndex 时隐藏展开收起按钮
- 相关组件支付模块多处应用此统一方案(bill-ledger-search、bill-payment-search、invoice-application、invoice-receipt、receipt-claim-record、receipt-flow)
- 维护展开状态对应的箭头图标和文案为“收起”与“展开”

refactor(business): 优化路线变更内容渲染与样式

- 替换旧的 formatRouteChangeContent 为更详细的 parseRouteChangeContent,支持多分组与字段显示
- 路线变更内容支持多层次分组、字段标签、改变前后值对比及未变更标识展示
- 取消全局列溢出提示以避免多行内容被截断,针对路线变更内容列显式禁用溢出提示并设置白色换行样式
- 在 business-crud-page、waybill-manage-page、loading-manage 相关页面和组件中统一应用该解析与渲染方案

refactor(option): 调整 measurement-unit 配置以提升表格列展示

- 移除 menuWidth 配置,采用 menuFixed 固定右侧模式
- 为创建人和更新人列添加 minWidth 110,时间列添加 width 180,避免内容被截断
- 表格列数组顺序调整,确保搜索与展示保持一致

refactor(payment & receipt-flow): 统一支付及回执模块工具栏和表格行为

- 按照统一规范和用户反馈,调整多处页面的搜索栏操作区和表格列配置
- 引入 TableHeaderTools 组件和相关状态传递,实现搜索显隐与表格尺寸切换统一
- 优化 avue-crud 表格组件的交互一致性和视觉表现

refactor(utils): 重构路线变更内容解析函数

- 新增 parseRouteChangeContent 以支持复杂路线变更文本的解析与分组
- 识别运单号、字段标签、变更前后值,支持多种箭头符号及调整描述
- 用法推广至相关业务页面,支持更加直观的路线变更展示界面
2026-10-14 16:21:50 +08:00
刘泉佳 9315431273 🔧 构建跳过 @napi-rs 原生模块修复 build,开发代理切回本地网关 2026-10-14 15:59:49 +08:00
刘泉佳 2c9ce33c10 🐛 补复合并遗漏的金蝶锁状态与附件同步逻辑,修复认领记录页残留重复表格标签 2026-10-14 15:58:57 +08:00
刘泉佳 85602ff98f Merge branch 'master' into feature/bfi-api-rate
# Conflicts:
#	src/components/vehicle-attachment-table/main.vue
#	src/option/payment/paymentApplication.js
#	src/views/payment/payment-application.vue
#	src/views/payment/receipt-claim-record.vue
#	src/views/vehicle/customer-archive.vue
2026-10-14 14:41:40 +08:00
gxwebsoft 749b314ceb 🐛 补充提交遗漏的 region-address 与 route-change 工具文件 2026-10-14 13:06:45 +08:00
b2894lxlx 74780b8777 ✨ 正式结算保存草稿与同步入口,项目申请平台公司多选等业务优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 12:59:27 +08:00
gxwebsoft 5bc1f69a7f Merge remote-tracking branch 'origin/master' 2026-10-14 12:45:02 +08:00
gxwebsoft 6de87ed4e2 style(contract): 优化合同管理页面表单样式与布局
- 调整合同管理备注输入框行数和最大长度
- 细化合同变更页面表单项间距和对齐方式
- 统一弹窗表单项的底部间距样式,改为注释禁用
- 调整 port-terminal 和 region 组件的表单项底部间距
- 修改 vite 配置中 API 请求代理地址
- 优化凭证执行详情搜索表单列宽布局
- 在运单管理页面更新型号字段为非必填,调整变更记录内容格式化显示
- 引入并使用 formatRouteChangeContent 工具函数
- 统一明细按钮显示逻辑,草稿状态也显示详情按钮
- 新增路由变更相关工具函数与样式支持
2026-10-14 12:44:56 +08:00
b2894lxlx 18aeb08c90 🐛 修复变更记录按提交批次汇总,临时额度-1显示为-
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:58:42 +08:00
b2894lxlx cdd30b8211 ✨ 机构同步编码校验对齐GWWL三位数字规则
组织编码校验改为上级直拼三位数字;全量同步清除提示同步保留外部组织与集团锚点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:45:01 +08:00
gxwebsoft 6762cf7b83 Merge remote-tracking branch 'origin/master' 2026-10-14 10:02:17 +08:00
gxwebsoft a87b5e4b95 fix(styles): 修正车辆调度弹窗表单标签与控件宽度不一致问题
- 修改选择器为后代选择器以适配 avue 表单项的嵌套结构
- 统一弹窗内表单标签宽度,避免 labelWidth: 'auto' 导致控件宽度不一
- 新增全局样式规则,固定标签宽度为 calc(4em + 24px)
- 删除用于调试控件宽度的临时文件 cascader-width-check.html
- 记录并说明 avue 弹窗宽度调整相关坑点和解决方案
2026-10-14 10:02:12 +08:00
b2894lxlx 1181aea3b9 🐛 凭证分片遇 HTTP 预签名时回退服务端代传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:44:17 +08:00
gxwebsoft 68f4b2c35e style(transportCapacity): 调整车辆调度弹窗部门级联选择器宽度
- 注释掉多个页面及组件中el-form-item的margin-bottom样式
- 增加车辆调度弹窗配置dialogCustomClass: 'vehicle-dispatch-dialog'
- 在全局样式element-ui.scss中为.vehicle-dispatch-dialog内el-cascader设置宽度100%
- 解决avue-crud弹窗append-to-body导致页面容器scoped样式无法命中问题
- 更新vite配置,将开发服务器端口修改为2889,代理目标指向远程地址
- 补充工程文档说明和样式调整原因分析
2026-10-13 20:21:17 +08:00
b2894lxlx 6296c8f6f4 ⚡ 配载货物信息分列数量单位,并修复车辆尺寸 -1 展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:55:15 +08:00
b2894lxlx 6955784e93 ⚡ 优化表格工具栏、附件文件名展示与合同金额两位小数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:44:40 +08:00
b2894lxlx bc7755a160 ✨ 小程序用户支持重置密码,列表隐藏 openid
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:47:28 +08:00
gxwebsoft 18a02a1969 Merge remote-tracking branch 'origin/master' 2026-10-12 14:20:26 +08:00
gxwebsoft a4c2987c5f style(table): 全站文件名列添加省略号与溢出隐藏
- 统一给文件名相关的 el-table-column 添加 class-name="is-ellipsis-link"
- 解决了 el-link 为 flex 项导致列宽撑破的问题
- 在样式中新增 is-ellipsis-link 规则,实现文本溢出省略号效果
- 保证超长文件名不会破坏表格布局且能完整展示链接提示
- 适用于业务多个组件中涉及附件、文件名等长文本列的处理
2026-10-12 12:12:24 +08:00
刘泉佳 d8f4f22b9c Merge branch 'feature/payment-apply-bfi-autoSync' into feature/bfi-api-rate 2026-10-12 10:54:57 +08:00
刘泉佳 b077ba5be4 fix: 审批通过提示改用后端返回文案,金蝶自动同步失败弹警告 2026-10-11 14:35:57 +08:00
刘泉佳 b9b579dc14 feat: 付款申请列表新增金蝶锁状态标识并拦截锁定态作废入口 2026-10-10 16:00:32 +08:00
刘泉佳 6ae23bb959 feat: 结算单/开票申请列表新增金蝶锁状态标识与失败原因提示 2026-10-10 15:19:10 +08:00
刘泉佳 4294c92e43 fix: 金蝶票池隐藏金蝶单据号 2026-10-08 14:22:28 +08:00
刘泉佳 b840530fe9 fix: 选票客商下拉改取客商发票信息抬头/税号(票池税号是开票税号,非统一信用代码) 2026-10-07 19:24:06 +08:00
刘泉佳 e6a1370de2 feat: 选票弹窗销/购方条件改客商选择器——按名称远程搜客商档案带出税号过滤,粘贴裸税号直查 2026-10-07 17:55:38 +08:00
刘泉佳 65296618a7 feat: 金蝶票池选票弹窗回切本地镜像标准分页(total跳页+名称模糊+认领登记三态过滤,下线游标翻页;附件查看下载与可用金额列保留) 2026-10-05 17:52:44 +08:00
刘泉佳 c4ca347f0e fix: 金蝶OFD附件预览短路提示下载;正式结算发票信息操作列固定最右侧 2026-10-05 15:52:31 +08:00
刘泉佳 b4ecc90470 feat: 收票登记发票附件查看下载与查看态操作列(金蝶piaozone path附件代理)
- vehicle-attachment-table 新增 kingdee-invoice-no/show-view-actions props:piaozone path 型附件查看经 A801-2 代理取流进弹窗预览(objectURL 弹窗关闭回收),下载/批量下载走金蝶代理串行触发
- 查看态展示查看/下载操作列,编辑态为查看/下载/删除;未传 props 的页面行为不变
- 收票登记表单页接入(编辑/查看态均生效),kingdee-attachment.js 导出 requestKingdeeFile 供组件复用
2026-10-01 18:32:22 +08:00
刘泉佳 0484c2359d Merge remote-tracking branch 'refs/remotes/origin/master' into feature/bfi-api-rate
# Conflicts:
#	pnpm-lock.yaml
#	src/axios.js
#	src/views/settlement/formal-settlement.vue
#	src/views/vehicle/customer-archive.vue
2026-10-01 13:46:44 +08:00
刘泉佳 7fca74907f feat: 正式结算单详情新增开票发票查看下载(金蝶回写直链)
- 发票信息卡片内新增只读开票发票表格(issuedInvoices,金蝶A303-5回写)
- 查看:数电PDF优先,退预览图/板式文件直链新窗口打开
- 下载:按直链扩展名命名,不经A801-2代理
2026-09-30 14:16:19 +08:00
刘泉佳 d7ddeacbbf feat: 已推送金蝶的认领隐藏作废按钮 2026-09-29 11:58:26 +08:00
刘泉佳 3d59203dbe fix: 作废确认弹窗去掉冲单描述,成功提示改为作废成功 2026-09-29 11:16:08 +08:00
刘泉佳 3700f5d82b ui: 收款流水页搜索区展开器改为与认领记录页一致的箭头图标按钮(tooltip 展开/折叠) 2026-09-29 10:35:56 +08:00
刘泉佳 05b16bbea0 ui: 收款流水页筛选重排——认领状态/交易时间提升为默认展示,低频条件收进展开区 2026-09-29 10:33:25 +08:00
刘泉佳 dc28bc2afa feat: 收款流水页筛选条件补充收款金额区间与认领人 2026-09-29 10:28:37 +08:00
刘泉佳 730c4e2a6a fix: 认领结果回写/流水同步 0 条时提示 NaN——unwrapData 判空改 ?? 保留合法数字 0 2026-09-29 01:33:58 +08:00
刘泉佳 9b42551bcd feat: 认领记录页筛选条件补充推送状态 2026-09-29 01:32:15 +08:00
刘泉佳 66944bdaf9 feat: 收款流水页筛选条件补充推送状态/金蝶状态/明细流水号(推送状态默认展示) 2026-09-29 01:25:36 +08:00
刘泉佳 09da05c75e feat: 收款流水页新增推送金蝶按钮与推送状态/失败原因/金蝶单号列,认领口径切公告未认领金额
- 行按钮推送金蝶(receipt_flow_push 权限,认领完成且未推送可点),成功提示金蝶收款单号
- 列表/详情展示推送状态、推送失败原因、金蝶收款单号与金蝶状态,留痕表含推送记录
- 认领表单/批量认领上限校验切公告未认领金额口径,新增剩余可认领金额展示
2026-09-29 01:03:21 +08:00
刘泉佳 8bc312502f feat: 收款流水认领页面展示金蝶未认领金额(unclaimedAmount) 2026-09-28 20:04:58 +08:00
刘泉佳 9b40800aab Merge remote-tracking branch 'origin/feature/kingdee-attachment-sync' into feature/bfi-api-rate
# Conflicts:
#	src/api/payment/invoiceApplication.js
#	src/api/payment/invoiceReceipt.js
#	src/api/payment/paymentApplication.js
#	src/api/settlement/formalSettlement.js
#	src/views/payment/invoice-application.vue
#	src/views/payment/invoice-receipt.vue
#	src/views/payment/payment-application-form.vue
#	src/views/payment/payment-application.vue
#	src/views/settlement/formal-settlement.vue
2026-09-28 10:07:19 +08:00
刘泉佳 f49f36fb60 ✨ 金蝶附件下载(A801-2)前端:票池弹窗/正式结算/付款申请三入口附件查看下载与类型分流 2026-09-27 21:17:59 +08:00
刘泉佳 7fd8140761 feat: 收款认领页直连A301-2公告同步并支持A301-1认领结果查询回写 2026-09-24 22:21:38 +08:00
刘泉佳 aabe67e4d6 ✨ 金蝶附件同步(A902)前端:四页面附件同步列、重推补传提示与收票推送超时放宽 2026-09-24 13:27:41 +08:00
刘泉佳 e63c7fdd42 Merge remote-tracking branch 'origin/feature/bfi-api-rate' into feature/bfi-api-rate
# Conflicts:
#	src/api/payment/invoiceReceipt.js
2026-09-24 11:56:47 +08:00
刘泉佳 ec56ee07be feat: 正式结算、客商档案金蝶推送按钮增加防重复点击 2026-09-24 05:08:01 +08:00
刘泉佳 a5eeda6152 feat: 付款申请、开票申请、收票登记金蝶按钮增加防重复点击 2026-09-24 05:07:51 +08:00
刘泉佳 5c45e9b2c9 feat: 金蝶推送接口前端超时扩大至30秒 2026-09-24 05:07:39 +08:00
刘泉佳 331a39b37d Merge remote-tracking branch 'origin/master' into feature/bfi-api-rate
# Conflicts:
#	src/views/vehicle/customer-archive.vue
2026-09-24 03:15:23 +08:00
刘泉佳 aa0aec3704 feat: 批量转结算弹窗新增单据号和合同编码查询条件 2026-09-23 15:39:32 +08:00
刘泉佳 622705325b feat: 选票弹窗新增剩余可用金额列并支持游标翻页与上一页缓存回显
- 下一页回传上页nextCursor续扫,新查询/换页容量/重新打开自动重置游标与页缓存
- 上一页直接回显已浏览页缓存,不再发起请求
- 查询表单删除单据使用状态条件(后端固定查未用),新增剩余可用金额展示列
2026-09-23 15:06:33 +08:00
刘泉佳 4657fc29bd ♻️ 收票管理仅保留推送金蝶收票单并统一命名为批量同步 2026-09-23 11:05:19 +08:00
刘泉佳 884de893db ✨ 收票单推送金蝶与票池实时游标分页前端适配:选票弹窗hasMore分页,下线镜像补查与票池同步入口,保存时按票号重查回填 2026-09-23 10:22:51 +08:00
刘泉佳 cc73d544ff 🔧 引入vue-tsc与typecheck脚本支持类型检查,pnpm-lock.yaml改为忽略不跟踪 2026-09-23 10:22:46 +08:00
刘泉佳 10fecb5a54 ✨ 金蝶票池选票统一查镜像前端接线(新增统一选票弹窗组件,替换收票与结算认领弹窗并删除Mock) 2026-09-22 21:26:50 +08:00
刘泉佳 bab6bdba27 Merge remote-tracking branch 'origin/feature/bfi-api-rate' into feature/bfi-api-rate 2026-09-22 16:36:33 +08:00
刘泉佳 a554ccf38e 🐛 axios网络级失败(502/504/超时)补充统一错误提示,修复接口报错页面无感知 2026-09-21 21:20:57 +08:00
刘泉佳 a748e52551 ✨ 开票申请商品行选择开票内容时快照税收分类编码并随保存提交 2026-09-21 19:14:57 +08:00
刘泉佳 e89e78b527 ✨ 正式结算单认领查询对接发票云全票池(多条件搜索+服务端分页) 2026-09-20 19:41:37 +08:00
刘泉佳 2a03991297 ⚡ 客商档案金蝶回填优化:放宽锁定字段、信用代码可重新输入并修复查询误判 2026-09-20 16:06:03 +08:00
刘泉佳 884cc4355f fix: 同步金蝶接口放宽请求超时,修复长耗时推送成功但页面无提示 2026-09-18 20:42:52 +08:00
刘泉佳 58aaf79445 ✨ 客商档案新增金蝶客商查询回填与同步金蝶重推入口 2026-09-18 20:01:29 +08:00
刘泉佳 f067518c0e fix: 付款申请批量同步金蝶失败时展示失败原因
- 批量同步后解析返回结果,按"同步失败"标识区分成功与失败条目
- 部分失败时以 warning 提示成功/失败条数及具体失败原因(停留8秒),全部成功仍提示已同步条数
2026-09-18 11:58:00 +08:00
刘泉佳 11460f3378 ✨ 付款管理新增同步付款结果按钮并扩展金蝶单据状态选项 2026-09-18 11:57:59 +08:00
刘泉佳 7f543937fb ✨ 结算单新增时自动查询BFI汇率
- currency.js: 新增getBfiExchangeRate接口调用
- pre-settlement-editor.vue:
  - 选择合同后同步结算币种并自动查询当天BFI汇率
  - 汇率日期变更时自动查询BFI汇率并回填结算汇率
  - 新增时自动设置当天日期并查询汇率
- formal-settlement-editor.vue:
  - 选择合同后同步结算币种并自动查询当天BFI汇率
  - 汇率日期变更时自动查询BFI汇率并回填结算汇率
  - 新增时自动查询当天BFI汇率
2026-09-16 14:14:53 +08:00
122 changed files with 8216 additions and 1888 deletions
+1
View File
@@ -1,5 +1,6 @@
.DS_Store
node_modules
pnpm-lock.yaml
/dist
/tests/e2e/videos/
+18
View File
@@ -0,0 +1,18 @@
# 2026-10-13
## 车辆调度弹窗:使用部门级联选择器宽度对齐
- 现象:`/transportCapacity/vehicle-dispatch` 新增/编辑弹窗中,使用部门 `el-cascader` 明显窄于同行车牌号 `el-input`。
- 根因:`el-cascader` 是 `display: inline-block` 宽度按内容收缩(不像 `el-autocomplete` / `el-input` 有 `--el-input-width: 100%` 兜底);而页面里写的 `.vehicle-dispatch-page :deep(.vehicle-dispatch-form-control){width:100%}` 命中不到 —— avue-crud 弹窗默认 `append-to-body: true`(`append-to-body": validData(crud.tableOption.dialogAppendToBody, true)`),DOM Teleport 到 body,已不在 `.vehicle-dispatch-page` 之下。
- 修法(走项目既有约定):
1. `src/option/transportCapacity/vehicle-dispatch.js` 增加 `dialogCustomClass: 'vehicle-dispatch-dialog'`;
2. `src/styles/element-ui.scss` 增加全局规则 `.vehicle-dispatch-dialog .el-form-item__content > .el-cascader { width:100%; max-width:100% }`。
- 自检:dev(2889) `curl /src/styles/element-ui.scss` 与 `/src/option/transportCapacity/vehicle-dispatch.js` 均 200,编译产物含新规则。
- 遗留观察:同文件里 `.vehicle-dispatch-page :deep(.avue--detail ...)` 那一批查看态禁用样式,同样因 Teleport 属死规则(未改,待确认是否需要迁到 `.vehicle-dispatch-dialog` 作用域)。
## 追加修正(同日 20:xx):第一次改完用户反馈「还是没对齐」
- 原因一:选择器写成了 `.el-form-item__content > .el-cascader`(直接子级)。**avue 表单项在 `el-form-item__content` 与控件之间还有两层包裹 div**(`packages/element-plus/form/index3.js`:content > div > [div|elTooltip] > 控件),必须用后代选择器。已改成 `.vehicle-dispatch-dialog .el-form-item__content .el-cascader`。
- 原因二(更隐蔽):option 用 `labelWidth: 'auto'`,**EP 的 auto 是逐项计算**(实测:3 字 54/64px、2 字 40px、4 字无星号 68px、4 字带星号 78px),label 越宽控件可用宽度越小 → 车牌号 input 486px、使用部门 cascader 472px,仍差 14px。已加 `.vehicle-dispatch-dialog .el-form-item__label { flex: 0 0 calc(4em + 24px) !important; width: calc(4em + 24px) !important }`(本弹窗左右两列最长 label 均为 4 汉字)。
- 验证方式(可复用):临时在项目根建 `cascader-width-check.html`,Vite 直接服务 → `import './src/option/...'` 用真实 option + `import './src/styles/element-ui.scss'`,挂 avue-crud 并 `mounted(){ this.$refs.crud.rowAdd() }` 弹出真实弹窗;用 agent-browser(`/Users/gxwebsoft/.workbuddy/binaries/node/workspace/node_modules/.bin/agent-browser`)`open` + `eval` 读 DOM 链路与 `getBoundingClientRect`。修完已删除该临时文件。
- 结果:全部 label 80px,车牌号 input 与使用部门 cascader 均为 left=732 / width=470 / right=1202,完全一致。
+142
View File
@@ -0,0 +1,142 @@
# 2026-10-14
## 运单管理:草稿行「查看」改为与其它状态一致的「详情」
- 现象:`/business/waybill-manage` 列表操作列,草稿行显示「查看」→ 走 avue 自带 `$refs.crud.rowView()` 表单弹窗;其它状态显示「详情」→ `openDetail()` 跳独立详情页 `/business/waybill-manage/detail?id=x`(新标签「运单管理详情」)。同一列两种弹窗形态。
- 根因:`waybill-manage-page.vue` 的 `showDetailButton(row)` 显式 `return status !== 'draft'`,把草稿排除在详情按钮外,于是落到 `#menu` 里 `!showDetailButton(row)` 的「查看」分支。
- 修法:`showDetailButton()` 去掉草稿判断,仅保留 `config.detailButton` + `_view` 权限判断;「查看」分支保留,仅在 `detailButton:false` 时兜底。
- 备注:`business-crud-page.vue:7043`、`transport-plan-page.vue:4111` 有同款 `status !== 'draft'` 逻辑(运输计划另有 `showViewButton`);两个 option 文件里的 `detailButtonIgnoreDraftStatus` 目前是死配置,组件未读取。
## 收付款管理模块:列表页右上角统一为 4 键工具组(最终方案 = 改 avue-crud)
- 现象:付款管理页右上角只有 1 个刷新按钮,与其它模块不一致;参考模块的 4 键工具组其实是 **avue-crud 内置头部**,不是可引用的公共组件。
- 方案演进:
1. 过渡方案(已落地 7 页):手写页引入标准件 `src/components/table-header-tools/main.vue`,改法为搜索区 `v-if="searchVisible"`、el-table 加 `:size="tableSize"`、列 `v-for="column in visibleColumns"`、data 加 `searchVisible/tableSize/visibleColumnKeys`;汇票两页表格在子组件,子组件透传 `update:searchVisible` + `refresh`。已接入 `invoice-application.vue`、`invoice-receipt.vue`、`receipt-flow.vue`、`receipt-claim-record.vue`、`bill-ledger.vue`+`bill-ledger-table.vue`、`bill-payment.vue`+`bill-payment-table.vue`。
但用户实测反馈:第 2 键(列设置)与第 4 键(表格尺寸)效果仍与参考模块不同 —— 参考模块第 2 键是 `el-drawer` 列设置抽屉(列名/隐藏/冻结/过滤/排序),第 4 键是「表格 ↔ 卡片」视图切换,两者都嵌在 avue 内部,**无法脱离 avue-crud 复用**。
2. 最终方案(用户选定,先做样板):列表页改写成 `<avue-crud>`,4 键 = 刷新 / 列显隐抽屉 / 搜索显隐 / 网格切换,与其它模块 100% 一致。
- 样板:`src/views/payment/payment-application.vue` + `src/option/payment/paymentApplication.js`(`paymentApplicationOption()` 工厂)。
- 搜索项与表格列 label 不一致的(表格列「收款方」vs 搜索项「客户名称」)用 `searchProp` 映射;属搜索专用列用 `searchOnlyColumn()` 统一生成(`hide + showColumn:false + add/edit/viewDisplay:false`)。
- ⚠️ **avue 的 searchOrder 是降序**(`header-search2.js`:`(b.searchOrder||0)-(a.searchOrder||0)`),值越大越靠前 —— 首次做反了,9 个搜索项 score 全部反转。
- ⚠️ **column 数组顺序即表格列顺序**,搜索项夹在中间会把该列挤到表格前面(`deptName` 踩过),搜索专用列必须紧跟在同位置或用 `hide` 隔离。
- `on-load` 在 created / currentChange / sizeChange 都会触发(`table-page2.js`),因此 `currentChange`/`sizeChange` 只改 page、**不要再调 onLoad**;`onLoad(page)` 也不能把 page 里的 total/pageSizes spread 进查询参数。
- 顺带加固:`table-header-tools` 加 `deactivated()` 关闭列设置弹窗(keep-alive 切页防 Teleport 弹窗残留);清理 invoice-application / invoice-receipt 里失效的 `__toolbar-right` 样式规则。
- 验证:项目根建临时 html 挂真组件 + `app.use(Avue)`,`agent-browser` 实测 —— 4 个按钮均渲染图标、刷新触发、搜索区切换、表格尺寸 default→large、列设置弹窗勾选双向生效;avue-crud 样板另测:4 键数量=4、默认 4 项搜索 + 展开 9 项、18 列表格顺序与原页一致、列显隐抽屉表头 5 列 18 行、网格按钮切出卡片视图;临时文件用完即删。
- 踩坑:`el-icon-*` 字符串图标靠 Avue 的全局映射,验证页不装 Avue 时 4 个按钮是空圆(差点误判)。
- 待办:用户确认样板效果后,把其余 6 页(invoice-application / invoice-receipt / receipt-flow / receipt-claim-record / bill-ledger / bill-payment)也改成 avue-crud,并回退其 table-header-tools 改动。
## 正式结算单表单:结算明细区按钮/筛选行对齐预结算单
- 现象:`formal-settlement-editor.vue` 的「结算明细」区把 导出/选择结算明细 塞在筛选表单的 actions 里(`grid-column:1/-1` 换行右对齐,出现两个蓝实心),与 `pre-settlement-editor.vue` 不一致(后者把这两个按钮放 `<section-card #extra>`,筛选项与查询/重置同行)。
- 改法(只动 formal 一个文件,72+/17-):
1. 模板 `section-card#extra` 加 导出(`v-if="hasPermission('formal_settlement_export')" type=primary plain`)+ 选择结算明细(`v-if="editable" type=primary plain`),与「收起明细」并列;actions 里只留 查询/重置。
2. el-form 去掉 `inline`、`label-width` 88px→auto,4 个输入补 `placeholder="请输入"`。
3. 样式 `grid-template-columns: repeat(4, minmax(0,1fr)) auto` + `align-items:center`;actions 去掉 `grid-column:1/-1` 改 `width:auto;margin-left:0;white-space:nowrap;justify-self:end`;媒体查询 ≤1200px 同步补 `auto` 列。
4. 补 `hasPermission`:`...mapGetters(['permission','userInfo'])` + `isAdmin`(`isAdministrator` from `@/utils/auth`)+ methods `hasPermission(code){ return this.isAdmin || this.validData(this.permission?.[code], false) }`(`validData` 是项目全局属性,main.js 未显式挂载但全站通用)。
- ⚠️ 关键坑:文件末尾 `.formal-editor :deep(.el-form-item){margin-bottom:24px}` 与筛选区 `:deep(.el-form-item){margin-bottom:0}` **特异性相同**,靠源序决定胜负 → 覆盖规则必须写在文件尾部(`@media` 之前)。pre 用的也是同索引结构。
- 验证:`agent-browser` + mock 注入实测 —— grid 列 `188.25×4 + 132`、5 个 form-item 同一行 top 相同、form-item margin-bottom=0;正式单与预结算单**同视口截图逐像素一致**。
## 「变更运输路线」弹窗 · 变更记录列表:变更前/后地址分行展示
- 需求:变更内容原为单行 `YD-xxx:发货地 A → B;到货地 C → D`(多条以 `;` 拼接),地址太长挤成一坨,要求「变更前地址 / 变更后地址」换行。
- 落地:新增 `src/utils/route-change.js` 导出 `formatRouteChangeContent(content, emptyText='-')` —— 仅**展示层**格式化(不动 taskInfoJson 里已存的 content,历史数据同样生效):`\r\n` 归一 → `;` 转 `\n` → `→` 转 ` →\n`(箭头留上一行行尾)→ 压缩空行;空值返回 `-`。
- 接入 3 处同一弹窗(都在 methods 里以 ES6 简写挂载 `formatRouteChangeContent`,与 `formatContractOptionLabel` 同款):
- `views/business/loading-manage.vue`(表格列 `record-content`)
- `views/business/components/waybill-manage-page.vue`
- `views/business/components/business-crud-page.vue`
- ⚠️ 关键坑:`white-space: pre-wrap` 下模板里插值前后的**换行+缩进空白会被渲染出来**(内容前面凭空多一行缩进)。所以要么把 `>{{ ... }}</div>` 写成紧贴一行(loading-manage),要么改用 `v-text="formatRouteChangeContent(row.content)"` 多行写法(另两处)——文本节点不存在才安全。CSS 里 `white-space:normal` 需改 `pre-wrap`(loading-manage 原为 normal),另两处补 `overflow-wrap:anywhere; word-break:break-word; line-height:22px`。
- 效果:`YD-20260904-0022:发货地 广西…公司 →` / 换行 / `广东省…西瓜洲` / `到货地 山东…公司 →` / `山东省…公司`。
- 验证:node 直跑函数(多条 / 单条 / 路线顺序 / 空值)输出正确;dev `localhost:2889` 三个 .vue 与 scoped style 块均 200,编译产物各含 3 处 `formatRouteChangeContent`。
- 未做(如需再说):存储格式与导出 Excel 仍是单行文本;`vehicle/transport-change-record.vue` 的 `changeContent` 是人工填写的自由文本,未改。
## 发货模板"选择常用地址"改图标(对齐运输计划)
- 需求:`/business/shipping-template/form` 收发货信息行,把文字「选择」按钮改成运输计划同款图标方式。
- 改动(`src/views/business/components/shipping-template-page.vue`):
1. `import { Location, OfficeBuilding } from '@element-plus/icons-vue'`,并把 `OfficeBuilding` 加进 `data()`;
2. departure/arrival 两个 form 插槽里,把 `<el-tooltip><el-button ...>选择</el-button></el-tooltip>` 从行尾移到第二个地址输入框之后,改成 `:icon="OfficeBuilding"` + `link` 图标按钮(class `shipping-template-page__address-pick-btn`);
3. suffix 的 `<el-icon>` 补 class `shipping-template-page__map-suffix`(cursor/18px/#909399/hover #409eff),与运输计划同名同款;
4. `.shipping-template-page__route-row` grid 第 3 列插入 `40px` 图标列(原末尾的 `auto`),并把 `__address-pick-btn .el-icon { font-size: 22px }` 写进该行容器内。
- 关键坑(差点误判成 bug):Element Plus 图标两种用法机制不同 ——
- 标签形式 `<Location />` 靠 `main.js` 里 `app.component(key, ...)` **全局注册**解析,与 `data()` 无关;
- 表达式形式 `:icon="OfficeBuilding"` 编译成 `_ctx.OfficeBuilding`,**必须把组件对象放进 data()**(放 `components` 注册里模板取不到,会静默渲染不出图标)。
验证页漏了全局注册,曾误判 `<Location />` 不渲染。
- 验证:临时验证页(项目根 html + 真 .vue,走 Vite + 真 element-plus + 全局图标注册)实测:地址框内 Location 18px、按钮 40px 内 svg 22px 居中、7 列 184/368/40/48/184/64/184 铺满 1120px;截图视觉与运输计划一致。用完已删临时文件。
- dev 自检:`shipping-template-page.vue` 与 scoped style 均 200。
## 多联总单:调度页顶部路线节点 / 详情页路线图 —— 省市区按「市 区县」分段展示
- 现象:`/business/master-order?mode=dispatch&id=xxx` 顶部 `route-overview` 的起/终节点直接绑定 `route.departureName/arrivalName` 原始值 → 显示成「上海市浦东新区」「广西壮族自治区南宁市邕宁区」挤成一坨(同页段1~段3 的表头早已是「上海市 浦东新区 → 上海市 静安区」,用户截图圈出顶部这行说"怎么还没改")。
- 根因:顶部节点是唯一没走格式化方法的绑定(段头走 `dispatchLocationName`,详情页走 `segmentLocationName`),不是格式化逻辑有问题。
- 落地:新增 `src/utils/region-address.js` 导出 `formatRegionAddress(value)` + `splitRegionTokens(value)`(按行政区划后缀切分,`州`+`市`、`县`+`区` 连写特例合并;**整串都是行政区划**才重排,站点名「南宁站」「广州南站」和含街道门牌的详细地址原样返回)→「市 区县」的空格展示,省/自治区不重复(与全站既有 `formatRoadLocation`/`formatRouteCityDistrict` 规则一致,`广西壮族自治区南宁市邕宁区` → `南宁市 邕宁区`)。
- 接入 4 处:调度页 `route-overview` 起/终节点的强文本改用 `dispatchLocationName(route, 'departure'|'arrival')`、第二行地址改用 `formatRegionAddress(...)`,`addressText()` 名称+地址都格式化并去重(`南宁市 邕宁区` 不再重复两遍);详情页 `route-map` 两处 `<small>` 地址改用 `formatRegionAddress(...)`。
- 验证:项目根临时验证页(真组件 + `axios.defaults.adapter` 全量 mock 后端,复刻截图数据 `routes=[上海市静安区, 南宁站, 广西壮族自治区南宁市邕宁区]`)+ agent-browser 读 DOM —— 调度页顶部 4 节点 =「上海市 浦东新区 / 上海市 静安区 / 南宁站 / 南宁市 邕宁区」,与段1~段3 表头、详情页路线图**逐字一致**;终节点宽 101px 未被省略号截断(改前原始串超宽被截);两页 innerText 再扫 `省|自治区` 已无未加空格的残留。用完已删临时文件。
## 同事拉代码编译报错(漏提交新文件)→ 已修复
- 现象:同事 pull 后 `[vite:load-fallback] Could not load .../src/utils/route-change (imported by src/views/business/loading-manage.vue): ENOENT`。
- 根因:`src/utils/route-change.js`、`src/utils/region-address.js` 是**新增文件且一直 untracked**,本地能跑、远端没有。
提交时用了只带已跟踪文件的写法(`git commit -am` 之类),.vue 的引用被提交了、新 .js 没进去(12:44 的 6de87ed 就是这种情况)。region-address 是第二个雷:route-change 修好后会接着报它。
- 修复:`git add src/utils/region-address.js src/utils/route-change.js` → commit `749b314 :bug: 补充提交遗漏的 region-address 与 route-change 工具文件` → push(期间远端有同事新提交 74780b8,先 `git pull --rebase` 无冲突)。三处 tree(本地 HEAD / origin/master / 远端真实 SHA)核对一致。
- 复查:写脚本扫 `src/**` 所有 `@/`、相对路径引用(含 scss @use/@import),比对 `git ls-files`,**无其它"文件存在但未跟踪"的引用目标**,暂时不会再踩同类坑。
- 教训:本仓库提交前建议 `git status` 扫一眼 `??`,新增文件必须显式 `git add`。
## payment 模块 6 个手写列表页:搜索栏「展开/收起」统一(对齐 customer-archive / settlement)
- 起因:`/payment/invoice-application`、`/payment/bill-payment` 的展开收起控件与别的模块不一致。排查发现 payment 模块 6 个手写搜索栏各写各的:5 个是 `el-tooltip`+`el-button text`+纯箭头图标(文案「折叠」,tooltip 才显示),`receipt-flow.vue` 更离谱 —— 裸 `el-button`(带边框)且排在**查询/重置左边**。
- 统一标准(两处参照物写法完全一致):
- `views/settlement/{formal-settlement,pre-settlement,transport-reconciliation,receivable-payable-detail,settlement-adjustment}.vue` 已有写法:
```html
<el-button text :icon="searchExpanded ? ArrowUp : ArrowDown" @click="toggleSearch">
{{ searchExpanded ? '收起' : '展开' }}
</el-button>
```
放在「查询 / 重置」之后(最右)。
- `views/vehicle/customer-archive.vue` 是 avue-crud 内建,`searchIcon: true` + `searchIndex: 4`,渲染出的也是同一形态。
- avue 显示规则(`node_modules/@smallwei/avue/lib/packages/element-plus/crud/menu/header-search3.js` 的 `isSearchIcon`):
`searchIcon === true && columnLen > searchIndex` → **搜索项不超过 4 项时根本不渲染展开/收起按钮**。故各页 data 加 `searchIndex: 4` + `searchItemCount: N`,computed `showSearchToggle = searchItemCount > searchIndex`。
- 逐页落地:invoice-application(5项) / invoice-receipt(6项) / receipt-claim-record(11项) / receipt-flow(7项) / components/bill-ledger-search(6项) 均显示;**components/bill-payment-search 仅 4 项(后端 `BillPaymentServiceImpl.selectPage` 也只支持 paymentNo/deptName/paymentDate 区间/approvalStatus 这 4 个条件)→ showSearchToggle=false,不渲染按钮**,避免留一个"点了没反应"的假按钮。
- `receipt-flow.vue`:补 `import { ArrowDown, ArrowUp }`、按钮从最左移到最右、文案「折叠」→「收起」。
- gap 不动:`business/`、`settlement/` 手写页的 `__search-actions` 都写了 `gap: 8px`(与本次统一无关,别顺手删)。
- 校验:6 个文件 `curl localhost:2889/src/...vue` 与 `?vue&type=style&index=0&scoped=true&lang.scss` 全 200;`grep 折叠|el-tooltip src/views/payment/` 已无残留。
## 15:55 路线变更「变更内容」列排布问题(待用户确认方案,未动代码)
- 位置:`变更运输路线` 弹窗 → `变更记录` tab → 表格 `变更内容` 列,共 **3 处同款**:`views/business/loading-manage.vue:1688`(`__record-content`)、`views/business/components/waybill-manage-page.vue:3027`、`views/business/components/business-crud-page.vue:2998`。
- 存储格式为**单行文本**:`YD-xxx:发货地 A → B;到货地 C → D;YD-yyy:…`,展示层由 `src/utils/route-change.js` 的 `formatRouteChangeContent()` 切行(`;`→换行、`→`→` →\n`),再以 `white-space: pre-wrap` 平铺。
- 病因:`:` 不带换行 → 运单号与「发货地 原地址」挤同一行;前/后无标签;多条记录无分隔。一个单元格可堆 3 条运单 × 2 字段 × 2 行 ≈ 12+ 行。
- 已给 3 个方案(A 单元格内分组对齐:运单号独占行主色 + 字段标签列 + 前值/后值缩进对齐,改 formatter 返回结构 + 模板渲染;B 列只留「运单号 · N 项」摘要 + 查看详情弹窗,复用同类组件 `src/components/change-record-detail-dialog/main.vue`(变更字段/变更前/变更后三列 + 分页);C 按「运单 × 字段」拆行 + `span-method` 合并时间/人)。**推荐 A,B 备选**。等用户确认后再改。
## 16:20 已按方案 A 落地(变更内容列分组渲染)
- **`src/utils/route-change.js` 重写**:删掉 `formatRouteChangeContent`,改为导出 `parseRouteChangeContent(content)`,返回分组数组 `[{ type, title, text, fields:[{label,before,after,single,changed,unchanged}] }]`。
- ⚠️ **关键坑:存储格式里「到货地」前面只有 `;`、没有冒号**(真实格式 `${waybillNo}:发货地 A → B;到货地 C → D`)。第一版按「冒号切 head/tail」写,导致「到货地」整段落进纯文本兜底 → 必须"先判断 segment 是否以字段名开头",再把无标题字段行合并进上一条 waybill 分组。
- 字段名正则的**长标签必须排在短标签前**(`发货地址` 早于 `发货地`),否则被前缀吃掉。字段白名单:发货地址/到货地址/发货地/到货地/装货地/卸货地/收货地。
- 三种分组类型:`waybill`(`运单号:发货地…`)、`field`(`;到货地…`,合并进上一个非 text 分组)、`group`(`路线顺序:X 调整为 Y`,按「调整为」拆前后,不走箭头切分,因为路线文本本身含 `→`)、`text`(兜底原文)。
- 箭头正则 `/\s*(?:→|➔|➜|[\-–—]{1,2}\s*>)\s*/`;空值归一为 `-`。
- **三处模板统一改为分组渲染**(运单号主色标题 + 左侧 2px 主色竖条;字段 label 列 54px;`changed` 两行「旧值(灰) / → 新值(主色)」,`unchanged` 单行 + 灰字「未变更」):`loading-manage.vue`(`loading-route-change-dialog__change-*`)、`waybill-manage-page.vue`、`business-crud-page.vue`。样式块也同步替换(`__route-change-record-content` 从 `white-space: pre-wrap` 改为 flex column;箭头 `text-align: right`,grid item 会被 blockify 所以 span 上生效)。
- 校验:node 跑 `parseRouteChangeContent` 7 组用例(真实截图数据 / 路线顺序 / 混合 / 半角箭头分号 / 无箭头单值 / 纯文本 / 空)全部符合预期;`@vue/compiler-sfc` 编译三个 .vue 的 template + scss 全过;SSR 渲染真实模板 + agent-browser 截图确认观感(临时文件已删)。
- 已知代价:窄列(360px)下地址被 54px 标签列占位,单元格比原来高约一倍;换来的是一条运单/一个字段的分组边界一目了然。
## 16:05 /base/measurement-unit 创建时间/更新时间列截断修复
- 现象:表格里「创建时…」「更新时…」列被挤到 ~80px,`2026-0...` 截断;用户猜测要固定操作列。
- 根因:`option/base/measurement-unit.js` 两列只写了 `type/format/sortable`,**没有 width/minWidth** → el-table 未设宽列按剩余空间均分,被压到低于内容宽度 → 省略号。与 `menuFixed` 无关。
- 修法:`createTime`/`updateTime` 加 `width: 180`(与同模块 `option/base/common-address.js` 完全一致),`createUserName`/`updateUserName` 加 `minWidth: 110`。
- 结论(重要经验):**操作列不需要额外"固定"**,`menuFixed: 'right'` 早在 option 里(`main.js` 全局 `crudOption.menuWidth: 180`,avue 库默认 220)。不要为了"防截断"去加宽操作列,加宽只会增加横向滚动;时间列截断一律是缺 width。
- 验证方式(可复用):项目根建临时 html `__mu-check.html` → Vite 直接服务;`import { createOption } from '/src/option/base/measurement-unit.js'` + `app.use(Avue, { crudOption: { menuWidth: 180 } })`,用 `h(resolveComponent('avue-crud'), {...}, { menu: () => [...] })` 渲染(避免运行时模板编译)。
实测(容器 1200px):td 宽 `[50,70,150,150,130,200,100,110,110,180,180,180]` 合计 1610 → 横向滚动 410px;`scrollWidth > clientWidth` 的单元格 **0 个**;`操作` th 为 `position: sticky`,滚到最右后右边缘 = 容器右边缘 → fixed 生效。用完删文件 + `agent-browser close`。
## 16:25 变更内容列 hover 提示去除(分组改造的连带问题)
- 用户反馈:改完分组后,变更内容单元格 hover 出现提示浮层,要求去掉。
- 根因:`main.js:84` `app.use(ElementPlus, { table: { showOverflowTooltip: true } })` 全局开启 → EP 给每列 `.cell` 加 `.el-tooltip` 类(`white-space: nowrap`)。**我上一次改造把内层 `white-space: pre-wrap` 换成了 flex column、忘了补 white-space** → 继承 nowrap → 长地址横向溢出 → tooltip 激活。改动前靠 pre-wrap 自保所以没暴露。
- 修法(三处,列级豁免、不动全局配置):① 变更内容列显式 `:show-overflow-tooltip="false"`;② `__route-change-record-content` 补 `white-space: normal` 兜底。
- ⚠️ 验证踩坑:用「项目根临时 html + `node_modules/element-plus/dist/index.full.min.js` 全局构建 + `app.use(ElementPlus, { table: { showOverflowTooltip: true } })`」复现时,**el-table 表头/列宽渲染错乱**(表头逐列堆叠、每行只渲染出 2 个 cell),该环境不可用于布局验证;但单元格内容渲染正常,足以确认分组结构无误。要验证真实表格布局还是得走 Vite 服务真实页面。
另:自定义组件接模板字符串时,切出的模板里 `row.content` 要替换成 `content`(插槽作用域不存在于独立组件里),否则报 `Cannot read properties of undefined (reading 'content')`。
- 校验:编译三个 .vue 的 template + scss 全过;临时验证页 `__route-change-verify.html` 与 8899 端口静态服务已删除/停止。
- 未处理(等用户确认):备注列的 `show-overflow-tooltip` 保持原样(属全站约定),用户若要求连备注一起去掉再单独改。
## 16:45 付款申请表单「结算单号」字段交互歧义修复(方案 A)
- 现象(用户截图圈出):`/payment/payment-application/form` 结算单号是 `el-select multiple`,placeholder 写「请输入」但**打不进去字**,必须先猜到要点右边「选择」才有效。
- 根因(双重的):
1. 该 `el-select` 的 `:model-value` 绑只读 computed `referenceSettlementNos`(= 已选单号本身),**options 就是它自己** → 点开下拉、点选项,值不变;代码里 `handleSettlementSelectVisible` 甚至主动 blur 掉下拉。真正改值的只有右侧「选择」按钮。
2. placeholder 写「请输入」但控件不可输入(未开 `filterable`),且 `.el-tag__close{display:none}` 藏了删除叉 → 视觉上更像输入框。全站规矩是「请输入/请选择」二选一,这里是选择语义。
- 落地(只改 `views/payment/payment-application-form.vue`,全站 `openReference`/`referenceSettlementNos` 无第二处引用):
- 模板:`el-select` → `el-input readonly` + 外层 `el-tooltip`,`:model-value="referenceDisplayText"`,`placeholder="请输入"`,`:disabled="readonly"`,**`@click="!readonly && openReference()"` 让整框可点开弹窗**;右侧「选择」按钮保留。
- 新增 computed:`referenceSettlementCount`、`referenceDisplayText`(单张显示单号;**多张只显示 `已选 N 张结算单`**,完整单号靠 tooltip,明细看下方「结算信息」表)。
- 删除 `handleSettlementSelectVisible` 方法与 `settlementNoSelectRef`;样式 `.payment-form-page__settlement-select{,:deep(...)}` → `.payment-form-page__settlement-input`(+ `cursor:pointer` / disabled 时 `not-allowed`)。
- ⚠️ 踩坑:第一版把「共 N 张」做成 `el-input` 的 `#suffix` 插槽 → span=6 的窄列里后缀挤占宽度,单号被截成 `JS202610`,比不改还差。**窄列(≤span6)不要在输入框内塞后缀/前缀,把信息编进 `:model-value` 文本本身**。
- 验证(项目根临时 html + `vue/dist/vue.esm-bundler.js`,用完即删):
- ⚠️ 临时页必须 import `vue/dist/vue.esm-bundler.js`,默认 `vue` 是 runtime-only,字符串 template 报 “runtime compilation is not supported”。
- 4 态实测:空值 placeholder 正确 / 单张显单号 / 多张显「已选 3 张结算单」且不截断 / 查看态 disabled 且按钮隐藏;输入框宽 132px、行高 32px。
- 点击探针验证:可编辑态点 `.el-input` 命中 1 次(el-tooltip 不拦截 click),查看态命中 0 次 ✓。
+7
View File
@@ -0,0 +1,7 @@
# 2026-10-15
## 10:46 付款申请表单「结算单号」修复丢失 → 重放并提交
- 现象:用户反馈昨天改好的「结算单号」(只读输入框 + 整框可点开选择弹窗)今天又变回旧的 `el-select` 写法。
- 根因:昨天的改动**一直没有 commit**,是纯工作区改动;用户 pull(HEAD 到 9919fb9,两个 settlement 样式提交包含该文件旧版)后被覆盖。教训同 2026-09-18「漏提交新文件」一役:**本仓库改动必须尽快显式 commit**,`git status` 干净 ≠ 改动还在。
- 处理:按昨天方案 A 原样重放 4 处修改(模板 el-select→el-input readonly+tooltip+整框可点、新增 `referenceSettlementCount`/`referenceDisplayText` computed、删 `handleSettlementSelectVisible`、样式类改名 `__settlement-input` + cursor 规则),curl 自检 vue/style 均 200。
- **这次只提交该单文件**:`git commit -m ":bug: ..." -- src/views/payment/payment-application-form.vue` → `db03ced`(`.workbuddy/memory` 不入库,留在工作区)。尚未 push,等用户确认。
+57 -1
View File
@@ -12,16 +12,72 @@
**坑二:宿主 view 直绑全局 `$route.query.*` 给子组件 prop。** 缓存实例被灌入当前页 id → 拿错 id 查后端(批量弹「运单管理不存在」),live computed 还会误 push 标签。修法:`routePathLocked` 锁路径 + watcher / push 前判 `isOwnedRouteActive` + 宿主绑定加路径守卫。⚠️ `routePathLocked` **必须放 `data()`**(Options API 顺序 data → computed → watch(immediate) → created;放 created 会读到空值、挡掉首次加载)。已修 waybill-manage-page / transport-plan-page + 三个宿主 view。
**坑四:avue-crud 弹窗 `append-to-body` 默认 true(`dialog-form3.js`:`validData(tableOption.dialogAppendToBody, true)`),DOM Teleport 到 body。** 于是页面里以页面容器为祖先的 scoped 选择器(`.xxx-page :deep(.yyy)`)**命中不到弹窗内容**,尤其 `el-cascader`(inline-block、宽度按内容收缩,不像 `el-input`/`el-autocomplete` 有 `--el-input-width:100%` 兜底)会明显比同行 input 窄。修法:option 里配 `dialogCustomClass: 'xxx-dialog'`,规则写到 `src/styles/element-ui.scss` 的 `.xxx-dialog ...`(项目既有约定,10+ 处同款);或把基准类放在 **Teleport 内部元素**上(如 ship.vue 的 `.ship-form` 挂在 el-form 上,自身带 scope 属性,照样生效)。
**坑五:avue 表单项控件不是 `.el-form-item__content` 的直接子级。** `form/index3.js` 渲染为 `content > div > [div|elTooltip] > 控件`,所以 `.el-form-item__content > .el-cascader` 这类**直接子级选择器对 avue 表单全部失效**(手写 `<el-form-item>` 才生效),一律用后代选择器。
**坑六:`labelWidth: 'auto'` 在 Element Plus 里逐项计算,不是全表单共享。** 实测同一弹窗:3 字 54/64px、2 字 40px、4 字 68px、4 字带必填星号 78px;label 越宽 → 控件可用宽度越小,同级控件宽度就不一致(车牌号 input 486 vs 使用部门 cascader 472)。要让同一行控件等宽必须统一 label 宽度:`.xxx-dialog .el-form-item__label { flex: 0 0 calc(Nem + 24px) !important; width: calc(Nem + 24px) !important }`(N=本列最长汉字数,与 ship.vue、`.dialog-form--carded` 同款)。
**验证 avue 弹窗 DOM/宽度的可复用办法:** 项目根建临时 html(Vite 直接服务)→ 引真实 option 与 `src/styles/element-ui.scss`,挂 avue-crud 并 `mounted(){ this.$refs.crud.rowAdd() }` 弹出真弹窗;用 `agent-browser`(`/Users/gxwebsoft/.workbuddy/binaries/node/workspace/node_modules/.bin/agent-browser`)`open` + `eval` 读 DOM 链路与 `getBoundingClientRect`,用完删文件。
**坑三:路由表重复定义 → 内容区整页空白(2026-09-18)。** `/business/contract-manage/detail` 在 `router/views/index.js` 定义两次、子路由同名 `合同详情`。重名 `addRoute` 会 `removeRoute(name)` 移除旧子记录,但**不从父记录 children 里摘除**,第一条 Layout 壳记录留在 matcher 且靠前 → `resolve()` 命中它 → `$route.matched.length === 1` → 二级 router-view 无匹配 → 侧栏/标签正常但内容空白。修法:删重复定义。排查:扫「顶层 path + 首个子路由 name」重复项。
## 列表页右上角按钮组(table-header-tools 标准件)
⚠️ **2026-10-14 修正:最终方向是手写列表页改用 `avue-crud`**。用户要求与其它模块"完全一致",而 avue 的列显隐抽屉(`el-drawer`+表格,隐藏/冻结/过滤/排序,行拖拽)是 avue-crud 内部子组件(`inject: ['crud']`),脱离 avue-crud 拿不出来;第 4 键也是 avue 的"表格↔卡片视图"切换。所以 `table-header-tools` 只是过渡仿制件,**新页面不要再接**,直接写 avue-crud。
手写 `el-table` 列表页若暂时保留,右上角统一用 `src/components/table-header-tools/main.vue`,复刻 avue-crud 默认 4 键:刷新 `el-icon-refresh` / 列设置 `el-icon-operation` / 搜索显隐 `el-icon-search` / 表格尺寸 `el-icon-grid`(default→large→small 循环)。
- 接入范式:`<table-header-tools :columns="columns" v-model:visible-column-keys="visibleColumnKeys" v-model:table-size="tableSize" v-model:search-visible="searchVisible" @refresh="loadTable" />`;配套:搜索区 `v-if="searchVisible"`、el-table `:size="tableSize"`、列用 computed `visibleColumns = columns.filter(c => visibleColumnKeys.includes(c.prop))`。⚠️ 不能 `v-for` + `v-if` 同元素(Vue3 v-if 优先级更高,取不到 column 变量)。
- 表格在子组件时(如 `payment/components/bill-*-table.vue`):子组件加 props `searchVisible` + emits `refresh`/`update:searchVisible`,父页面 `v-model:search-visible` + `@refresh`,搜索子组件加 `v-if="searchVisible"`。
- `icon="el-icon-xxx"` 字符串**依赖 Avue 的全局 el-icon 映射**(`main.js` 只注册 EP 的 PascalCase 图标),临时验证页没 `app.use(Avue)` 会渲染成空圆按钮 —— 别误判成组件 bug。
- 组件已加 `deactivated()` 关列设置弹窗(防 keep-alive 下 Teleport 弹窗残留)。
- 已接入(过渡态,待逐页改 avue):business/{master-order,loading-manage,voucher-manage}、settlement、payment 模块手写页。
## avue-crud 列表页改造要点(payment 模块样板,2026-10-14)
- **列显隐抽屉**:点第 2 键弹出,标题"列显隐"(语言包 `crud.showTitle`),表头 列名/隐藏/冻结/过滤/排序。直接读写 avue crud 的列配置,页面无需实现。
- **第 4 键**:`handleGridShow()` = 表格 ↔ 卡片视图(`tableCard` 按列通用渲染),页面无需写卡片模板。
- `searchOrder` 是**降序**排序(值越大越靠前):`list.sort((a,b) => (b.searchOrder||0) - (a.searchOrder||0))`。首次改造踩过坑。
- `searchIndex: 4` + `searchIcon: true` → 默认只显示前 4 个搜索项 + "展开/收起"(columnLen > searchIndex 时才出现该按钮)。
- **搜索项 label 与表格列 label 不同**(列「收款方」/搜索「客户名称」):单独定义 hide 列 + `searchProp` 指向后端字段(avue 在 search-change 里自动改名)。
- 搜索专用列加 `hide: true` + `showColumn: false`,否则会混进列显隐抽屉。
- ⚠️ **`createTime`/`updateTime` 必须显式给 `width: 180`**(全站统一,参考 `option/base/common-address.js`)。不写宽度的列参与"剩余空间均分",容易被压到 80px 出现 `2026-0...` 截断 —— 这是**列缺 width**,不是操作列没固定;`menuFixed: 'right'` 各页早已配好(`main.js` 全局 `crudOption.menuWidth: 180`,avue 默认 220),别用"加宽操作列"来处理时间列截断。创建人/更新人给 `minWidth: 110`。
- ⚠️ **column 数组顺序 = 表格列顺序**:搜索专用列别插在表格列中间(会把表格列顶到前面);deptName 这种"既搜索又展示"的列要放回表格列的正确位置。
- 分页:`page: { currentPage, pageSize, pageSizes, total }`;`on-load` 由 avue 内部(table-page)在 created / 翻页 / 改页容量时触发,`current-change`/`size-change` 里**只改 page 不要再调 onLoad**(否则双请求)。
- `onLoad(page)` 只用 `this.query`(avue 传的 page 对象含 total/pageSizes 等分页字段,别 spread 进查询参数)。
- 搜索参数转换放 `searchChange(params, done)`:日期区间数组拆成 `xxxStartDate/xxxEndDate`;所属组织级联用 `normalizeOrganizationSearch(params, 'deptName')`(见 `mixins/organization-search.js`,option 里该列加 `searchslot: true`)。
- 单元格空值统一 `-`:列级 `formatter: (row, value) => ...`;金额 `Number(v||0).toFixed(2)`;状态/链接用 `slot: true` + `#prop` 插槽。
- 操作列用 `#menu` 插槽(`menuWidth: 320`),`addBtn/viewBtn/delBtn/editBtn: false` 关掉 avue 自带按钮。
## 地址/行政区划展示(全站既有规则)
「省/市/区县」都要空格分段,但**省与自治区不重复展示**:`上海市浦东新区`→`上海市 浦东新区`,`广西壮族自治区南宁市邕宁区`→`南宁市 邕宁区`(同 `formatRoadLocation` / `formatRouteCityDistrict` / 总单列表 `formatRoadAddress` 一致)。
- 统一入口:`src/utils/region-address.js` 的 `formatRegionAddress(value)`(按后缀切分行政区划,`州+市`、`县+区` 连写合并;**只有整串都是行政区划**才重排,站点名「南宁站/广州南站」与含门牌的详细地址原样返回)。v-for 列表里用它做展示时,直接把函数挂进 `methods` 用 ES6 简写(`formatRegionAddress,`)。
- ⚠️ 路线节点的名称字段来自后端 `routeJson`:`routes[i].departureName` = 第 i 段**到货地/途经地**(不是本段发货地),末段 = 最终收货地;`master.departureName/arrivalName` 才是总发货/总收货。总单调度/详情都按「上一个节点的 departureName」拼分段起点。
## 样式硬规则
- 底栏 `flex; justify-content:flex-end`,**不写 gap**(靠 EP 默认 `.el-button+.el-button{margin-left:12px}`);浮动底栏必须 `position:fixed`(祖先 overflow:hidden 使 sticky 失效)+ `:global(.avue--collapse .x){left:60px}`、`:global(.avue-layout--horizontal .x){left:0}`,`:global()` 要包整个选择器。
- 按钮层级:次要=无 type;中间步骤=`primary plain`;主操作=`primary`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]`。
- 独立页标题 `.archive-page-form__title`;分组用全局 `<section-card>`;上传证件区 `width:100%; max-width:240px` + `.el-upload{height:151px}`。
- ⚠️ `<style scoped lang="scss">` 顶层禁 `//` 注释(Vite5 + sass `Unexpected '/'` → 500),顶层用 `/* */`。其余见 `src/styles/element-ui.scss` 与 `AGENTS.md` 4.4。
- **搜索栏「展开/收起」唯一标准写法**(settlement 5 处已用、avue 页面同款):
`<el-button text :icon="searchExpanded ? ArrowUp : ArrowDown" @click="toggleSearch">{{ searchExpanded ? '收起' : '展开' }}</el-button>`
放「查询/重置」**之后**(最右);禁 `el-tooltip` 包图标按钮、禁文案「折叠」。`ArrowUp/ArrowDown` 要 import 并放 `data()`(`:icon` 是表达式)。
显示条件与 avue 一致(`header-search3.js` 的 `isSearchIcon = searchIcon===true && columnLen > searchIndex`):搜索项 **≤4 项不渲染按钮** —— 故 data 里维护 `searchIndex: 4` + `searchItemCount: N`,用 computed `showSearchToggle` 控制。搜索栏操作区 `gap: 8px` 各模块一致,**不要去改**。
## 表单 / 自检 / 导出
- placeholder 只写 `请输入`/`请选择`;例外:`请输入或选择车辆`、日期区间 `起`/`止`、示例值。校验 message 须带字段名。
- `business-crud-page.vue`:option 经 `cloneOption` 克隆,独立表单页走 `PageAvueForm`。
- 自检:`curl "localhost:2889/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`。
- 自检:`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`)。
## Element Plus 图标两种用法的机制差异(易误判)
`main.js` 里 `for (...ElementPlusIconsVue) app.component(key, component)` 全局注册了全部图标,因此模板里可以写标签形式 `<Location />`(靠全局注册解析)。
但 `:icon="OfficeBuilding"` 是**表达式**,编译成 `_ctx.OfficeBuilding`,只会从 data / props / computed / setup 返回值里取 —— **组件对象必须放进 `data()`**;放进 `components: {}` 注册里模板取不到,会静默渲染不出图标(不报错,只是空白)。
项目既有惯例:需要 `:icon` 的图标一律 `import` 后在 `data()` 里 `return { Xxx }`(transport-plan-page、waybill-manage-page、business-crud-page 都是这么写的)。
注:标签形式(如 `<Location />`)不依赖 data,放不放 data 都能显示;仅 `:icon` 必须放 data。
## 全局 `showOverflowTooltip: true` 与自定义多行单元格(2026-10-14 踩)
`main.js` 的 `app.use(ElementPlus, { table: { showOverflowTooltip: true } })`(+ Avue `crudOption.showOverflowTooltip: true`)→ **全站所有表格列默认开启 hover 提示**,EP 会给该列 `.cell` 加 `.el-tooltip` 类(`white-space: nowrap` + 固定宽度 + 溢出省略)。
后果:**列里放自定义多行 / 分组内容时,内层文本继承 nowrap → 横向溢出 → 单元格被裁切 + hover 弹提示**(极易被误判成"组件 bug")。
修法(列级豁免,不动全局):① 列上显式 `:show-overflow-tooltip="false"`;② 内层容器补 `white-space: normal` 兜底。两处都写最稳。
☑️ 已用于路线变更「变更内容」列(`loading-manage.vue` / `components/waybill-manage-page.vue` / `components/business-crud-page.vue` 三处)。
另:`styles/element-ui.scss` 里已有两条基于 `.cell.el-tooltip` 的既有豁免规则(操作列 `:has(.el-link)`、`class-name="is-ellipsis-link"`),改表格列时先看这两条。
+5 -2
View File
@@ -6,7 +6,8 @@
"prod": "vite --mode production",
"build": "vite build",
"build:prod": "vite build --mode production",
"serve": "vite preview --host"
"serve": "vite preview --host",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -47,9 +48,11 @@
"prettier": "^2.8.7",
"sass": "^1.85.1",
"terser": "^5.31.1",
"typescript": "^7.0.2",
"unplugin-auto-import": "^0.11.2",
"vite": "^5.4.19",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-setup-extend": "^0.4.0"
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue-tsc": "^3.3.11"
}
}
+415 -95
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -58,3 +58,14 @@ export const changeStatus = (id, status) => {
},
});
};
export const getBfiExchangeRate = (currencyCode, effectdate) => {
return request({
url: '/blade-transport/bfi/exchange-rate',
method: 'get',
params: {
currencyCode,
effectdate,
},
});
};
+11
View File
@@ -52,6 +52,17 @@ export const createFileTask = data =>
request({ url: `${fileTaskBaseUrl}/createFileTask`, method: 'post', data });
export const getPartUploadUrl = (id, partNumber) =>
request({ url: `${fileTaskBaseUrl}/getUploadUrl`, method: 'get', params: { id, partNumber } });
export const uploadPartByProxy = (id, partNumber, partBlob) => {
const formData = new FormData();
formData.append('file', partBlob, `part-${partNumber}.bin`);
return request({
url: `${fileTaskBaseUrl}/uploadPart`,
method: 'post',
params: { id, partNumber },
data: formData,
timeout: 600000,
});
};
export const updateFileTask = data =>
request({ url: `${fileTaskBaseUrl}/updateFileTask`, method: 'post', data });
export const pauseFileTask = ids =>
+1 -1
View File
@@ -22,7 +22,7 @@ export const approve = data => request({ url: `${baseUrl}/approve`, method: 'pos
export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'post', data });
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', params: { id } });
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', timeout: 30000, params: { id } });
export const invoiceTypeOptions = [
{ label: '电子专票', value: 'electronic_special' },
+18 -8
View File
@@ -5,8 +5,17 @@ const baseUrl = '/blade-transport/invoice-receipt';
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 getInvoicePool = keyword =>
request({ url: `${baseUrl}/invoice-pool`, method: 'get', params: { keyword } });
export const queryInvoicePool = params =>
request({ url: `${baseUrl}/invoice-pool`, method: 'get', params });
export const downloadInvoicePoolAttachment = invoiceNo =>
// 金蝶票池发票文件下载(后端代理 A801-2 流式回源),耗时较普通接口长,放宽到 120s
request({
url: `${baseUrl}/invoice-pool/attachment/download`,
method: 'get',
params: { invoiceNo },
responseType: 'blob',
timeout: 120000,
});
export const getSettlementCandidates = (keyword, receiptId) =>
request({
url: `${baseUrl}/settlement-candidates`,
@@ -25,8 +34,8 @@ export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post'
export const approve = data => request({ url: `${baseUrl}/approve`, method: 'post', data });
export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'post', data });
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', params: { id } });
export const pushKingdee = ids =>
request({ url: `${baseUrl}/push-kingdee`, method: 'post', timeout: 30000, data: ids });
export const approvalStatusOptions = [
{ label: '草稿', value: 'draft' },
@@ -36,8 +45,9 @@ export const approvalStatusOptions = [
{ label: '已作废', value: 'voided' },
];
export const kingdeeStatusOptions = [
{ label: '未同步', value: 'unsynced' },
{ label: '已同步', value: 'synced' },
{ label: '同步失败', value: 'failed' },
export const kingdeePushStatusOptions = [
{ label: '未推送', value: 'unsynced' },
{ label: '已保存收票单', value: 'pushed' },
{ label: '已指定应付', value: 'assigned' },
{ label: '推送失败', value: 'failed' },
];
+17 -2
View File
@@ -10,16 +10,28 @@ export const getReferenceAmount = (paymentType, referenceId, excludeId) =>
method: 'get',
params: { paymentType, referenceId, excludeId: excludeId || undefined },
});
/** 按合同查询可选项目预付核销列表(审批通过且仍有可核销余额) */
export const getProjectAdvanceWriteOffOptions = params =>
request({
url: `${baseUrl}/project-advance-write-off-options`,
method: 'get',
params,
});
export const save = data => request({ url: `${baseUrl}/save`, method: 'post', data });
export const remove = id => request({ url: `${baseUrl}/remove`, method: 'post', params: { id } });
export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post', data });
export const approve = data => request({ url: `${baseUrl}/approve`, method: 'post', data });
export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'post', data });
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
// 同步金蝶涉及多次金蝶远程接口调用(A103-2 预检 + A103-1/A103-3 推送),单笔可达 20s+,覆盖全局 10s 超时
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', params: { id } });
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', timeout: 30000, params: { id } });
// 批量同步逐单单据级事务串行推送,耗时随单量线性增长
export const syncKingdeeBatch = ids =>
request({ url: `${baseUrl}/sync-kingdee-batch`, method: 'post', data: ids });
request({ url: `${baseUrl}/sync-kingdee-batch`, method: 'post', timeout: 30000, data: ids });
// 回写任务按 paymentNo 分批远程查询 A103-2/A103-4,同样可能超过全局超时
export const syncKingdeeResult = () =>
request({ url: `${baseUrl}/sync-kingdee-result`, method: 'post', timeout: 30000 });
export const paymentTypeOptions = [
{ label: '项目预付', value: 'project_advance' },
@@ -36,5 +48,8 @@ export const approvalStatusOptions = [
export const kingdeeStatusOptions = [
{ label: '未生成', value: 'unsynced' },
{ label: '已生成', value: 'synced' },
{ label: '付款中', value: 'paying' },
{ label: '已付款', value: 'paid' },
{ label: '已关闭', value: 'closed' },
{ label: '生成失败', value: 'failed' },
];
+10
View File
@@ -8,14 +8,24 @@ export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get'
export const updateAttachments = data =>
request({ url: `${baseUrl}/attachments`, method: 'post', data });
export const voidClaim = id => request({ url: `${baseUrl}/void`, method: 'post', params: { id } });
export const queryKingdeeResult = () =>
request({ url: `${baseUrl}/query-kingdee-result`, method: 'post' });
export const claimStatusOptions = [
{ label: '已认领', value: 'claimed' },
{ label: '已作废', value: 'voided' },
];
export const pushStatusOptions = [
{ label: '未推送', value: 'none' },
{ label: '已推送', value: 'pushed' },
{ label: '推送失败', value: 'failed' },
];
export const kingdeeBillStatusOptions = [
{ label: '未生成', value: 'none' },
{ label: '已推送', value: 'pushed' },
{ label: '审核通过', value: 'approved' },
{ label: '已收款', value: 'received' },
{ label: '处理失败', value: 'failed' },
];
+16 -1
View File
@@ -12,10 +12,25 @@ export const getSettlementCandidates = (keyword, flowId) =>
params: { keyword, flowId },
});
export const claim = data => request({ url: `${baseUrl}/claim`, method: 'post', data });
export const sync = data => request({ url: `${baseUrl}/sync`, method: 'post', data });
export const sync = () => request({ url: `${baseUrl}/sync`, method: 'post' });
export const pushKingdee = flowId =>
request({ url: `${baseUrl}/push-kingdee`, method: 'post', params: { flowId } });
export const claimStatusOptions = [
{ label: '未认领', value: 'unclaimed' },
{ label: '部分认领', value: 'partial' },
{ label: '认领完成', value: 'claimed' },
];
export const pushStatusOptions = [
{ label: '未推送', value: 'none' },
{ label: '已推送', value: 'pushed' },
{ label: '推送失败', value: 'failed' },
];
export const kingdeeBillStatusOptions = [
{ label: '已推送', value: 'pushed' },
{ label: '审核通过', value: 'approved' },
{ label: '已收款', value: 'received' },
{ label: '处理失败', value: 'failed' },
];
+4 -1
View File
@@ -30,8 +30,9 @@ export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post'
export const approve = data => request({ url: `${baseUrl}/approve`, method: 'post', data });
export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'post', data });
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
// 同步金蝶涉及多次金蝶远程接口调用(A102-4 预检 + A102-2 保存),可达 20s+,覆盖全局 10s 超时
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', params: { id } });
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', timeout: 30000, params: { id } });
export const getDetailFees = detailId =>
request({ url: `${baseUrl}/detail-fees`, method: 'get', params: { detailId } });
export const getReceiptClaims = formalSettlementId =>
@@ -48,3 +49,5 @@ export const applyPayments = data =>
request({ url: `${baseUrl}/apply-payments`, method: 'post', data });
export const claimInvoices = data =>
request({ url: `${baseUrl}/claim-invoices`, method: 'post', data });
export const queryInvoicePool = params =>
request({ url: `${baseUrl}/invoice-pool`, method: 'get', params });
+2
View File
@@ -24,6 +24,8 @@ export const getContractList = (current, size, params) =>
export const getFeeOptions = () => request({ url: `${baseUrl}/fee-options`, method: 'get' });
export const getNextNo = () => request({ url: `${baseUrl}/next-no`, method: 'get' });
export const getCandidateDetails = (current, size, params) =>
request({
url: `${baseUrl}/candidate-details`,
+55
View File
@@ -0,0 +1,55 @@
import request from '@/axios';
const baseUrl = '/blade-transport/settlement-import';
export const getOptions = () => request({ url: `${baseUrl}/options`, method: 'get' });
export const getNextCode = () => request({ url: `${baseUrl}/next-code`, method: 'get' });
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 exportTemplate = () =>
request({
url: `${baseUrl}/export-template`,
method: 'get',
responseType: 'blob',
});
export const parseFile = file => {
const data = new FormData();
data.append('file', file);
return request({
url: `${baseUrl}/parse`,
method: 'post',
data,
timeout: 120000,
});
};
export const validate = data =>
request({ url: `${baseUrl}/validate`, method: 'post', data, timeout: 120000 });
export const exportFailures = data =>
request({
url: `${baseUrl}/export-failures`,
method: 'post',
data,
responseType: 'blob',
timeout: 120000,
});
export const saveDraft = data =>
request({ url: `${baseUrl}/draft`, method: 'post', data, timeout: 120000 });
export const confirm = data =>
request({ url: `${baseUrl}/confirm`, method: 'post', data, timeout: 120000 });
export const voidBatch = data => request({ url: `${baseUrl}/void`, method: 'post', data });
+3 -1
View File
@@ -77,7 +77,8 @@ export const syncOaDepartment = (
size = 20,
signal,
incremental = false,
modifiedSince
modifiedSince,
companyOaId
) => {
return request({
url: '/blade-system/dept/sync-oa-department',
@@ -87,6 +88,7 @@ export const syncOaDepartment = (
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
companyOaId: companyOaId || undefined,
},
timeout: 60000,
signal,
+22
View File
@@ -152,6 +152,28 @@ export const getScoreTemplate = quantificationId => {
});
};
export const queryKingdeeCustomer = creditCode => {
return request({
url: '/blade-transport/bfi/customer/query-by-credit-code',
method: 'get',
timeout: 30000,
params: {
creditCode,
},
});
};
export const syncKingdee = id => {
return request({
url: '/blade-transport/customer-archive/sync-kingdee',
method: 'post',
timeout: 30000,
params: {
id,
},
});
};
export const recognizeBusinessLicenseOcr = imageUrl => {
return request({
url: '/blade-transport/baidu-ocr/recognize-url',
+9 -1
View File
@@ -271,10 +271,18 @@ axios.interceptors.response.use(
},
error => {
NProgress.done();
// 网络级失败(HTTP 状态 >500、网关错误、超时、断网等 validateStatus 放行之外的响应)
// 原为静默 reject,接口报错页面无任何感知;统一补错误提示,取消请求与刷新令牌请求除外
if (!axios.isCancel(error) && !isRefreshTokenRequest(error?.config)) {
ElMessage({
message: error?.response?.data?.msg || '请求失败,请稍后重试',
type: 'error',
});
}
return Promise.reject(new Error(error));
// 保留原始 axios 错误对象:下游(如导入失败明细解析)需要 error.response
// 读取响应体。若包装成 new Error(error),response 会丢失、message 退化成
// "[object Object]",用户只能看到无意义的通用提示。
return Promise.reject(error);
}
);
@@ -0,0 +1,434 @@
<template>
<el-dialog v-model="visible" :title="title" width="92%" append-to-body destroy-on-close @open="handleOpen">
<el-form :model="query" inline label-width="130px" @submit.prevent>
<el-form-item label="发票号码">
<el-input v-model="query.invoiceNo" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="开票日期">
<el-date-picker
v-model="query.invoiceDateRange"
type="daterange"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item label="开票单位">
<el-input v-model="query.salerName" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="受票单位">
<el-input v-model="query.buyerName" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="开票单位(销方)">
<el-select
v-model="query.salerTaxNo"
clearable
filterable
remote
reserve-keyword
:remote-method="keyword => searchCustomers(keyword, 'saler')"
:loading="customerLoading.saler"
placeholder="输入名称搜索或直接粘贴税号"
style="width: 220px"
@keyup.enter="handleQuery"
>
<el-option
v-for="item in customerOptions.saler"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="受票单位(购方)">
<el-select
v-model="query.buyerTaxNo"
clearable
filterable
remote
reserve-keyword
:remote-method="keyword => searchCustomers(keyword, 'buyer')"
:loading="customerLoading.buyer"
placeholder="输入名称搜索或直接粘贴税号"
style="width: 220px"
@keyup.enter="handleQuery"
>
<el-option
v-for="item in customerOptions.buyer"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="发票类型">
<el-select
v-model="query.invoiceType"
clearable
filterable
placeholder="不限"
style="width: 230px"
>
<el-option
v-for="option in invoiceTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="认领状态">
<el-select v-model="query.claimed" clearable placeholder="不限" style="width: 120px">
<el-option label="未认领" :value="false" />
<el-option label="已认领" :value="true" />
</el-select>
</el-form-item>
<el-form-item label="登记状态">
<el-select v-model="query.registered" clearable placeholder="不限" style="width: 120px">
<el-option label="未登记" :value="false" />
<el-option label="已登记" :value="true" />
</el-select>
</el-form-item>
<el-form-item>
<el-button :disabled="loading" @click="resetQuery">重置</el-button>
<el-button type="primary" :disabled="loading" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
<el-table
ref="poolTable"
v-loading="loading"
:data="rows"
:row-key="rowKey"
border
height="520"
:highlight-current-row="!multiple"
@selection-change="selection => (selected = selection)"
@row-dblclick="handleRowDblclick"
>
<el-table-column v-if="multiple" type="selection" width="52" fixed="left" align="center" reserve-selection />
<el-table-column v-else width="52" align="center" fixed="left">
<template #default="{ row }">
<el-radio
:model-value="current && current.invoiceNo"
:value="row.invoiceNo"
@change="current = row"
>
<span></span>
</el-radio>
</template>
</el-table-column>
<el-table-column prop="invoiceNo" label="发票号码" min-width="180" />
<el-table-column prop="invoiceDate" label="开票日期" min-width="120" />
<el-table-column prop="invoiceType" label="发票类型" min-width="140" />
<el-table-column prop="issuerName" label="开票单位" min-width="180" show-overflow-tooltip />
<el-table-column prop="receiverName" label="受票单位" min-width="180" show-overflow-tooltip />
<el-table-column label="金额(含税)" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.invoiceAmount) }}</template>
</el-table-column>
<el-table-column label="税率" min-width="90" align="center">
<template #default="{ row }">{{ formatRate(row.taxRate) }}</template>
</el-table-column>
<el-table-column label="税额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.taxAmount) }}</template>
</el-table-column>
<el-table-column label="剩余可用金额" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.availableTaxAmount) }}</template>
</el-table-column>
<!--<el-table-column prop="kingdeeBillNo" label="金蝶单据号" min-width="210" show-overflow-tooltip />-->
<el-table-column label="认领状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.claimed" type="danger" size="small">已认领</el-tag>
<el-tag v-else type="info" size="small">未认领</el-tag>
</template>
</el-table-column>
<el-table-column label="登记状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.registered" type="warning" size="small">已登记</el-tag>
<el-tag v-else type="info" size="small">未登记</el-tag>
</template>
</el-table-column>
<el-table-column label="附件" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-link
type="primary"
:underline="false"
:disabled="attachmentBusy === row.invoiceNo"
@click.stop="viewAttachment(row)"
>查看</el-link
>
<el-link
type="primary"
:underline="false"
:disabled="attachmentBusy === row.invoiceNo"
@click.stop="downloadAttachment(row)"
>下载</el-link
>
</template>
</el-table-column>
<el-table-column v-if="!multiple" label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click.stop="confirmRows([row])">选择</el-link>
</template>
</el-table-column>
</el-table>
<div class="kingdee-pool-dialog__pager">
<span class="kingdee-pool-dialog__pager-tip">按名称检索范围为已同步票,未找到请改用发票号码或税号精确查找</span>
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:disabled="loading"
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">{{ confirmText }}</el-button>
</template>
</el-dialog>
</template>
<script>
import request from '@/axios';
import { getList as getCustomerList } from '@/api/vehicle/customer-archive';
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
export default {
name: 'KingdeeInvoicePoolDialog',
props: {
modelValue: { type: Boolean, required: true },
title: { type: String, default: '查询金蝶票据池' },
confirmText: { type: String, default: '确定' },
multiple: { type: Boolean, default: false },
initialInvoiceNo: { type: String, default: '' },
queryApi: { type: Function, required: true },
},
emits: ['update:modelValue', 'confirm'],
data() {
return {
loading: false,
// 与后端 BfiConstant.INVOICE_TYPE_NAMES 对齐:value 为发票类型编码(镜像按 invoice_type_code 精确匹配)
// 选票场景只开放数电票(普票/专票)
invoiceTypeOptions: [
{ value: '26', label: '数电发票(普通发票)' },
{ value: '27', label: '数电发票(增值税专用发票)' },
],
rows: [],
selected: [],
current: null,
page: { current: 1, size: 10, total: 0 },
// 正在查看/下载附件的发票号(行级 loading 守卫)
attachmentBusy: null,
// 销/购方客商下拉:远程按名称搜客商档案,选中带出税号查询;手动输入的裸税号直接作为值过滤
customerOptions: { saler: [], buyer: [] },
customerLoading: { saler: false, buyer: false },
query: this.emptyQuery(),
};
},
computed: {
visible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
},
methods: {
emptyQuery() {
return {
invoiceNo: '',
// 默认查近一个月,随请求传后端并在页面回显
invoiceDateRange: [this.$dayjs().subtract(1, 'month').format('YYYY-MM-DD'), this.$dayjs().format('YYYY-MM-DD')],
salerName: '',
buyerName: '',
salerTaxNo: '',
buyerTaxNo: '',
invoiceType: '',
claimed: null,
registered: null,
};
},
// 镜像行主键为 id,回源入库前的响应仍可能无 id,用发票号码兜底
rowKey(row) {
return row.id || row.invoiceNo;
},
// 票池行附件字段为复数 attachmentsJson,行内首项即发票文件({source,path,name})
rowAttachment(row = {}) {
const value = row.attachmentsJson;
if (!value) return null;
let parsed = value;
if (typeof value === 'string') {
try {
parsed = JSON.parse(value);
} catch {
return null;
}
}
return Array.isArray(parsed) ? parsed[0] || null : parsed || null;
},
attachmentName(row) {
const attachment = this.rowAttachment(row);
return attachment?.name || `${row.invoiceNo || '发票'}.pdf`;
},
async viewAttachment(row) {
const attachment = this.rowAttachment(row);
if (!isKingdeePathAttachment(attachment)) {
this.$message.warning('当前发票暂无可查看附件');
return;
}
this.attachmentBusy = row.invoiceNo;
try {
await viewKingdeeInvoiceFile(row.invoiceNo, this.attachmentName(row));
} finally {
this.attachmentBusy = null;
}
},
async downloadAttachment(row) {
const attachment = this.rowAttachment(row);
if (!isKingdeePathAttachment(attachment)) {
this.$message.warning('当前发票暂无可下载附件');
return;
}
this.attachmentBusy = row.invoiceNo;
try {
await downloadKingdeeInvoiceFile(row.invoiceNo, this.attachmentName(row));
} finally {
this.attachmentBusy = null;
}
},
handleOpen() {
this.query = this.emptyQuery();
this.query.invoiceNo = this.initialInvoiceNo || '';
this.current = null;
this.selected = [];
this.$refs.poolTable?.clearSelection();
this.page.current = 1;
this.load();
},
handleQuery() {
this.page.current = 1;
this.load();
},
handleSizeChange() {
this.page.current = 1;
this.load();
},
resetQuery() {
this.query = this.emptyQuery();
this.handleQuery();
},
// 客商名称远程搜索:含 18 位社会信用代码特征(数字+大写字母)时按裸税号直查;
// 否则搜客商档案,取客商发票信息(blade_customer_invoice_info)的抬头/税号作选项——票池税号是开票税号,非统一信用代码
async searchCustomers(keyword, side) {
const text = String(keyword || '').trim();
if (!text) return;
if (/^[0-9A-Z]{15,20}$/.test(text)) {
this.customerOptions[side] = [{ label: `${text}(手输税号)`, value: text }];
return;
}
this.customerLoading[side] = true;
try {
const data = this.unwrap(await getCustomerList(1, 20, { fullName: text }));
const customers = (data.records || []).filter(item => item.id);
if (!customers.length) {
this.customerOptions[side] = [];
return;
}
// 列表接口不带发票信息,逐户取 detail(detail 组装 invoices,每户一张发票信息)
const options = await Promise.all(
customers.map(async customer => {
const detail = this.unwrap(await request({ url: '/blade-transport/customer-archive/detail', method: 'get', params: { id: customer.id } }));
return (detail?.invoices || [])
.filter(invoice => invoice.taxNo)
.map(invoice => ({ label: `${invoice.invoiceTitle || customer.fullName}(${invoice.taxNo})`, value: invoice.taxNo }));
})
);
this.customerOptions[side] = options.flat().filter(
(option, index, list) => list.findIndex(item => item.value === option.value) === index
);
} finally {
this.customerLoading[side] = false;
}
},
unwrap(response) {
const body = response?.data || response || {};
return body?.data || body;
},
async load() {
this.loading = true;
try {
const range = this.query.invoiceDateRange || [];
const data = this.unwrap(
await this.queryApi({
current: this.page.current,
size: this.page.size,
invoiceNo: this.query.invoiceNo || undefined,
invoiceDateStart: range[0] || undefined,
invoiceDateEnd: range[1] || undefined,
salerName: this.query.salerName || undefined,
buyerName: this.query.buyerName || undefined,
salerTaxNo: this.query.salerTaxNo || undefined,
buyerTaxNo: this.query.buyerTaxNo || undefined,
invoiceType: this.query.invoiceType || undefined,
claimed: this.query.claimed ?? undefined,
registered: this.query.registered ?? undefined,
})
);
this.rows = data.records || [];
this.page.total = Number(data.total || 0);
} finally {
this.loading = false;
}
},
handleRowDblclick(row) {
if (!this.multiple) {
this.confirmRows([row]);
}
},
handleConfirm() {
const picked = this.multiple ? this.selected : this.current ? [this.current] : [];
this.confirmRows(picked);
},
confirmRows(rows) {
if (!rows.length) {
this.$message.warning(this.multiple ? '请至少选择一条发票' : '请选择一张发票');
return;
}
this.$emit('confirm', rows);
this.visible = false;
},
formatMoney(value) {
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(2) : '-';
},
formatRate(value) {
const number = Number(value);
return Number.isFinite(number) ? `${number}%` : '-';
},
},
};
</script>
<style lang="scss" scoped>
.kingdee-pool-dialog__pager {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
gap: 16px;
}
.kingdee-pool-dialog__pager-tip {
color: #909399;
font-size: 12px;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div class="avue-crud__right table-header-tools">
<el-button v-if="showRefresh" icon="el-icon-refresh" circle @click="$emit('refresh')" />
<el-button
v-if="showColumn"
icon="el-icon-operation"
circle
@click="columnSettingVisible = true"
/>
<el-button
v-if="showSearch"
icon="el-icon-search"
circle
@click="$emit('update:searchVisible', !searchVisible)"
/>
<el-button v-if="showGrid" icon="el-icon-grid" circle @click="toggleTableSize" />
<el-dialog
v-if="showColumn"
v-model="columnSettingVisible"
title="列设置"
width="360px"
append-to-body
destroy-on-close
>
<el-checkbox-group
class="table-header-tools__column-setting"
:model-value="visibleColumnKeys"
@update:model-value="$emit('update:visibleColumnKeys', $event)"
>
<el-checkbox v-for="item in columns" :key="item.prop" :label="item.prop">
{{ item.label }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="columnSettingVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
const TABLE_SIZE_OPTIONS = ['default', 'large', 'small'];
export default {
name: 'TableHeaderTools',
props: {
columns: {
type: Array,
default: () => [],
},
visibleColumnKeys: {
type: Array,
default: () => [],
},
tableSize: {
type: String,
default: 'default',
},
searchVisible: {
type: Boolean,
default: true,
},
showRefresh: {
type: Boolean,
default: true,
},
showColumn: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: true,
},
showGrid: {
type: Boolean,
default: true,
},
},
emits: ['refresh', 'update:visibleColumnKeys', 'update:tableSize', 'update:searchVisible'],
data() {
return {
columnSettingVisible: false,
};
},
deactivated() {
// keep-alive 缓存页面时关闭列设置弹窗,避免 Teleport 到 body 的弹窗切页后残留
this.columnSettingVisible = false;
},
methods: {
toggleTableSize() {
const index = TABLE_SIZE_OPTIONS.indexOf(this.tableSize);
const next = TABLE_SIZE_OPTIONS[(index + 1) % TABLE_SIZE_OPTIONS.length];
this.$emit('update:tableSize', next);
},
},
};
</script>
<style scoped lang="scss">
.table-header-tools.avue-crud__right {
width: auto;
margin: 0 0 0 auto;
flex: none;
white-space: nowrap;
text-align: right;
}
.table-header-tools__column-setting {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 360px;
overflow: auto;
}
</style>
+132 -11
View File
@@ -9,18 +9,21 @@
<el-table :data="rows" border @selection-change="selectedRows = $event">
<el-table-column v-if="!readonly" type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="vehicle-attachment-table__file-name"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>
{{ attachmentName(row) }}
</span>
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -40,9 +43,23 @@
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column prop="uploadTime" label="上传时间" width="180" align="center" sortable />
<el-table-column v-if="!readonly" label="操作" width="100" align="center" fixed="right">
<template #default="{ $index }">
<el-link type="danger" @click="removeAttachment($index)">删除</el-link>
<el-table-column
v-if="!readonly || showViewActions"
label="操作"
:width="readonly ? 120 : 170"
align="center"
fixed="right"
>
<template #default="{ row, $index }">
<div class="vehicle-attachment-table__actions">
<el-link type="primary" :disabled="kingdeeBusy" @click="previewAttachment(row)"
>查看</el-link
>
<el-link type="primary" :disabled="kingdeeBusy" @click="downloadAttachment(row)"
>下载</el-link
>
<el-link v-if="!readonly" type="danger" @click="removeAttachment($index)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
@@ -68,6 +85,7 @@
width="90%"
top="4vh"
class="vehicle-attachment-table__viewer-dialog"
@closed="handlePreviewClosed"
>
<pdf-preview
v-if="documentPreviewVisible && previewFile.url && previewFile.isPdf"
@@ -108,6 +126,11 @@ import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import PdfPreview from '@/components/pdf-preview/main.vue';
import { downloadFileByUrl } from '@/utils/util';
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
requestKingdeeFile,
} from '@/utils/kingdee-attachment';
const attachmentFileTypes = [
'pdf',
@@ -153,6 +176,16 @@ export default {
type: Number,
default: 500,
},
// 金蝶侧附件(piaozone path 型)代理下载所依赖的发票号;行内自带 invoiceNo 时优先用行内值
kingdeeInvoiceNo: {
type: String,
default: '',
},
// 查看态也展示「查看/下载」操作列(编辑态始终展示,含删除)
showViewActions: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
data() {
@@ -164,6 +197,10 @@ export default {
imagePreviewIndex: 0,
documentPreviewVisible: false,
previewFile: {},
// 金蝶侧附件代理请求进行中(行级按钮共享 loading 守卫,防重复触发)
kingdeeBusy: false,
// 金蝶附件预览用的 objectURL,弹窗关闭时回收
kingdeeObjectUrl: '',
viewerPlugins,
viewerToolbar: {
download: true,
@@ -198,6 +235,12 @@ export default {
attachmentUrl(row = {}) {
return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || row.src || '';
},
kingdeeInvoiceNoOf(row = {}) {
return row.invoiceNo || this.kingdeeInvoiceNo || '';
},
isKingdeeRow(row = {}) {
return isKingdeePathAttachment(row) && Boolean(this.kingdeeInvoiceNoOf(row));
},
attachmentExtension(row = {}) {
const source = String(this.attachmentName(row) || this.attachmentUrl(row)).split('?')[0];
const index = source.lastIndexOf('.');
@@ -245,6 +288,10 @@ export default {
return `${(value / 1024 / 1024).toFixed(1)}MB`;
},
downloadAttachment(row) {
// 金蝶侧附件(piaozone path 型)经后端代理 A801-2 下载,OSS 直链维持前端直连下载
if (this.isKingdeeRow(row)) {
return this.downloadKingdeeAttachment(row);
}
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法下载');
@@ -252,20 +299,47 @@ export default {
}
downloadFileByUrl(url, this.attachmentName(row));
},
async downloadKingdeeAttachment(row) {
if (this.kingdeeBusy) return;
this.kingdeeBusy = true;
try {
await downloadKingdeeInvoiceFile(this.kingdeeInvoiceNoOf(row), this.attachmentName(row));
} finally {
this.kingdeeBusy = false;
}
},
batchDownload() {
const rows = this.selectedRows.length ? this.selectedRows : this.rows;
const downloadableRows = rows.filter(row => this.attachmentUrl(row));
if (!downloadableRows.length) {
const kingdeeRows = rows.filter(row => this.isKingdeeRow(row));
const urlRows = rows.filter(row => !this.isKingdeeRow(row) && this.attachmentUrl(row));
if (!kingdeeRows.length && !urlRows.length) {
this.$message.warning(
this.selectedRows.length ? '所选附件暂无可下载地址' : '暂无可下载附件'
);
return;
}
downloadableRows.forEach((row, index) => {
urlRows.forEach((row, index) => {
window.setTimeout(() => this.downloadAttachment(row), index * 500);
});
// 金蝶侧附件走代理接口,串行触发避免并发重复回源
this.downloadKingdeeRows(kingdeeRows);
},
async downloadKingdeeRows(rows) {
if (this.kingdeeBusy) return;
this.kingdeeBusy = true;
try {
for (const row of rows) {
await downloadKingdeeInvoiceFile(this.kingdeeInvoiceNoOf(row), this.attachmentName(row));
}
} finally {
this.kingdeeBusy = false;
}
},
previewAttachment(row) {
// 金蝶侧附件经 A801-2 代理取流后进弹窗预览,OSS 直链维持原逻辑
if (this.isKingdeeRow(row)) {
return this.previewKingdeeAttachment(row);
}
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法预览');
@@ -288,6 +362,42 @@ export default {
};
this.documentPreviewVisible = true;
},
async previewKingdeeAttachment(row) {
// OFD 版式文件浏览器无法在线渲染,直接提示下载,避免拉流后预览失败
if (this.attachmentExtension(row) === 'ofd') {
this.$message.warning('OFD 版式文件不支持在线预览,请使用下载获取后查看');
return;
}
if (this.kingdeeBusy) return;
this.kingdeeBusy = true;
try {
const blob = await requestKingdeeFile(this.kingdeeInvoiceNoOf(row));
this.releaseKingdeeObjectUrl();
const isPdf = this.isPdf(row) || String(blob.type || '').includes('application/pdf');
this.kingdeeObjectUrl = URL.createObjectURL(blob);
this.previewFile = {
name: this.attachmentName(row),
url: this.kingdeeObjectUrl,
mimeType: isPdf ? 'application/pdf' : blob.type || '',
isPdf,
};
this.documentPreviewVisible = true;
} catch (error) {
if (!error?.silent) this.$message.error(error?.message || '附件预览失败');
} finally {
this.kingdeeBusy = false;
}
},
handlePreviewClosed() {
this.releaseKingdeeObjectUrl();
this.previewFile = {};
},
releaseKingdeeObjectUrl() {
if (this.kingdeeObjectUrl) {
URL.revokeObjectURL(this.kingdeeObjectUrl);
this.kingdeeObjectUrl = '';
}
},
handlePreviewUnsupported() {
this.$message.warning('当前文件暂不支持在线预览');
},
@@ -318,6 +428,17 @@ export default {
text-decoration: underline;
}
&__actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
:deep(.el-link) {
font-size: 13px;
}
}
&__upload {
display: flex;
justify-content: flex-start;
+4 -1
View File
@@ -38,7 +38,6 @@ export const createOption = () => ({
delBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 240,
menuFixed: 'right',
column: [
{
@@ -112,6 +111,7 @@ export const createOption = () => ({
{
label: '创建人',
prop: 'createUserName',
minWidth: 110,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
@@ -120,6 +120,7 @@ export const createOption = () => ({
{
label: '更新人',
prop: 'updateUserName',
minWidth: 110,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
@@ -131,6 +132,7 @@ export const createOption = () => ({
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
width: 180,
sortable: true,
addDisplay: false,
editDisplay: false,
@@ -142,6 +144,7 @@ export const createOption = () => ({
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
width: 180,
sortable: true,
addDisplay: false,
editDisplay: false,
+3 -2
View File
@@ -97,7 +97,8 @@ export const config = {
action: 'startChange',
label: '变更',
statusProp: 'approvalStatus',
status: ['approved'],
// 立项通过、变更通过后均可再次发起变更
status: ['approved', 'change_approved'],
permission: 'project_apply_change',
},
],
@@ -185,7 +186,7 @@ export const option = createCrudOption([
searchPlaceholder: '请输入',
searchOrder: 8,
minWidth: 150,
rules: textRule('平台公司', 50, true),
rules: textRule('平台公司', 500, true),
},
{
label: '平台公司ID',
+2
View File
@@ -13,6 +13,8 @@ export const invoiceApplicationTableColumns = [
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 130 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 150 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 140 },
{ prop: 'kingdeeLockStatus', label: '金蝶锁状态', minWidth: 150 },
{ prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 },
];
export const invoiceApplicationDetailColumns = [
+4 -2
View File
@@ -11,6 +11,8 @@ export const invoiceReceiptTableColumns = [
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 130 },
{ prop: 'createUserName', label: '创建人', minWidth: 110 },
{ prop: 'createTime', label: '创建时间', minWidth: 170 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 150 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 140 },
{ prop: 'kingdeeReceivedInvoiceNo', label: '金蝶单据号', minWidth: 150 },
{ prop: 'kingdeePushStatusName', label: '金蝶状态', minWidth: 130 },
{ prop: 'kingdeePushMsg', label: '失败原因', minWidth: 160 },
{ prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 },
];
+167 -20
View File
@@ -1,20 +1,167 @@
export const paymentApplicationTableColumns = [
{ prop: 'paymentNo', label: '单据号', minWidth: 140, link: true },
{ prop: 'paymentTypeName', label: '付款类型', minWidth: 110 },
{ prop: 'projectName', label: '所属项目', minWidth: 140 },
{ prop: 'deptName', label: '所属组织', minWidth: 120 },
{ prop: 'contractNo', label: '合同编号', minWidth: 130 },
{ prop: 'contractName', label: '合同名称', minWidth: 140 },
{ prop: 'payerName', label: '付款方', minWidth: 130 },
{ prop: 'payeeName', label: '收款方', minWidth: 130 },
{ prop: 'appliedAmount', label: '申请付款金额', minWidth: 140, money: true },
{ prop: 'paidAmount', label: '已付款金额', minWidth: 130, money: true },
{ prop: 'matchedInvoiceAmount', label: '匹配发票金额', minWidth: 130, money: true },
{ prop: 'applicantName', label: '申请人', minWidth: 100 },
{ prop: 'applyDate', label: '申请日期', minWidth: 120 },
{ prop: 'approvalStatusName', label: '审核状态', minWidth: 110, status: true },
{ prop: 'currentNode', label: '当前节点', minWidth: 120 },
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 120 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 130 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 130 },
];
/**
* 付款申请列表(avue-crud)配置
* ------------------------------------------------------------------
* 列表页与其它模块统一使用 avue-crud,头部 4 个按钮(刷新 / 列显隐 / 搜索显隐 /
* 表格卡片切换)由 avue-crud 自带,不做二次封装。
*
* 搜索项与表格列 label 不一致的(如表格列「收款方」/ 搜索项「客户名称」),
* 单独定义一个 hide 的搜索列,并用 searchProp 映射到后端字段。
*
* ⚠️ avue 的 searchOrder 为降序排序,值越大越靠前(见 header-search2.js:
* `list.sort((a, b) => (b.searchOrder || 0) - (a.searchOrder || 0))`)。
*/
const textFormatter = (row, value) =>
value === null || value === undefined || value === '' ? '-' : value;
const moneyFormatter = (row, value) => Number(value || 0).toFixed(2);
// 仅用于搜索、不在表格中出现的列
const searchOnlyColumn = ({ label, prop, searchOrder, searchProp, dicData }) => {
const column = {
label,
prop,
search: true,
searchOrder,
searchPlaceholder: '请输入',
hide: true,
showColumn: false,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
};
if (searchProp) column.searchProp = searchProp;
if (dicData) {
column.type = 'select';
column.searchPlaceholder = '请选择';
column.dicData = dicData;
}
return column;
};
export const paymentApplicationOption = ({
paymentTypeOptions = [],
approvalStatusOptions = [],
kingdeeStatusOptions = [],
} = {}) => ({
height: 'auto',
calcHeight: 32,
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
searchShow: true,
searchIcon: true,
searchLabelWidth: 88,
searchMenuSpan: 24,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
indexFixed: 'left',
selection: true,
selectionWidth: 52,
selectionFixed: 'left',
addBtn: false,
viewBtn: false,
delBtn: false,
editBtn: false,
copyBtn: false,
menuWidth: 320,
menuFixed: 'right',
dialogClickModal: false,
column: [
// ---------- 搜索项(前 4 项默认展示,其余折叠;searchOrder 越大越靠前) ----------
{
label: '单据号',
prop: 'paymentNo',
slot: true,
search: true,
searchOrder: 9,
searchPlaceholder: '请输入',
minWidth: 150,
},
{
label: '日期',
prop: 'applyDateRange',
type: 'daterange',
searchType: 'daterange',
search: true,
searchOrder: 8,
hide: true,
showColumn: false,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
searchOnlyColumn({
label: '客户名称',
prop: 'payeeNameSearch',
searchOrder: 7,
searchProp: 'payeeName',
}),
searchOnlyColumn({
label: '项目名称',
prop: 'projectNameSearch',
searchOrder: 6,
searchProp: 'projectName',
}),
searchOnlyColumn({
label: '关联结算单',
prop: 'settlementNo',
searchOrder: 4,
}),
searchOnlyColumn({
label: '付款类型',
prop: 'paymentType',
searchOrder: 3,
dicData: paymentTypeOptions,
}),
searchOnlyColumn({
label: '审核状态',
prop: 'approvalStatus',
searchOrder: 2,
dicData: approvalStatusOptions,
}),
searchOnlyColumn({
label: '金蝶单据状态',
prop: 'kingdeeStatus',
searchOrder: 1,
dicData: kingdeeStatusOptions,
}),
// ---------- 表格列 ----------
{ label: '付款类型', prop: 'paymentTypeName', minWidth: 110, formatter: textFormatter },
{ label: '所属项目', prop: 'projectName', minWidth: 140, formatter: textFormatter },
{
label: '所属组织',
prop: 'deptName',
search: true,
searchOrder: 5,
searchslot: true,
minWidth: 120,
formatter: textFormatter,
},
{ label: '合同编号', prop: 'contractNo', minWidth: 130, formatter: textFormatter },
{ label: '合同名称', prop: 'contractName', minWidth: 140, formatter: textFormatter },
{ label: '付款方', prop: 'payerName', minWidth: 130, formatter: textFormatter },
{ label: '收款方', prop: 'payeeName', minWidth: 130, formatter: textFormatter },
{ label: '申请付款金额', prop: 'appliedAmount', minWidth: 140, formatter: moneyFormatter },
{ label: '已付款金额', prop: 'paidAmount', minWidth: 130, formatter: moneyFormatter },
{
label: '匹配发票金额',
prop: 'matchedInvoiceAmount',
minWidth: 130,
formatter: moneyFormatter,
},
{ label: '申请人', prop: 'applicantName', minWidth: 100, formatter: textFormatter },
{ label: '申请日期', prop: 'applyDate', minWidth: 120, formatter: textFormatter },
{ label: '审核状态', prop: 'approvalStatusName', slot: true, minWidth: 110 },
{ label: '当前节点', prop: 'currentNode', minWidth: 120, formatter: textFormatter },
{ label: '当前处理人', prop: 'currentProcessor', minWidth: 120, formatter: textFormatter },
{ label: '金蝶单据号', prop: 'kingdeeBillNo', minWidth: 130, formatter: textFormatter },
{ label: '金蝶单据状态', prop: 'kingdeeStatusName', minWidth: 130, formatter: textFormatter },
{ label: '金蝶锁状态', prop: 'kingdeeLockStatus', slot: true, minWidth: 150 },
{ label: '附件同步', prop: 'attachmentSyncStatusName', minWidth: 100, formatter: textFormatter },
],
});
+6
View File
@@ -8,5 +8,11 @@ export const receiptFlowTableColumns = [
{ prop: 'summary', label: '摘要', minWidth: 160 },
{ prop: 'transactionTime', label: '交易时间', minWidth: 170 },
{ prop: 'claimStatusName', label: '认领状态', minWidth: 110, status: true },
{ prop: 'kingdeeClaimStatusName', label: '公告状态', minWidth: 110 },
{ prop: 'claimedAmount', label: '已认领金额', minWidth: 130, money: true },
{ prop: 'unclaimedAmount', label: '金蝶未认领金额', minWidth: 140, money: true },
{ prop: 'pushStatusName', label: '推送状态', minWidth: 110, status: true },
{ prop: 'pushFailReason', label: '推送失败原因', minWidth: 160 },
{ prop: 'kingdeeBillNo', label: '金蝶收款单号', minWidth: 150 },
{ prop: 'kingdeeBillStatusName', label: '金蝶状态', minWidth: 110 },
];
@@ -21,6 +21,8 @@ export const formalSettlementTableColumns = [
{ label: '当前处理人', prop: 'currentProcessor', minWidth: 130 },
{ label: '金蝶单据号', prop: 'kingdeeBillNo', minWidth: 150 },
{ label: '金蝶单据状态', prop: 'kingdeeSyncStatus', minWidth: 135 },
{ label: '金蝶锁状态', prop: 'kingdeeLockStatus', minWidth: 150 },
{ label: '附件同步', prop: 'attachmentSyncStatusName', minWidth: 100 },
{ label: '创建人', prop: 'createUserName', minWidth: 110 },
{ label: '创建时间', prop: 'createTime', minWidth: 170 },
];
@@ -33,8 +35,8 @@ export const sourceColumns = [
];
export const detailColumns = [
{ label: '单据号', prop: 'documentNo', minWidth: 180 },
{ label: '运单号', prop: 'waybillNo', minWidth: 160 },
{ label: '单据号', prop: 'documentNo', minWidth: 180, link: true },
{ label: '运单号', prop: 'waybillNo', minWidth: 160, link: true },
{ label: '车号', prop: 'vehicleNo', minWidth: 130 },
{ label: '发货地址', prop: 'departureAddress', minWidth: 180 },
{ label: '到货地址', prop: 'arrivalAddress', minWidth: 180 },
+1 -1
View File
@@ -40,7 +40,7 @@ export const settlementSummaryColumns = [
export const settlementDetailColumns = [
{ label: '单据号', prop: 'documentNo', minWidth: 180, link: true },
{ label: '运单号', prop: 'waybillNo', minWidth: 160 },
{ label: '运单号', prop: 'waybillNo', minWidth: 160, link: true },
{ label: '车号', prop: 'vehicleNo', minWidth: 130 },
{ label: '发货地址', prop: 'departureAddress', minWidth: 180 },
{ label: '到货地址', prop: 'arrivalAddress', minWidth: 180 },
@@ -88,6 +88,8 @@ export const updateFeeFormFields = [
];
export const transferSearchFields = [
{ label: '单据号', prop: 'documentNo', type: 'input' },
{ label: '合同编码', prop: 'contractNo', type: 'input' },
{ label: '合同名称', prop: 'contractName', type: 'input' },
{ label: '批次号', prop: 'batchNo', type: 'input' },
{ label: '生成日期', prop: 'generateDateRange', type: 'daterange' },
+3 -1
View File
@@ -56,7 +56,7 @@ export default {
addBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 280,
menuWidth: 320,
column: [
{
label: '手机号',
@@ -140,8 +140,10 @@ export default {
prop: 'openid',
minWidth: 180,
overHidden: true,
hide: true,
addDisplay: false,
editDisplay: true,
viewDisplay: true,
order: 40,
placeholder: '可清空以解绑微信',
},
+1 -1
View File
@@ -97,7 +97,7 @@ export const userOption = safe => {
{
label: '账号名',
prop: 'account',
span: 12,
span: 6,
rules: [
{
required: true,
@@ -12,6 +12,9 @@ export const option = {
height: 'auto',
calcHeight: 32,
dialogWidth: 1200,
// 弹窗根节点 class:avue-crud 弹窗默认 append-to-body 到 body,
// 页面内 scoped 样式命中不到弹窗内容,故用该 class 在全局样式里做兜底(见 styles/element-ui.scss)
dialogCustomClass: 'vehicle-dispatch-dialog',
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
+15
View File
@@ -119,6 +119,21 @@ export default [
},
],
},
{
path: '/settlement/settlement-import/form',
component: Layout,
children: [
{
path: '',
name: '新增导入结算单',
meta: {
keepAlive: false,
activeMenu: '/settlement/settlement-import',
},
component: loadView(() => import('@/views/settlement/settlement-import-form.vue')),
},
],
},
{
path: '/settlement/transport-reconciliation/form',
component: Layout,
+86 -1
View File
@@ -367,7 +367,7 @@
// 4) 弹窗内表单项上下间距:预留绝对定位校验提示(约 14~18px)的可视空间
.el-dialog .el-form-item {
margin-bottom: 24px;
//margin-bottom: 24px;
}
// 校验提示浮在控件下方,提高层级避免被下一行输入框背景遮挡
@@ -652,6 +652,28 @@
background: #fff;
}
// 车辆调度新增/编辑/查看弹窗:使用部门 el-cascader 默认 display: inline-block、宽度按内容收缩,
// 会比同行的 el-input(--el-input-width 默认 100%)窄一大截,需显式拉满列宽。
// 两个必须注意的点(踩过):
// 1) avue-crud 弹窗默认 append-to-body 到 body,页面内 `.vehicle-dispatch-page :deep(...)`
// 这类以页面容器为祖先的选择器匹配不到弹窗内容,必须用弹窗自身 class(dialogCustomClass)在全局兜底;
// 2) avue 表单项在 el-form-item__content 与控件之间还有两层包裹 div
// (form/index3.js 渲染:content > div > [div|elTooltip] > 控件),
// 所以要用后代选择器,不能写 `>` 直接子级。
.vehicle-dispatch-dialog .el-form-item__content .el-cascader {
width: 100%;
max-width: 100%;
}
// 车辆调度弹窗 label 宽度统一:option 用 labelWidth: 'auto' 时 label 宽度随字数变化
// (3 字 64px / 2 字 40px / 4 字带星号 78px),控件可用宽度随之变化,
// 于是同一行两列控件宽度不一致(如车牌号 486px vs 使用部门 472px)。
// 本弹窗左右两列最长 label 均为 4 个汉字(所属组织 / 使用部门 / 申请时间),故统一按 4em + 24px 取宽。
.vehicle-dispatch-dialog .el-form-item__label {
flex: 0 0 calc(4em + 24px) !important;
width: calc(4em + 24px) !important;
}
// 过程配置新增/编辑/查看弹窗(手写 el-dialog,参照 vehicle/credit-score-quantification):
// 灰底弹窗体(全站 .el-dialog__body 已统一)内堆叠 <section-card> 分组白卡,
// 卡间 12px 间距由 section-card 自身 margin-bottom 保证,与项目其他弹窗风格统一
@@ -1003,6 +1025,66 @@ td.el-table-fixed-column--right {
text-overflow: clip;
}
// 全站统一:内容型长文本列(文件名 / 单号 / 备注等)单元格内也放了 el-link / span.attachment-file-name,
// 会被上面两条「含 el-link 的单元格」规则一并命中,导致:
// ① 上面第 950 行的 display: flex 让链接成为 flex item,flex item 默认 min-width: auto 无法收缩,
// 超长文本(如未规范命名的附件名)会直接把列撑破;
// ② 豁免规则的 overflow: visible 让溢出内容不再被单元格裁切,直接压到相邻列上。
// 因此对「内容列」做显式兜底:单元格自身恢复块级 + 省略号截断,链接参与收缩并显示省略号。
// 用法:在 <el-table-column> 上配置 class-name="is-ellipsis-link"(动态列可 :class-name 绑定)。
// 操作列不要加此类,否则会失去自动换行能力。
.el-table .el-table__body .cell.is-ellipsis-link {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.el-link {
display: inline-block;
min-width: 0;
max-width: 100%;
overflow: hidden;
vertical-align: middle;
// Element Plus 的 el-link__inner 是 inline-flex,flex 容器上的 text-overflow 不生效,
// 必须降级为块级容器,省略号才会真正渲染出来。
.el-link__inner {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.attachment-file-name {
display: inline-block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
}
// 附件上传表格:文件名统一用 span,视觉与可点击行为对齐原 el-link primary
.attachment-file-name {
color: var(--el-color-primary, #409eff);
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
&.is-disabled {
color: var(--el-text-color-placeholder, #c0c4cc);
cursor: not-allowed;
text-decoration: none;
}
}
// 全站统一:Avue 内置行操作按钮(查看 / 编辑 / 删除 / 复制等)视觉对齐为 el-link 链接样式。
// 选择器仅命中表格行操作列 .avue-crud__menu 内的 is-text 按钮,不误伤顶部工具栏(.avue-crud__header)
// 与弹窗按钮(.avue-crud__dialog__menu)。事件(rowView/rowEdit/rowDel)与权限(permission 指令)
@@ -1261,3 +1343,6 @@ td.el-table-fixed-column--right {
cursor: not-allowed;
}
}
.is-ellipsis-link {
overflow: hidden;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 金蝶侧发票附件查看/下载公共工具
*
* 票池发票附件为 {source:'piaozone', path, name}:path 是金蝶侧文件 id(非 http 直链),
* 须由后端代理 A801-2 流式回源;OSS 直链附件(url/link/fileUrl)仍由前端直连下载/预览。
* 入口共用:票池弹窗、正式结算认领发票行、付款申请发票附件行、收票登记附件表。
*/
import { ElMessage } from 'element-plus';
import { downloadFile } from '@/utils/util';
import { downloadInvoicePoolAttachment } from '@/api/payment/invoiceReceipt';
/**
* 金蝶侧附件判定:source=piaozone,或有 path 且非 http(s) 直链
* @param {Object|Null} attachment 附件对象(attachmentsJson/attachmentJson 行内首项)
* @returns {Boolean}
*/
export const isKingdeePathAttachment = attachment => {
if (!attachment) return false;
if (attachment.source === 'piaozone') return true;
return Boolean(attachment.path) && !/^https?:\/\//i.test(attachment.path);
};
/**
* 请求金蝶文件流:成功返回 Blob;业务失败为 HTTP 200 + R.fail JSON(响应通道未写流时后端即失败),
* 按 Content-Type 识别并抛出带 msg 的错误;网络级失败拦截器已提示,标记 silent 避免重复提示。
* 供查看(弹窗内预览)/下载两类入口复用
* @param {String} invoiceNo 发票号码
* @returns {Promise<Blob>}
*/
export const requestKingdeeFile = async invoiceNo => {
let res;
try {
res = await downloadInvoicePoolAttachment(invoiceNo);
} catch (error) {
throw Object.assign(new Error(error?.message || '附件下载失败'), { silent: true });
}
const blob = res?.data;
if (!(blob instanceof Blob)) {
throw new Error('响应不是文件流');
}
if (blob.type && blob.type.includes('application/json')) {
let message = '附件下载失败';
try {
const parsed = JSON.parse(await blob.text());
message = parsed.msg || parsed.message || message;
} catch {
// 错误体非 JSON 时按默认提示
}
throw new Error(message);
}
return blob;
};
/**
* 下载金蝶侧发票附件(经 A801-2 后端代理),失败统一 ElMessage 提示
* @param {String} invoiceNo 发票号码
* @param {String} fileName 下载文件名(缺省按 发票号.pdf)
*/
export const downloadKingdeeInvoiceFile = async (invoiceNo, fileName) => {
if (!invoiceNo) {
ElMessage.warning('当前发票缺少发票号码,无法下载金蝶侧附件');
return;
}
try {
const blob = await requestKingdeeFile(invoiceNo);
downloadFile(blob, fileName || `${invoiceNo}.pdf`);
} catch (error) {
if (!error?.silent) ElMessage.error(error?.message || '金蝶附件下载失败');
}
};
/**
* 新窗口预览金蝶侧发票附件:拉取 Blob 转 objectURL(PDF 可预览;OFD 等浏览器不可预览的格式由浏览器触发下载)
* @param {String} invoiceNo 发票号码
* @param {String} fileName 预览文件名(缺省按 发票号.pdf)
*/
export const viewKingdeeInvoiceFile = async (invoiceNo, fileName) => {
if (!invoiceNo) {
ElMessage.warning('当前发票缺少发票号码,无法预览金蝶侧附件');
return;
}
try {
const blob = await requestKingdeeFile(invoiceNo);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// 延迟释放 objectURL:立即回收可能导致新窗口尚未加载完成即失效
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (error) {
if (!error?.silent) ElMessage.error(error?.message || '金蝶附件预览失败');
}
};
+65 -13
View File
@@ -61,9 +61,18 @@ export const MK_BIZ = {
const MK_SWITCH_NAME = '开启MK';
async function loadMkTemplateList() {
/** 进程内缓存,避免每次提交都打字典;登录后同一会话开关一般不变 */
let mkEnabledCache = null;
let mkEnabledPromise = null;
let mkTemplateListCache = null;
async function loadMkTemplateList(force = false) {
if (!force && mkTemplateListCache) {
return mkTemplateListCache;
}
const res = await getDictionary({ code: 'mk_template' });
return res?.data?.data || [];
mkTemplateListCache = res?.data?.data || [];
return mkTemplateListCache;
}
/** 业务字典 mk_template:名称「开启MK」且键值为 1 时才请求 MK */
@@ -72,10 +81,37 @@ export function isMkEnabled(list = []) {
return String(matched?.dictKey ?? '').trim() === '1';
}
/** 加载业务字典并判断是否开启 MK */
export async function loadMkEnabled() {
const list = await loadMkTemplateList();
return isMkEnabled(list);
/** 清除 MK 开关缓存(字典变更后可调用) */
export function clearMkEnabledCache() {
mkEnabledCache = null;
mkEnabledPromise = null;
mkTemplateListCache = null;
}
/**
* 加载业务字典并判断是否开启 MK
* @param {boolean} [force=false] 是否强制刷新缓存
*/
export async function loadMkEnabled(force = false) {
if (!force && mkEnabledCache !== null) {
return mkEnabledCache;
}
if (!force && mkEnabledPromise) {
return mkEnabledPromise;
}
mkEnabledPromise = loadMkTemplateList(force)
.then(list => {
mkEnabledCache = isMkEnabled(list);
return mkEnabledCache;
})
.catch(error => {
mkEnabledCache = false;
throw error;
})
.finally(() => {
mkEnabledPromise = null;
});
return mkEnabledPromise;
}
export async function resolveMkTemplateCode(dictName, list) {
@@ -99,19 +135,22 @@ export function isMkApprovedStatus(bizType, approvalStatus) {
}
/**
* 提交 MK 审核流
* 提交 MK 审核流。未开启 MK 时直接跳过,不请求流程实例相关接口。
* @param {Object} options
* @param {string} options.bizType 业务类型
* @param {string|number} options.bizId 业务主键(后端据此生成/复用 formInstanceId)
* @param {string|number} options.bizId 业务主键
* @param {string} [options.subjectName] 标题后缀
* @param {string} [options.approvalStatus] 提交前的审批状态(须在业务 submit 改状态前取值;通过态强制新建实例)
* @param {string} [options.approvalStatus] 提交前的审批状态
* @param {boolean} [options.mkEnabled] 调用方已知开关时传入,false 时立即跳过
* @returns {Promise<boolean>} true=已发起/复用 MK 流程,false=未开启或已跳过
*/
export async function submitMkApprovalFlow({
bizType,
bizId,
subjectName = '',
approvalStatus = '',
}) {
mkEnabled,
} = {}) {
const conf = MK_BIZ[bizType];
if (!conf) {
return Promise.reject(new Error(`不支持的MK业务类型:${bizType}`));
@@ -119,17 +158,29 @@ export async function submitMkApprovalFlow({
if (bizId == null || bizId === '') {
return Promise.reject(new Error('业务id不能为空'));
}
// 调用方显式关闭,或字典未开启:不请求 hasMkProcessInstance / processSubmit 等 MK 接口
if (mkEnabled === false) {
return false;
}
const enabled = mkEnabled === true ? true : await loadMkEnabled();
if (!enabled) {
return false;
}
const list = await loadMkTemplateList();
if (!isMkEnabled(list)) {
return;
mkEnabledCache = false;
return false;
}
const bizIdText = String(bizId);
const forceNewFormInstance = isMkApprovedStatus(bizType, approvalStatus);
// 审核通过后再提交必须新建流程;其余场景已有有效实例则跳过
if (!forceNewFormInstance) {
const existsRes = await hasMkProcessInstance({ bizId: bizIdText });
if (existsRes?.data?.data) {
return;
return true;
}
}
if ((conf.rejected || []).includes(approvalStatus)) {
@@ -139,11 +190,12 @@ export async function submitMkApprovalFlow({
const subject = subjectName
? `${conf.subjectPrefix}:${subjectName}`
: `${conf.subjectPrefix}:${bizIdText}`;
return processSubmit({
await processSubmit({
templateCode,
bizId: bizIdText,
subject,
bizType,
forceNewFormInstance,
});
return true;
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 行政区划展示格式化(仅展示层,不改动后端存储值)。
*
* 需求:省 / 市 / 区县之间要有空格隔开,避免「上海市浦东新区」「广西壮族自治区南宁市邕宁区」挤成一坨。
* 与全站既有规则保持一致(公路地址按「市 区县」展示,省/自治区不重复展示):
* 上海市浦东新区 -> 上海市 浦东新区
* 广西壮族自治区南宁市邕宁区 -> 南宁市 邕宁区
* 梅州市梅县区 -> 梅州市 梅县区
*
* 非纯行政区划串(站点名「南宁站」「广州南站」、含街道门牌的详细地址等)原样返回,不做任何改动。
*/
// 行政区划后缀:长后缀在前,避免「自治区」被截成「区」、「自治州」被截成「州」
const REGION_SUFFIXES = [
'特别行政区',
'自治区',
'自治州',
'自治县',
'自治旗',
'开发区',
'新区',
'林区',
'矿区',
'地区',
'省',
'市',
'州',
'盟',
'区',
'县',
'旗',
];
// 省级区划:展示时不在区划链首重复(「广西壮族自治区南宁市邕宁区」-> 「南宁市 邕宁区」)
const PROVINCE_SUFFIX = /(?:省|自治区|特别行政区)$/;
/**
* 从文本头部匹配第一个行政区划片段。
* 特例:「梅州市」州后紧跟市,并入同一片段;「梅县区」县/旗后紧跟区,并入同一片段。
* @param {string} text 已去除空白的文本
* @returns {string} 片段,未匹配到返回 ''
*/
function matchRegionToken(text) {
for (let end = 0; end < text.length; end += 1) {
const prefix = text.slice(0, end + 1);
const suffix = REGION_SUFFIXES.find(item => prefix.endsWith(item));
if (!suffix) continue;
const next = text[end + 1] || '';
if ((suffix === '州' || suffix === '盟') && next === '市') continue;
if ((suffix === '县' || suffix === '旗') && next === '区') return text.slice(0, end + 2);
return prefix;
}
return '';
}
/**
* 按行政区划后缀把文本切成区划片段。
* @param {string} value 原值
* @returns {string[]|null} 整串都是行政区划时返回片段数组,否则返回 null
*/
export function splitRegionTokens(value) {
const text = String(value ?? '').replace(/\s+/g, '');
if (!text) return null;
const tokens = [];
let rest = text;
while (rest) {
const token = matchRegionToken(rest);
if (!token) return null;
tokens.push(token);
rest = rest.slice(token.length);
}
return tokens;
}
/**
* 行政区划串按「市 区县」展示,省/自治区不重复展示。
* @param {string} value 原值
* @returns {string} 含空格的展示值;非纯行政区划串原样返回
*/
export function formatRegionAddress(value) {
const text = String(value ?? '').trim();
if (!text) return '';
const tokens = splitRegionTokens(text);
if (!tokens || !tokens.length) return text;
const display = PROVINCE_SUFFIX.test(tokens[0]) ? tokens.slice(1) : tokens;
return (display.length ? display : tokens).join(' ');
}
export default formatRegionAddress;
+133
View File
@@ -0,0 +1,133 @@
/**
* 路线变更记录内容解析
*
* 存储格式(单行):
* YD-20260904-0022:发货地 A → B;到货地 C → D;YD-20260904-0025:发货地 E → F;到货地 G → H
* 路线顺序:A → B → C 调整为 B → A → C
*
* 解析结果(分组,供变更记录列表按「分组标题 - 字段 - 变更前/后」渲染):
* [
* {
* title: 'YD-20260904-0022',
* text: '',
* fields: [{ label: '发货地', before: 'A', after: 'B', single: 'B', changed: true, unchanged: false }],
* },
* { title: '', text: '无法识别的原文', fields: [] },
* ]
*
* 说明:仅用于展示层,不改变已存储的 content 文本(历史数据同样适用)。
*/
// 长标签必须排在短标签前(「发货地址」优先于「发货地」),否则会被前缀吃掉
const FIELD_LABELS = ['发货地址', '到货地址', '发货地', '到货地', '装货地', '卸货地', '收货地'];
const FIELD_LABEL_PATTERN = new RegExp(`^(${FIELD_LABELS.join('|')})\\s*([\\s\\S]*)$`);
// 变更前 / 变更后分隔符:→、➔、➜、->、-->、–>、—>
const ARROW_PATTERN = /\s*(?:→|➔|➜|[\-–—]{1,2}\s*>)\s*/;
// 运单地址之外的变更描述,例如「A → B → C 调整为 B → A → C」
const ADJUST_PATTERN = /^([\s\S]*?)\s调整为\s*([\s\S]*)$/;
const EMPTY_TEXT = ['', '-', '--', '—', 'null', 'undefined'];
// 冒号前内容超过该长度时不视为分组标题(运单号通常长于该值)
const TITLE_MAX_LENGTH = 12;
const normalizeValue = value => {
const text = String(value === undefined || value === null ? '' : value).trim();
return EMPTY_TEXT.includes(text.toLowerCase()) ? '-' : text;
};
const buildField = (label, rawValue) => {
const value = String(rawValue === undefined || rawValue === null ? '' : rawValue).trim();
const adjustMatched = value.match(ADJUST_PATTERN);
let before = value;
let after = '';
let hasPair = false;
if (adjustMatched) {
before = adjustMatched[1];
after = adjustMatched[2];
hasPair = true;
} else {
const parts = value.split(ARROW_PATTERN);
if (parts.length > 1) {
before = parts[0];
after = parts[1];
hasPair = true;
}
}
const beforeText = normalizeValue(before);
const afterText = normalizeValue(after);
return {
label,
before: beforeText,
after: afterText,
// 单值展示(未拆分出前后值时使用)
single: hasPair ? afterText : beforeText,
changed: hasPair && beforeText !== afterText,
unchanged: hasPair && beforeText === afterText,
};
};
const parseSegment = segment => {
// 形如「发货地 A → B」(前面只有「;」分隔,无分组标题)→ 归入上一条运单
const leadingMatched = segment.match(FIELD_LABEL_PATTERN);
if (leadingMatched && leadingMatched[2].trim()) {
return {
type: 'field',
title: '',
text: '',
fields: [buildField(leadingMatched[1], leadingMatched[2])],
};
}
const colonIndex = segment.search(/[::]/);
if (colonIndex === -1) {
return { type: 'text', title: '', text: segment, fields: [] };
}
const head = segment.slice(0, colonIndex).trim();
const tail = segment.slice(colonIndex + 1).trim();
const fieldMatched = tail.match(FIELD_LABEL_PATTERN);
// 形如「YD-20260904-0022:发货地 A → B」
if (fieldMatched && fieldMatched[2].trim()) {
return {
type: 'waybill',
title: head,
text: '',
fields: [buildField(fieldMatched[1], fieldMatched[2])],
};
}
// 形如「路线顺序:A → B → C 调整为 B → A → C」
if (head && head.length <= TITLE_MAX_LENGTH) {
return { type: 'group', title: head, text: '', fields: [buildField('', tail)] };
}
return { type: 'text', title: '', text: segment, fields: [] };
};
export const parseRouteChangeContent = content => {
if (content === undefined || content === null) return [];
const text = String(content).replace(/\r\n?/g, '\n').trim();
if (!text) return [];
const segments = text
.split(/[;;\n]+/)
.map(item => item.trim())
.filter(Boolean);
const groups = [];
segments.forEach(segment => {
const group = parseSegment(segment);
const last = groups[groups.length - 1];
// 同一运单下的「到货地…」等无标题字段,合并进上一个运单分组
if (group.type === 'field' && last && (last.type === 'waybill' || last.type === 'field')) {
last.fields.push(...group.fields);
return;
}
groups.push(group);
});
return groups;
};
+32
View File
@@ -1,3 +1,35 @@
/**
* 合并列表搜索条件:新条件覆盖旧条件,本次显式清空的字段从查询中移除。
*/
export const applyIncomingSearch = (current = {}, incoming = {}) => {
const merged = { ...current, ...incoming };
Object.keys(incoming || {}).forEach(key => {
const value = incoming[key];
if (
value === '' ||
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0)
) {
delete merged[key];
}
});
return merged;
};
/**
* 启停状态下拉「全部」在数字类型下会被转成 0/-1,不能当作查询条件。
* 状态 0 表示草稿等有效值的页面不要调用。
*/
export const omitInvalidListStatus = (params = {}) => {
const values = { ...params };
const status = values.status;
if (status === 0 || status === '0' || status === -1 || status === '-1') {
delete values.status;
}
return values;
};
export const normalizeSearchRangeParams = (params = {}, rangeMap = {}) => {
const values = { ...params };
+51
View File
@@ -0,0 +1,51 @@
import { getList as getReceivablePayableDetailList } from '@/api/settlement/receivable-payable-detail';
import { getList as getWaybillList } from '@/api/business/waybill-manage';
// 结算明细的「单据号」是应收应付明细单据号(应收 YS…/应付 YF…),按结算类型落到对应菜单页
const SETTLEMENT_DETAIL_PATHS = {
receivable: '/settlement/receivable-detail',
payable: '/settlement/payable-detail',
};
const unwrapPage = response => {
const body = response?.data || response || {};
return body?.data || body;
};
const firstRecordId = data => (Array.isArray(data) ? data : data?.records || [])[0]?.id || '';
export const settlementDetailPath = settlementType =>
SETTLEMENT_DETAIL_PATHS[settlementType] || SETTLEMENT_DETAIL_PATHS.payable;
/**
* 结算明细行「单据号」对应的应收应付明细ID:已落库的明细自带 sourceDetailId,未落库的行按单据号回查。
*/
export const resolveReceivablePayableDetailId = async (row = {}, settlementType) => {
if (row.sourceDetailId) return row.sourceDetailId;
if (!row.documentNo) return '';
try {
const data = unwrapPage(
await getReceivablePayableDetailList(1, 1, {
documentNo: row.documentNo,
settlementType,
})
);
return firstRecordId(data);
} catch (error) {
return '';
}
};
/**
* 结算明细行「运单号」对应的运单ID:总单来源的明细没有 waybillId(运单号列存的是总单号),按运单号回查。
*/
export const resolveWaybillId = async (row = {}) => {
if (row.waybillId) return row.waybillId;
if (!row.waybillNo) return '';
try {
const data = unwrapPage(await getWaybillList(1, 1, { waybillNo: row.waybillNo }));
return firstRecordId(data);
} catch (error) {
return '';
}
};
+2 -1
View File
@@ -150,6 +150,7 @@ import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { omitInvalidListStatus } from '@/utils/search-range';
import { getToken, isAdministrator } from '@/utils/auth';
import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
@@ -1182,7 +1183,7 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, { ...params, ...this.query })
getList(page.currentPage, page.pageSize, omitInvalidListStatus({ ...params, ...this.query }))
.then(res => {
const data = res.data.data;
this.page.total = data.total;
+2 -1
View File
@@ -104,6 +104,7 @@ import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { omitInvalidListStatus } from '@/utils/search-range';
import { getToken } from '@/utils/auth';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
@@ -507,7 +508,7 @@ export default {
this.onLoad(this.page, this.query);
},
buildQuery(params = {}) {
const values = { ...params, ...this.query };
const values = omitInvalidListStatus({ ...params, ...this.query });
const rangeMap = {
effectiveDateRange: ['effectiveDateStart', 'effectiveDateEnd'],
expiryDateRange: ['expiryDateStart', 'expiryDateEnd'],
+2 -1
View File
@@ -63,6 +63,7 @@
<script>
import { getList, getDetail, submit, remove, changeStatus } from '@/api/base/customer-type';
import { omitInvalidListStatus } from '@/utils/search-range';
import { mapGetters } from 'vuex';
export default {
@@ -290,7 +291,7 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, Object.assign(params, this.query)).then(res => {
getList(page.currentPage, page.pageSize, omitInvalidListStatus(Object.assign(params, this.query))).then(res => {
const data = res.data.data;
this.page.total = data.total;
this.data = data.records;
+2 -1
View File
@@ -97,6 +97,7 @@ import { exportBlob } from '@/api/common';
import { getDictionaryAll } from '@/api/system/dictbiz';
import { getDeptTree } from '@/api/system/dept';
import { downloadXls } from '@/utils/util';
import { omitInvalidListStatus } from '@/utils/search-range';
import { getToken } from '@/utils/auth';
import { Download } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
@@ -569,7 +570,7 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, Object.assign(params, this.query)).then(res => {
getList(page.currentPage, page.pageSize, omitInvalidListStatus(Object.assign(params, this.query))).then(res => {
const data = res.data.data;
this.page.total = data.total;
this.data = data.records;
+2 -1
View File
@@ -82,6 +82,7 @@
import { mapGetters } from 'vuex';
import { changeStatus, getDetail, getList, remove, submit } from '@/api/base/measurement-unit';
import { createOption } from '@/option/base/measurement-unit';
import { omitInvalidListStatus } from '@/utils/search-range';
import { isAdministrator } from '@/utils/auth';
export default {
@@ -218,7 +219,7 @@ export default {
},
onLoad(page = this.page, params = this.query) {
this.loading = true;
return getList(page.currentPage, page.pageSize, params)
return getList(page.currentPage, page.pageSize, omitInvalidListStatus(params))
.then(res => {
const pageData = res.data.data || {};
this.data = pageData.records || [];
+3 -2
View File
@@ -270,6 +270,7 @@ import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { omitInvalidListStatus } from '@/utils/search-range';
import { getToken, isAdministrator } from '@/utils/auth';
import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
@@ -1636,7 +1637,7 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, { ...params, ...this.query })
getList(page.currentPage, page.pageSize, omitInvalidListStatus({ ...params, ...this.query }))
.then(res => {
const data = res.data.data;
this.page.total = data.total;
@@ -1721,7 +1722,7 @@ export default {
.el-form-item--small.el-form-item,
.el-form-item--large.el-form-item,
.el-form-item--medium.el-form-item {
margin-bottom: 24px;
margin-bottom: 20px;
}
}
</style>
+2 -1
View File
@@ -150,6 +150,7 @@ import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { omitInvalidListStatus } from '@/utils/search-range';
import { getToken, isAdministrator } from '@/utils/auth';
import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
@@ -1157,7 +1158,7 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, { ...params, ...this.query })
getList(page.currentPage, page.pageSize, omitInvalidListStatus({ ...params, ...this.query }))
.then(res => {
const data = res.data.data;
this.page.total = data.total;
+1 -1
View File
@@ -787,6 +787,6 @@ export default {
margin-bottom: 12px !important;
}
.el-form-item{
margin-bottom: 24px !important;
margin-bottom: 22px !important;
}
</style>
+52 -7
View File
@@ -396,8 +396,11 @@ export default {
return this.selectionList.map(item => item.id).join(',');
},
},
created() {
this.applyDefaultDeptSearch();
created() {
// 超管默认查全部组织,不预填组织筛选;普通用户预填当前组织。
if (!this.isAdmin) {
this.applyDefaultDeptSearch();
}
this.initDeptTree();
},
methods: {
@@ -455,10 +458,23 @@ export default {
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
// 本次搜索显式带了组织字段时,以本次为准(含清空);否则沿用缓存。
const hasDeptInParams =
Object.prototype.hasOwnProperty.call(params, 'deptId') ||
Object.prototype.hasOwnProperty.call(params, 'searchDeptId');
const rawDeptId = hasDeptInParams
? params.deptId ?? params.searchDeptId
: query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
// 级联可能回传路径数组,统一取末级并保持字符串,避免雪花 ID 精度丢失。
const deptValue = Array.isArray(rawDeptId) ? rawDeptId.at(-1) : rawDeptId;
query.deptId = String(deptValue ?? '')
.split(',')[0]
.trim();
if (!query.deptId) {
delete query.deptId;
}
} else {
delete query.deptId;
}
@@ -466,6 +482,23 @@ export default {
return query;
},
applyDefaultDeptSearch() {
// 超管默认不限组织,避免预填组织后新增落在其他组织时列表看不见。
if (this.isAdmin) {
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
if (deptColumn) {
deptColumn.searchValue = undefined;
}
const nextQuery = { ...this.query };
delete nextQuery.deptId;
delete nextQuery.searchDeptId;
this.query = nextQuery;
this.$nextTick(() => {
if (this.$refs.crud?.searchForm) {
this.$refs.crud.searchForm.searchDeptId = undefined;
}
});
return;
}
const deptId = this.defaultDeptId();
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
if (!deptId || !deptColumn) return;
@@ -477,6 +510,11 @@ export default {
}
});
},
resolveDeptName(deptId) {
if (!deptId) return '';
const matched = this.deptOptions.find(item => String(item.value) === String(deptId));
return matched ? String(matched.label || '').trim() : '';
},
resetAddressQuery() {
this.addressQuery = {
addressName: '',
@@ -933,7 +971,8 @@ export default {
}
api.submit(submitRow).then(
() => {
this.onLoad(this.page);
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
@@ -951,7 +990,7 @@ export default {
}
api.submit(submitRow).then(
() => {
this.onLoad(this.page);
this.onLoad(this.page, this.query);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
@@ -1006,7 +1045,13 @@ export default {
beforeOpen(done, type) {
this.dialogReadonly = type === 'view';
if (type === 'add') {
this.form = {};
const form = {};
// 超管若当前按某组织筛选,新增默认归属该组织,避免保存后被筛选条件滤掉。
if (this.isAdmin && this.query.deptId) {
form.deptId = String(this.query.deptId);
form.deptName = this.resolveDeptName(form.deptId);
}
this.form = form;
done();
return;
}
@@ -2065,11 +2065,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -2338,14 +2349,16 @@
<div class="business-crud-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="business-crud-page__transport-plan-detail-attachments">
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -2531,14 +2544,16 @@
<div class="business-crud-page__waybill-attachments">
<span class="business-crud-page__waybill-label">附件</span>
<div>
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ file.originalName || file.name || '-' }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.originalName || file.name || '-' }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -2820,11 +2835,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<el-table-column
label="文件名"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -2966,10 +2991,50 @@
<el-table :data="waybillRouteChangeRecords" border>
<el-table-column prop="changeTime" label="变更时间" min-width="170" />
<el-table-column prop="changeUser" label="变更人" min-width="120" />
<el-table-column prop="content" label="变更内容" min-width="360">
<el-table-column
prop="content"
label="变更内容"
min-width="360"
:show-overflow-tooltip="false"
>
<template #default="{ row }">
<div class="business-crud-page__route-change-record-content">
{{ row.content || '-' }}
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="business-crud-page__route-change-group"
>
<div v-if="group.title" class="business-crud-page__route-change-group-title">
{{ group.title }}
</div>
<div v-if="group.text" class="business-crud-page__route-change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="business-crud-page__route-change-field"
>
<span class="business-crud-page__route-change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="business-crud-page__route-change-before">
{{ field.before }}
</span>
<span class="business-crud-page__route-change-arrow">→</span>
<span class="business-crud-page__route-change-after">
{{ field.after }}
</span>
</template>
<span v-else class="business-crud-page__route-change-single">
{{ field.single }}
<span v-if="field.unchanged" class="business-crud-page__route-change-flag">
未变更
</span>
</span>
</div>
</div>
</div>
</template>
</el-table-column>
@@ -4225,11 +4290,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.enter="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.space.prevent="previewAttachment(row, dispatchItemAttachmentRows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -5246,6 +5322,7 @@ import { formatContractOptionLabel } from '@/utils/contract';
import { openImportDialog } from '@/utils/import-excel';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { parseRouteChangeContent } from '@/utils/route-change';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
@@ -6592,6 +6669,7 @@ export default {
},
methods: {
formatContractOptionLabel,
parseRouteChangeContent,
initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
@@ -15861,7 +15939,65 @@ export default {
}
&__route-change-record-content {
white-space: pre-wrap;
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
&__route-change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
&__route-change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
&__route-change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
&__route-change-field-label {
color: #909399;
white-space: nowrap;
}
&__route-change-before,
&__route-change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-arrow {
color: #c0c4cc;
text-align: right;
}
&__route-change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
@media (max-width: 1200px) {
@@ -45,11 +45,17 @@
align="left"
header-align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="preview?.(row, rows)">{{
attachmentName(row)
}}</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="preview?.(row, rows)"
@keydown.enter="preview?.(row, rows)"
@keydown.space.prevent="preview?.(row, rows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column v-if="description" label="附件描述" min-width="220">
@@ -44,19 +44,22 @@
<dt>附件</dt>
<dd>
<template v-if="attachments.length">
<el-link
<span
v-for="file in attachments"
:key="file.url || file.link || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ attachmentName(file) }}</el-link
>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
</template>
<span v-else>-</span>
</dd>
</div>
</dl>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
</section>
<el-dialog v-model="documentPreviewVisible" :title="previewFile.name || '附件预览'" append-to-body destroy-on-close width="90%" top="4vh">
@@ -107,6 +110,7 @@ import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archiv
import { CaretBottom, CaretTop } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { formatRegionAddress } from '@/utils/region-address';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
@@ -161,6 +165,7 @@ export default {
} finally { this.loading = false; }
},
methods: {
formatRegionAddress,
quantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
segmentNumber(route, index) { return String(route.segmentNo || '').replace(/^段/, '') || index + 1; },
isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[segmentNo]); },
@@ -330,7 +335,7 @@ export default {
.transport-flow-tag {
margin-left: 8px;
}
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } :deep(.el-link) { font-size: 14px; } }
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } .attachment-file-name { font-size: 14px; max-width: 100%; } }
.route-map { display: flex; align-items: flex-start; padding: 16px 24px 20px; border-top: 1px solid #eff1f7; overflow-x: auto; }
.route-map__node { display: grid; flex: 0 0 150px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } }
.route-map__progress { color: #303133 !important; }
@@ -21,10 +21,10 @@
<div class="route-overview__node">
<span :class="['route-badge', index === 0 ? 'start' : 'middle']">{{ index === 0 ? '起' : '经' }}</span>
<el-tooltip :content="route.departureName || '-'" placement="top" :show-after="120">
<strong>{{ route.departureName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'departure') }}</strong>
</el-tooltip>
<el-tooltip :content="route.departureAddress || '-'" placement="top" :show-after="120">
<small>{{ route.departureAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.departureContact, route.departurePhone)" placement="top" :show-after="120">
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
@@ -34,10 +34,10 @@
<div v-if="index === routes.length - 1" class="route-overview__node">
<span class="route-badge end">终</span>
<el-tooltip :content="route.arrivalName || '-'" placement="top" :show-after="120">
<strong>{{ route.arrivalName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'arrival') }}</strong>
</el-tooltip>
<el-tooltip :content="route.arrivalAddress || '-'" placement="top" :show-after="120">
<small>{{ route.arrivalAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.arrivalContact, route.arrivalPhone)" placement="top" :show-after="120">
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
@@ -324,6 +324,7 @@ import {
resolvePriceUnitByQuantityUnit,
} from '@/utils/currency';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { formatRegionAddress } from '@/utils/region-address';
import { isMobile } from '@/utils/validate';
const unwrapRecords = res => {
@@ -851,7 +852,12 @@ export default {
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
},
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
formatRegionAddress,
addressText(name, address) {
// 名称与详细地址都要按「市 区县」展示;两者格式化后相同时只展示一次
const values = [this.formatRegionAddress(name), this.formatRegionAddress(address)].filter(Boolean);
return [...new Set(values)].join(' / ') || '-';
},
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
dateStartLabel(route) { return route.documentType === '计划单' ? '计划开始日期' : '预计发货日期'; },
dateEndLabel(route) { return route.documentType === '计划单' ? '计划结束日期' : '预计完成日期'; },
@@ -261,11 +261,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="240" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -153,11 +153,24 @@
@click="handleTransportSecondaryAddressInputClick('departure')"
>
<template #suffix>
<el-icon @click.stop="openTransportMapDialog('departure')">
<el-icon
class="shipping-template-page__map-suffix"
@click.stop="openTransportMapDialog('departure')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="shipping-template-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
/>
</el-tooltip>
<span>联系人</span>
<el-input
v-model="form.departureContact"
@@ -166,16 +179,6 @@
/>
<span>联系方式</span>
<el-input v-model="form.departurePhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
@@ -197,25 +200,28 @@
@click="handleTransportSecondaryAddressInputClick('arrival')"
>
<template #suffix>
<el-icon @click.stop="openTransportMapDialog('arrival')">
<el-icon
class="shipping-template-page__map-suffix"
@click.stop="openTransportMapDialog('arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="shipping-template-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
/>
</el-tooltip>
<span>联系人</span>
<el-input v-model="form.arrivalContact" placeholder="请输入" :disabled="dialogReadonly" />
<span>联系方式</span>
<el-input v-model="form.arrivalPhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
@@ -454,11 +460,20 @@
>
<el-table-column type="selection" width="55" />
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220"
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
><template #default="{ row }"
><el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link></template
><span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span></template
></el-table-column
>
<el-table-column label="附件描述" min-width="220"
@@ -721,11 +736,21 @@
</div>
<el-table :data="detailAttachmentRows" border empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220">
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -1103,7 +1128,7 @@ import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { Location } from '@element-plus/icons-vue';
import { Location, OfficeBuilding } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import {
@@ -1254,6 +1279,7 @@ export default {
data() {
return {
Location,
OfficeBuilding,
form: {},
query: {},
loading: true,
@@ -3402,10 +3428,25 @@ export default {
.shipping-template-page__route-row {
display: grid;
grid-template-columns:
minmax(160px, 1fr) minmax(220px, 2fr) auto minmax(140px, 1fr) auto minmax(160px, 1fr)
auto;
minmax(160px, 1fr) minmax(220px, 2fr) 40px auto minmax(140px, 1fr) auto
minmax(160px, 1fr);
gap: 8px;
align-items: center;
.shipping-template-page__address-pick-btn {
:deep(.el-icon) {
font-size: 22px;
}
}
}
.shipping-template-page__map-suffix {
cursor: pointer;
color: #909399;
font-size: 18px;
&:hover {
color: #409eff;
}
}
.shipping-template-page__address-map-input {
cursor: pointer;
@@ -492,11 +492,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -673,14 +684,16 @@
<div class="transport-plan-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="transport-plan-page__transport-plan-detail-attachments">
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -855,11 +868,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<el-table-column
label="文件名"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -2201,11 +2224,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.enter="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.space.prevent="previewAttachment(row, dispatchItemAttachmentRows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -1,7 +1,7 @@
<template>
<div class="voucher-folder-page voucher-folder-page--embedded">
<section class="voucher-folder-page__search">
<el-form :model="query" label-position="right" label-width="160px">
<el-form :model="query" label-position="right">
<div class="voucher-folder-page__search-grid">
<el-form-item label="车牌号">
<el-input v-model="query.plateNo" clearable placeholder="请输入" />
@@ -196,7 +196,7 @@ watch(
}
&__search-grid {
display: grid;
grid-template-columns: 360px 360px 1fr;
grid-template-columns: 220px 220px 1fr;
gap: 8px 24px;
}
&__search-actions {
@@ -887,11 +887,14 @@ const syncCarrierOptions = () => {
carrierOptions.value = [];
return;
}
const carrierId =
const carrierId = resolveCarrierArchiveId(
customerContract?.partyBId ||
customerContract?.partyBUserId ||
customerContract?.customerId ||
'';
customerContract?.partyBUserId ||
customerContract?.contractPartyBId ||
customerContract?.customerId ||
'',
carrierName
);
carrierOptions.value = [{ id: carrierId, carrierName, label: carrierName, value: carrierName }];
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
@@ -1317,12 +1320,20 @@ const contractChange = id => {
const carrierTypeChange = () => {
syncCarrierOptions();
};
const resolveCarrierArchiveId = (carrierId, carrierName) => {
if (carrierId) return carrierId;
const name = String(carrierName || '').trim();
if (!name) return '';
const matched = queryCarriers.value.find(item => String(item.name || '').trim() === name);
return matched?.id || '';
};
const carrierChange = value => {
const item = carrierOptions.value.find(option => String(option.value) === String(value || ''));
clearCarrier();
if (!item) return;
form.carrierId = item.id || '';
form.carrierIds = item.id ? [item.id] : [];
const carrierId = resolveCarrierArchiveId(item.id, item.carrierName);
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
form.carrierName = item.carrierName || '';
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
};
@@ -1738,11 +1749,23 @@ const normalizeOptionalPlanId = value => {
const buildImportPayload = (status = form.status, rowList = rows.value, extra = {}) => {
const { file, ...batch } = form;
const planId = normalizeOptionalPlanId(batch.planId);
const carrierId = resolveCarrierArchiveId(
batch.carrierId || batch.carrierIds?.[0] || '',
batch.carrierName
);
const carrierIds =
Array.isArray(batch.carrierIds) && batch.carrierIds.length
? batch.carrierIds
: carrierId
? [carrierId]
: [];
return {
...batch,
planId,
// 未选计划时不带占位名称,避免落库/回显 -1
planName: planId ? batch.planName || undefined : null,
carrierId: carrierId || undefined,
carrierIds,
status,
rows: rowList.map(normalizeImportRow).map(stripInternalRowFields),
...extra,
@@ -1334,7 +1334,7 @@
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="型号" required>
<el-form-item label="型号">
<el-input
v-model="form.model"
placeholder="选择货物自动回填"
@@ -1473,11 +1473,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -1858,14 +1869,16 @@
<div class="waybill-manage-page__waybill-attachments">
<span class="waybill-manage-page__waybill-label">附件</span>
<div>
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ file.originalName || file.name || '-' }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.originalName || file.name || '-' }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -3007,10 +3020,56 @@
<el-table :data="waybillRouteChangeRecords" border>
<el-table-column prop="changeTime" label="变更时间" min-width="170" />
<el-table-column prop="changeUser" label="变更人" min-width="120" />
<el-table-column prop="content" label="变更内容" min-width="360">
<el-table-column
prop="content"
label="变更内容"
min-width="360"
:show-overflow-tooltip="false"
>
<template #default="{ row }">
<div class="waybill-manage-page__route-change-record-content">
{{ row.content || '-' }}
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="waybill-manage-page__route-change-group"
>
<div
v-if="group.title"
class="waybill-manage-page__route-change-group-title"
>
{{ group.title }}
</div>
<div v-if="group.text" class="waybill-manage-page__route-change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="waybill-manage-page__route-change-field"
>
<span class="waybill-manage-page__route-change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="waybill-manage-page__route-change-before">
{{ field.before }}
</span>
<span class="waybill-manage-page__route-change-arrow">→</span>
<span class="waybill-manage-page__route-change-after">
{{ field.after }}
</span>
</template>
<span v-else class="waybill-manage-page__route-change-single">
{{ field.single }}
<span
v-if="field.unchanged"
class="waybill-manage-page__route-change-flag"
>
未变更
</span>
</span>
</div>
</div>
</div>
</template>
</el-table-column>
@@ -3653,6 +3712,7 @@ import { wgs84ToGcj02 } from '@/utils/coordinate';
import { openImportDialog } from '@/utils/import-excel';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { parseRouteChangeContent } from '@/utils/route-change';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
@@ -4740,6 +4800,7 @@ export default {
},
methods: {
formatContractOptionLabel,
parseRouteChangeContent,
// 页面被 keep-alive 缓存/卸载时调用,关闭本页所有 append-to-body 的弹窗,
// 避免 Teleport 出去的 DOM 残留在 body 上盖住后续页面(详见 data 里的说明)。
closeInnerDialogs() {
@@ -5205,10 +5266,11 @@ export default {
return true;
},
showDetailButton(row) {
// 草稿与其它状态统一走「详情」(独立详情页 / 详情弹窗),
// 不再对草稿单独降级为 avue 的「查看」表单弹窗,避免同一列出现两种弹窗形态。
if (!this.config.detailButton || !this.hasPermission(`${this.config.permission}_view`))
return false;
const status = this.statusValue(row);
return status !== 'draft';
return true;
},
formatDetailValue(row, prop) {
if (!row) return '-';
@@ -6491,17 +6553,10 @@ export default {
if (isAddMode) {
delete submitRow.id;
delete submitRow.waybillNo;
// 复制提交:状态列未进表单,Avue 可能不带回;显式保留原运单业务状态(含已完成)
// 复制新建:业务状态固定为草稿,不沿用原运单状态
const copyId = this.$route.query.copyId || '';
if (copyId && !saveAsDraft) {
const copiedStatus =
this.form?.businessStatus ||
this.copySourceBusinessStatus ||
submitRow.businessStatus ||
'';
if (copiedStatus) {
submitRow.businessStatus = copiedStatus;
}
if (copyId) {
submitRow.businessStatus = this.config.draftStatus || 'draft';
}
}
const skipValidation = saveAsDraft && this.config.skipDraftValidation === true;
@@ -6524,14 +6579,16 @@ export default {
if (this.config.enableAttachmentTable) {
submitRow.attachmentsJson = JSON.stringify(this.attachmentRows);
}
const isCopyAdd = Boolean(this.$route.query.copyId) && isAddMode;
if (this.config.saveAsDraft && saveAsDraft) {
submitRow[this.config.draftStatusProp || 'approvalStatus'] =
this.config.draftStatus || 'draft';
} else if (
!isCopyAdd &&
this.config.draftStatusProp === 'businessStatus' &&
submitRow.businessStatus === (this.config.draftStatus || 'draft')
) {
// 正式提交:清空草稿态,由后端按过程配置校正为待执行/进行中
// 正式提交:清空草稿态,由后端按过程配置校正为待执行/进行中(复制新建除外,保持草稿)
submitRow.businessStatus = '';
}
delete submitRow.basicInfoTitle;
@@ -6794,12 +6851,18 @@ export default {
}
},
rowSave(row, done, loading) {
const submitRow = this.normalizeRow(row);
// 复制页提交走草稿保存,避免 submit 接口把 draft 校正为待执行/进行中
const isCopySubmit = Boolean(this.$route.query.copyId);
const submitRow = this.normalizeRow(row, isCopySubmit);
if (!submitRow) {
this.stopSubmitLoading(loading);
return;
}
this.getSaveRequest()(submitRow).then(
const request =
isCopySubmit && typeof this.api.saveDraft === 'function'
? this.api.saveDraft
: this.getSaveRequest();
request(submitRow).then(
res => {
this.markWaybillListNeedRefresh();
this.onLoad(this.page);
@@ -6928,13 +6991,14 @@ export default {
if (type === 'add' && copyId) {
this.api.getDetail(copyId).then(res => {
const sourceData = res.data.data || {};
// 原运单业务状态(如 completed)提交时需原样带回并生成应收应付
this.copySourceBusinessStatus = sourceData.businessStatus || '';
// 清除不应该复制的字段(业务状态与原运单保持一致)
this.copySourceBusinessStatus = '';
// 清除不应该复制的字段;复制新建固定为草稿
delete sourceData.id;
delete sourceData.code;
delete sourceData.waybillNo;
delete sourceData.waybillStatus;
delete sourceData.businessStatus;
delete sourceData.businessStatusName;
delete sourceData.driverAcceptStatus;
delete sourceData.driverAcceptTime;
delete sourceData.driverAcceptDriverId;
@@ -6951,11 +7015,8 @@ export default {
delete sourceData.updateUserName;
this.applyFormDetail(sourceData);
// 标记为手工创建;业务状态沿用原运单(含已完成)
this.form.dataSource = '手工创建';
if (this.copySourceBusinessStatus) {
this.form.businessStatus = this.copySourceBusinessStatus;
}
this.form.businessStatus = this.config.draftStatus || 'draft';
done();
});
return;
@@ -11830,7 +11891,65 @@ export default {
}
&__route-change-record-content {
white-space: pre-wrap;
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
&__route-change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
&__route-change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
&__route-change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
&__route-change-field-label {
color: #909399;
white-space: nowrap;
}
&__route-change-before,
&__route-change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-arrow {
color: #c0c4cc;
text-align: right;
}
&__route-change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
&__common-address-toolbar {
+29 -5
View File
@@ -106,6 +106,7 @@
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
@@ -331,7 +332,7 @@
<script>
import * as api from '@/api/business/contract-manage';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ContractAttachmentSection, {
isContractSealMaterialLocation,
@@ -386,6 +387,19 @@ const normalizeOptionalAmount = value => {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : null;
};
const formatContractAmountInput = (value, emptyValue = null) => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue;
};
const normalizeCustomPeriods = (periods = []) =>
@@ -541,6 +555,9 @@ export default {
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.form.contractAmount = normalized === '' ? null : normalized;
},
formatContractAmountField() {
this.form.contractAmount = formatContractAmountInput(this.form.contractAmount);
},
loadDictionaries() {
Promise.all([
getSystemDictionary({ code: 'currency_type' }),
@@ -583,7 +600,7 @@ export default {
copyCount: normalizeOptionalPositiveInteger(data.copyCount),
invoiceCycle: normalizeOptionalPositiveInteger(data.invoiceCycle),
paymentDays: normalizeOptionalPositiveInteger(data.paymentDays),
contractAmount: normalizeOptionalAmount(data.contractAmount),
contractAmount: formatContractAmountInput(data.contractAmount),
templateFlag: data.templateFlag ?? 0,
electronicSealFlag: data.electronicSealFlag ?? 0,
archiveStatus: data.archiveStatus || '未归档',
@@ -721,7 +738,7 @@ 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' }); this.$message.success('变更已提交'); this.$router.back(); },
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(); },
},
};
</script>
@@ -735,11 +752,18 @@ export default {
.contract-basic-section > .dialog-section-title { margin-bottom: 20px; color: #303133; }
.contract-basic-section__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 48px; }
.contract-basic-section__change-type { grid-column: 1 / -1; }
.contract-basic-section__remark { width: 100%; margin-bottom: 0 !important; }
.contract-basic-section__remark {
width: 100%;
margin-bottom: 23px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
.contract-basic-section__date-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; width: 360px; max-width: 100%; }
.contract-basic-section__date-range span { color: #606266; }
.contract-basic-section__date-range :deep(.el-date-editor) { width: auto; min-width: 0; }
.contract-basic-section :deep(.el-form-item) { align-items: center; margin-bottom: 24px; }
.contract-basic-section :deep(.el-form-item) { align-items: center; }
.contract-basic-section :deep(.el-form-item__label) { flex: 0 0 96px !important; width: 96px !important; height: auto; padding-right: 12px; white-space: normal; word-break: break-all; line-height: 18px; }
.contract-basic-section :deep(.el-form-item__content) { min-width: 0; }
.contract-basic-section :deep(.el-input),
+33 -13
View File
@@ -525,6 +525,7 @@
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
@@ -612,8 +613,8 @@
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入其他事项说明"
/>
@@ -1098,6 +1099,19 @@ const normalizeOptionalAmount = (value, emptyValue = null) => {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : emptyValue;
};
const formatContractAmountInput = (value, emptyValue = null) => {
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue;
};
const normalizeProjectValue = value => {
if (value === undefined || value === null || value === '' || String(value) === '-1') return '';
return value;
@@ -1794,7 +1808,7 @@ export default {
copyCount: normalizeOptionalInteger(detail.copyCount, ''),
invoiceCycle: normalizeOptionalInteger(detail.invoiceCycle, ''),
paymentDays: normalizeOptionalInteger(detail.paymentDays, ''),
contractAmount: normalizeOptionalAmount(detail.contractAmount, null),
contractAmount: formatContractAmountInput(detail.contractAmount, null),
archiveStatus: detail.archiveStatus || '未归档',
feeGenerationMode:
detail.feeGenerationMode || (Number(detail.billingEnabled) === 0 ? 'manual' : 'system'),
@@ -1972,7 +1986,9 @@ export default {
bizType: 'contract-manage',
bizId: id,
subjectName: saved.contractName || submit.contractName || '',
approvalStatus: previousApprovalStatus || saved.approvalStatus || submit.approvalStatus || '',
approvalStatus:
previousApprovalStatus || saved.approvalStatus || submit.approvalStatus || '',
mkEnabled: this.mkEnabled,
})
);
}
@@ -2011,6 +2027,7 @@ export default {
bizId: payload.id || this.form.id,
subjectName: payload.contractName || this.form.contractName || '',
approvalStatus: this.form.approvalStatus || 'change_rejected',
mkEnabled: this.mkEnabled,
}).then(() => {
this.$message.success('已重新提交变更审批');
this.closeForm();
@@ -2356,6 +2373,9 @@ export default {
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.form.contractAmount = normalized === '' ? null : normalized;
},
formatContractAmountField() {
this.form.contractAmount = formatContractAmountInput(this.form.contractAmount, null);
},
positiveIntegerInput(prop, value) {
const normalized = String(value || '').replace(/\D/g, '').replace(/^0+/, '');
this.form[prop] = normalized;
@@ -2929,13 +2949,8 @@ export default {
detailValue(prop) {
const value = this.detailRow[prop];
if (prop === 'contractAmount') {
return value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
? '-'
: value;
const amountText = formatContractAmountInput(value, '');
return amountText === '' ? '-' : amountText;
}
if (
prop === 'contractStage' ||
@@ -2989,6 +3004,7 @@ export default {
bizId: row.id,
subjectName: row.contractName || '',
approvalStatus: row.approvalStatus || '',
mkEnabled: this.mkEnabled,
})
)
: request();
@@ -3248,7 +3264,11 @@ export default {
&__remark {
width: 100%;
margin-bottom: 0 !important;
margin-bottom: 24px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
&__date-range {
@@ -3322,7 +3342,7 @@ export default {
:deep(.el-form-item) {
align-items: center;
margin-bottom: 24px;
//margin-bottom: 24px;
}
:deep(.el-form-item__label) {
+241 -67
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="loading-manage-page">
<div v-if="!isStandalonePage" class="loading-manage-page__search">
<div v-if="!isStandalonePage && searchVisible" class="loading-manage-page__search">
<el-form :model="searchForm" label-position="right" label-width="88px">
<div class="loading-manage-page__search-grid">
<el-form-item label="配载单号">
@@ -228,17 +228,13 @@
<el-button type="primary" plain @click="handleExport">批量导出</el-button>
<el-button type="primary" plain @click="handleBatchComplete">批量完成</el-button>
</div>
<div class="loading-manage-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
<el-tooltip content="表格密度" placement="top">
<el-button :icon="Rank" text @click="toggleTableSize" />
</el-tooltip>
<el-tooltip content="列设置" placement="top">
<el-button :icon="Setting" text @click="handleColumnSetting" />
</el-tooltip>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
@@ -254,7 +250,12 @@
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="58" fixed="left" align="center" />
<el-table-column label="配载单号" prop="loadingNo" min-width="170">
<el-table-column
v-if="isColumnVisible('loadingNo')"
label="配载单号"
prop="loadingNo"
min-width="170"
>
<template #default="{ row }">
<el-link
type="primary"
@@ -264,7 +265,12 @@
>
</template>
</el-table-column>
<el-table-column label="运单号" prop="loadingSubNos" min-width="220">
<el-table-column
v-if="isColumnVisible('loadingSubNos')"
label="运单号"
prop="loadingSubNos"
min-width="220"
>
<template #default="{ row }">
<span class="loading-manage-page__sub-nos">
<template v-if="splitText(row.loadingSubNos).length">
@@ -283,11 +289,40 @@
</span>
</template>
</el-table-column>
<el-table-column label="车牌号" prop="vehicleNo" min-width="170" show-overflow-tooltip />
<el-table-column label="司机" prop="driverName" min-width="120" show-overflow-tooltip />
<el-table-column label="联系电话" prop="driverPhone" min-width="140" show-overflow-tooltip />
<el-table-column label="承运类型" prop="carrierType" min-width="120" show-overflow-tooltip />
<el-table-column label="发货地" prop="departureAddress" min-width="180">
<el-table-column
v-if="isColumnVisible('vehicleNo')"
label="车牌号"
prop="vehicleNo"
min-width="170"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverName')"
label="司机"
prop="driverName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverPhone')"
label="联系电话"
prop="driverPhone"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('carrierType')"
label="承运类型"
prop="carrierType"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('departureAddress')"
label="发货地"
prop="departureAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'departure')"
@@ -304,7 +339,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="途经地" prop="transitAddress" min-width="190">
<el-table-column
v-if="isColumnVisible('transitAddress')"
label="途经地"
prop="transitAddress"
min-width="190"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'transit')"
@@ -321,7 +361,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="到货地" prop="arrivalAddress" min-width="180">
<el-table-column
v-if="isColumnVisible('arrivalAddress')"
label="到货地"
prop="arrivalAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'arrival')"
@@ -338,20 +383,40 @@
</div>
</template>
</el-table-column>
<el-table-column label="承运商" prop="carrierName" min-width="160" show-overflow-tooltip />
<el-table-column label="运输方式" prop="transportType" min-width="150" show-overflow-tooltip>
<el-table-column
v-if="isColumnVisible('carrierName')"
label="承运商"
prop="carrierName"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('transportType')"
label="运输方式"
prop="transportType"
min-width="150"
show-overflow-tooltip
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip />
<el-table-column
v-if="isColumnVisible('dataSource')"
label="数据来源"
prop="dataSource"
min-width="130"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
label="创建人"
prop="createUserName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createTime')"
label="创建时间"
prop="createTime"
min-width="170"
@@ -359,6 +424,7 @@
sortable
/>
<el-table-column
v-if="isColumnVisible('updateTime')"
label="更新时间"
prop="updateTime"
min-width="170"
@@ -366,12 +432,20 @@
sortable
/>
<el-table-column
v-if="isColumnVisible('currentProcessNode')"
label="过程节点"
prop="currentProcessNode"
min-width="130"
show-overflow-tooltip
/>
<el-table-column label="状态" prop="businessStatus" width="110" fixed="right" align="center">
<el-table-column
v-if="isColumnVisible('businessStatus')"
label="状态"
prop="businessStatus"
width="110"
fixed="right"
align="center"
>
<template #default="{ row }">
<span :class="['loading-manage-page__status', `is-${row.businessStatus || 'unknown'}`]">
{{ statusText(row) }}
@@ -484,12 +558,16 @@
<div class="loading-detail__field loading-detail__attachment-field">
<span class="loading-detail__label">附件</span>
<span class="loading-detail__value"
><el-link
><span
v-for="file in detailAttachments"
:key="file.url || file.name"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ file.name }}</el-link
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.name }}</span
><span v-if="!detailAttachments.length">-</span></span
>
</div>
@@ -555,15 +633,12 @@
prop="packageType"
label="包装"
min-width="100" /><el-table-column
prop="weight"
label="重量(吨)"
min-width="110" /><el-table-column
prop="volume"
label="体积(方)"
min-width="110" /><el-table-column
prop="quantity"
label="数量"
min-width="90" /><el-table-column
prop="quantityUnit"
label="数量单位"
min-width="100" /><el-table-column
prop="materialCode"
label="物料编码"
min-width="120" /><el-table-column
@@ -1049,6 +1124,7 @@
label="运单号"
min-width="150"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link v-if="row.waybillNo" type="primary" @click="openWaybillDetail(row)">
@@ -1340,7 +1416,7 @@
<el-table-column prop="cargoType" label="货物类型" min-width="140" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="quantity" label="数量" min-width="100" />
<el-table-column prop="quantityUnit" label="单位" min-width="100" />
<el-table-column prop="quantityUnit" label="数量单位" min-width="100" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="120" />
@@ -1607,10 +1683,55 @@
><el-table-column prop="changeTime" label="变更时间" min-width="170" /><el-table-column
prop="changeUser"
label="变更人"
min-width="120" /><el-table-column prop="content" label="变更内容" min-width="360"
min-width="120" /><el-table-column
prop="content"
label="变更内容"
min-width="360"
:show-overflow-tooltip="false"
><template #default="{ row }"
><div class="loading-route-change-dialog__record-content">
{{ row.content || '-' }}
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="loading-route-change-dialog__change-group"
>
<div
v-if="group.title"
class="loading-route-change-dialog__change-group-title"
>
{{ group.title }}
</div>
<div v-if="group.text" class="loading-route-change-dialog__change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="loading-route-change-dialog__change-field"
>
<span class="loading-route-change-dialog__change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="loading-route-change-dialog__change-before">
{{ field.before }}
</span>
<span class="loading-route-change-dialog__change-arrow">→</span>
<span class="loading-route-change-dialog__change-after">
{{ field.after }}
</span>
</template>
<span v-else class="loading-route-change-dialog__change-single">
{{ field.single }}
<span
v-if="field.unchanged"
class="loading-route-change-dialog__change-flag"
>
未变更
</span>
</span>
</div>
</div>
</div></template
></el-table-column
><el-table-column
@@ -1719,10 +1840,12 @@ import {
} from '@/option/business/loading-manage';
import { option as waybillOption } from '@/option/business/waybill-manage';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Location, Rank, Refresh, Search, Setting } from '@element-plus/icons-vue';
import { Location, Rank, Search } from '@element-plus/icons-vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import dayjs from 'dayjs';
import { isMobile } from '@/utils/validate';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { parseRouteChangeContent } from '@/utils/route-change';
const defaultTransportType = '';
const defaultCandidateTransportType = '';
@@ -1916,10 +2039,31 @@ const uniqueText = (rows, prop) =>
),
].join('、');
const LOADING_TABLE_COLUMNS = [
{ prop: 'loadingNo', label: '配载单号' },
{ prop: 'loadingSubNos', label: '运单号' },
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'driverName', label: '司机' },
{ prop: 'driverPhone', label: '联系电话' },
{ prop: 'carrierType', label: '承运类型' },
{ prop: 'departureAddress', label: '发货地' },
{ prop: 'transitAddress', label: '途经地' },
{ prop: 'arrivalAddress', label: '到货地' },
{ prop: 'carrierName', label: '承运商' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'currentProcessNode', label: '过程节点' },
{ prop: 'businessStatus', label: '状态' },
];
export default {
components: {
Rank,
Search,
TableHeaderTools,
VoucherExecutionDetail,
PunchRecordPhotos,
},
@@ -1927,11 +2071,12 @@ export default {
return {
Rank,
Location,
Refresh,
Search,
Setting,
loading: false,
tableSize: 'default',
searchVisible: true,
columnSettingOptions: LOADING_TABLE_COLUMNS,
visibleColumnKeys: LOADING_TABLE_COLUMNS.map(column => column.prop),
tableData: [],
page: {
currentPage: 1,
@@ -2179,6 +2324,7 @@ export default {
},
},
methods: {
parseRouteChangeContent,
formatTaskCarrierContractLabel(item = {}) {
const carrierName = item.carrierName || item.partyB || '';
const contractLabel = formatContractOptionLabel(item);
@@ -2246,13 +2392,8 @@ export default {
selectionChange(rows) {
this.selectionRows = rows || [];
},
toggleTableSize() {
const order = ['default', 'small', 'large'];
const index = order.indexOf(this.tableSize);
this.tableSize = order[(index + 1) % order.length];
},
handleColumnSetting() {
ElMessage.info('列设置按原型保留入口,当前默认展示全部列表字段');
isColumnVisible(prop) {
return this.visibleColumnKeys.includes(prop);
},
statusText(row) {
return row.businessStatusName || loadingStatusMap[row.businessStatus] || '未知';
@@ -2755,7 +2896,6 @@ export default {
if (status === 'draft') {
return [
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'delete', label: '删除' },
];
}
@@ -2767,36 +2907,24 @@ export default {
if (row.driverRejected) {
actions.push({ type: 'reassign', label: '重新派单' });
}
actions.push(
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'cancel', label: '取消' }
);
actions.push({ type: 'edit', label: '编辑' }, { type: 'cancel', label: '取消' });
return actions;
}
if (status === 'running') {
return [
{ type: 'view', label: '详情' },
{ type: 'complete', label: '完成' },
{ type: 'copy', label: '复制' },
];
}
if (status === 'completed' || status === 'cancelled') {
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
return [{ type: 'view', label: '详情' }];
}
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
return [{ type: 'view', label: '详情' }];
},
handleRowAction(type, row) {
const actionMap = {
view: () => this.gotoLoadingDetail(row),
edit: () => this.openLoadingDialog('edit', row),
copy: () => this.gotoLoadingCopy(row),
reassign: () => this.gotoLoadingReassign(row),
delete: () => this.deleteLoading(row),
start: () => this.startLoading(row),
@@ -4471,8 +4599,7 @@ export default {
margin: 12px 0;
}
.loading-manage-page__toolbar-left,
.loading-manage-page__toolbar-right {
.loading-manage-page__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
@@ -5049,9 +5176,56 @@ export default {
gap: 4px;
}
.loading-route-change-dialog__record-content {
white-space: normal;
overflow-wrap: anywhere;
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
.loading-route-change-dialog__change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
.loading-route-change-dialog__change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
.loading-route-change-dialog__change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
.loading-route-change-dialog__change-field-label {
color: #909399;
white-space: nowrap;
}
.loading-route-change-dialog__change-before,
.loading-route-change-dialog__change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-arrow {
color: #c0c4cc;
text-align: right;
}
.loading-route-change-dialog__change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
}
.loading-route-change-map-dialog {
+53 -11
View File
@@ -1,7 +1,7 @@
<template>
<basic-container class="master-order-page">
<template v-if="mode === 'list'">
<div class="master-order-page__search">
<div v-show="searchVisible" class="master-order-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="master-order-page__search-grid">
<el-form-item v-for="field in primaryFields" :key="field.prop" :label="field.label">
@@ -55,8 +55,16 @@
</div>
<section class="master-list-panel">
<div class="toolbar">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button
><el-button type="primary" plain @click="download">批量导出</el-button>
<div class="toolbar-left">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button>
<el-button type="primary" plain @click="download">批量导出</el-button>
</div>
<table-header-tools
:show-column="false"
:show-grid="false"
v-model:search-visible="searchVisible"
@refresh="load"
/>
</div>
<div v-loading="loading" class="master-cards">
<article v-for="row in records" :key="row.id" class="master-card">
@@ -71,7 +79,7 @@
<p>{{ timeRange(row) }}</p>
<p>{{ goodsSummary(row) }}</p>
<p>创建人:{{ row.createUserName || '-' }}</p>
<p>创建时间:{{ row.createTime || '-' }}</p>
<p>创建时间:{{ formatDate(row.createTime) }}</p>
</section>
<section class="route-progress">
<div class="route-track">
@@ -147,12 +155,14 @@
<script>
import * as api from '@/api/business/master-order';
import { splitRegionTokens } from '@/utils/region-address';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import MasterOrderEditor from './components/master-order-editor.vue';
import MasterOrderDispatch from './components/master-order-dispatch.vue';
import MasterOrderDetail from './components/master-order-detail.vue';
export default {
components: { MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
components: { TableHeaderTools, MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
data() {
return {
// 实例创建时锁定所属路由。keep-alive 缓存后 $route 会变成其它页面,
@@ -160,6 +170,7 @@ export default {
//(mode=add 且无 id)会落入详情分支并请求缺少 id 的详情接口。
routePathLocked: this.$route.path,
searchExpanded: false,
searchVisible: true,
loading: false,
records: [],
query: { businessStatus: '' },
@@ -322,6 +333,10 @@ export default {
? `${String(row.planStartTime).slice(0, 10)} - ${String(row.planEndTime).slice(0, 10)}`
: '-';
},
// 创建时间只展示日期。
formatDate(value) {
return value ? String(value).slice(0, 10) : '-';
},
goodsSummary(row) {
const item = (row.goods || [])[0] || {};
if (!item.cargoName && !item.cargoType) return '-';
@@ -381,6 +396,26 @@ export default {
}`;
return `${city} ${district}`;
},
// 悬停气泡里的地址:起讫点若只保存了名称或详细地址的一半,气泡就会显示成
// “柳南区”“安徽”这类残缺地址,故把两个字段互为补充后展示。
completeAddress(name, address) {
const rawName = String(name || '').trim();
const rawAddress = String(address || '').trim();
if (!rawName) return rawAddress;
if (!rawAddress) return rawName;
// 详细地址已含省市,本身就是完整地址,不再拼接名称,避免重复卡片上已有的站点名。
if (/(?:省|自治区|特别行政区|市)/.test(rawAddress)) return rawAddress;
const normalizedName = rawName.replace(/\s+/g, '');
const normalizedAddress = rawAddress.replace(/\s+/g, '');
if (normalizedAddress.includes(normalizedName)) return rawAddress;
if (normalizedName.includes(normalizedAddress)) return rawName;
// “珠海1号港口”与“珠海港口1号”这类同字异序写法只保留一个。
if ([...normalizedName].sort().join('') === [...normalizedAddress].sort().join('')) {
return normalizedName.length >= normalizedAddress.length ? rawName : rawAddress;
}
// 区域在前、详细地址在后。
return splitRegionTokens(rawName) ? `${rawName}${rawAddress}` : `${rawAddress}${rawName}`;
},
routeNodeName(value, address, transportType, region = {}) {
const text = String(value || '').trim();
if (!text) return '-';
@@ -447,15 +482,12 @@ export default {
if (this.isStationLikeName(rawAddress) && !this.isStationLikeName(rawName)) {
return {
name: rawAddress,
address: rawName || regionText || rawAddress,
address: this.completeAddress(rawName, rawAddress) || regionText || rawAddress,
};
}
const displayName = rawName || rawAddress || '-';
const tipAddress =
(rawAddress && rawAddress !== displayName ? rawAddress : '') ||
regionText ||
rawAddress ||
displayName;
this.completeAddress(rawName, rawAddress) || regionText || rawAddress || displayName;
return { name: displayName, address: tipAddress };
}
return {
@@ -465,7 +497,7 @@ export default {
siteCode,
nextTransportType,
}),
address: rawAddress || rawName || regionText || '-',
address: this.completeAddress(rawName, rawAddress) || regionText || '-',
};
},
routeNodes(row = {}) {
@@ -633,8 +665,18 @@ export default {
}
.master-list-panel {
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
}
.master-card {
display: flex;
+3 -1
View File
@@ -416,6 +416,7 @@ import { getList as getProjectList } from '@/api/business/project-apply';
import { getDeptTree } from '@/api/system/dept';
import { exportBlob } from '@/api/common';
import { config, option } from '@/option/business/process-config';
import { applyIncomingSearch } from '@/utils/search-range';
import { getToken, isAdministrator } from '@/utils/auth';
import { downloadXls } from '@/utils/util';
import { mapGetters } from 'vuex';
@@ -603,7 +604,8 @@ export default {
});
},
normalizeListQuery(params = {}) {
const query = { ...params, ...this.query };
// 新条件覆盖旧条件,清空的筛选项不再沿用上一次的值。
const query = applyIncomingSearch(this.query, params);
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
+102 -31
View File
@@ -224,16 +224,19 @@
placeholder="请选择"
filterable
clearable
:disabled="isBasicInfoReadonly"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="平台公司" prop="undertakeDeptId">
<el-select
v-model="form.undertakeDeptId"
placeholder="请选择"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:disabled="isBasicInfoReadonly"
:disabled="dialogReadonly"
@change="handleUndertakeDeptChange"
>
<el-option
@@ -645,11 +648,22 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240" align="center">
@@ -945,7 +959,7 @@ const emptyForm = () => ({
projectShortName: '',
businessDeptId: '',
businessDeptName: '',
undertakeDeptId: '',
undertakeDeptId: [],
undertakeDeptName: '',
projectSource: '',
sourceRemark: '',
@@ -1135,7 +1149,15 @@ export default {
{ max: 20, message: '项目简称不能超过20个字符', trigger: 'blur' },
],
businessDeptId: [{ required: true, message: '请选择业务部门', trigger: 'change' }],
undertakeDeptId: [{ required: true, message: '请选择平台公司', trigger: 'change' }],
undertakeDeptId: [
{
type: 'array',
required: true,
min: 1,
message: '请选择平台公司',
trigger: 'change',
},
],
businessMode: [{ required: true, message: '请选择业务模式', trigger: 'change' }],
customerNames: [{ required: true, message: '请选择客户名称', trigger: 'change' }],
carrierNames: [{ required: true, message: '请选择下游承运商', trigger: 'change' }],
@@ -1497,13 +1519,13 @@ export default {
this.businessDeptTreeOptions = [{ label, value: detail.businessDeptId }];
this.deptOptions = [{ label, rawLabel: label, value: detail.businessDeptId }];
}
if (detail.undertakeDeptId) {
this.platformCompanyOptions = [
{
label: detail.undertakeDeptName || String(detail.undertakeDeptId),
value: detail.undertakeDeptId,
},
];
const undertakeDeptIds = this.parseUndertakeDeptIds(detail.undertakeDeptId);
const undertakeDeptNames = this.parseUndertakeDeptNames(detail.undertakeDeptName);
if (undertakeDeptIds.length) {
this.platformCompanyOptions = undertakeDeptIds.map((id, index) => ({
label: undertakeDeptNames[index] || String(id),
value: id,
}));
}
},
resolvePublicDictOptions(options, currentValue) {
@@ -1728,6 +1750,7 @@ export default {
bizId: row.id,
subjectName: row.projectName || '',
approvalStatus: row.approvalStatus || '',
mkEnabled: this.mkEnabled,
}).then(() => request())
: request();
afterMk.then(res => {
@@ -1849,6 +1872,8 @@ export default {
this.form = {
...form,
...situation,
undertakeDeptId: this.parseUndertakeDeptIds(form.undertakeDeptId),
undertakeDeptName: form.undertakeDeptName || '',
customerNames: this.getCustomerRowNames(customerRows, 'customer') || form.customerNames,
carrierNames: this.getCustomerRowNames(carrierRows, 'carrier') || form.carrierNames,
};
@@ -1932,6 +1957,7 @@ export default {
bizId: id,
subjectName: name,
approvalStatus: status,
mkEnabled: this.mkEnabled,
}).then(() => {
this.$message.success('提交成功!');
this.closeProjectForm();
@@ -2003,6 +2029,7 @@ export default {
bizId: id,
subjectName: this.form.projectName || '',
approvalStatus: this.form.approvalStatus || 'change_rejected',
mkEnabled: this.mkEnabled,
}).then(() => {
this.$message.success('提交成功!');
this.closeProjectForm();
@@ -2015,8 +2042,13 @@ export default {
});
},
normalizeSubmitForm(options = {}) {
const undertakeDeptIds = this.parseUndertakeDeptIds(this.form.undertakeDeptId);
const undertakeDeptName =
this.resolveUndertakeDeptNames(undertakeDeptIds) || this.form.undertakeDeptName || '';
const submitRow = {
...this.form,
undertakeDeptId: undertakeDeptIds.join(','),
undertakeDeptName,
fundLimit: this.formatAmount(this.form.fundLimit),
receivableLimit: this.formatAmount(this.form.receivableLimit),
...Object.fromEntries(
@@ -2408,11 +2440,40 @@ export default {
this.$refs.projectForm?.validateField('businessDeptId');
},
handleUndertakeDeptChange(value) {
const id = Array.isArray(value) ? value.at(-1) : value;
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
this.form.undertakeDeptName = dept ? dept.label : '';
const ids = this.parseUndertakeDeptIds(value);
this.form.undertakeDeptId = ids;
this.form.undertakeDeptName = this.resolveUndertakeDeptNames(ids);
this.$refs.projectForm?.validateField('undertakeDeptId');
},
parseUndertakeDeptIds(value) {
if (Array.isArray(value)) {
return value
.map(item => String(item ?? '').trim())
.filter(item => item && item !== '-1');
}
return String(value ?? '')
.split(/[,,]/)
.map(item => item.trim())
.filter(item => item && item !== '-1');
},
parseUndertakeDeptNames(value) {
return String(value ?? '')
.split(/[,,、]/)
.map(item => item.trim())
.filter(Boolean);
},
resolveUndertakeDeptNames(ids = []) {
return ids
.map(id => {
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
return dept?.label || '';
})
.filter(Boolean)
.join('、');
},
firstUndertakeDeptId(value) {
return this.parseUndertakeDeptIds(value)[0] || '';
},
// 由部门 id 反查「根 → 该节点」的 id 路径,供级联回显(找不到返回 null)
findDeptPath(id, tree = [], parents = []) {
const target = Array.isArray(id) ? id.at(-1) : id;
@@ -2432,7 +2493,8 @@ export default {
loadDeptOptions() {
getDeptTree().then(res => {
const deptTree = res.data.data || [];
const businessDeptTree = ['add', 'edit', 'majorSupplement'].includes(this.dialogType)
// 新增/编辑/重大补录/项目变更均可选择业务部门,排除外部组织
const businessDeptTree = ['add', 'edit', 'majorSupplement', 'change'].includes(this.dialogType)
? this.excludeExternalOrganization(deptTree)
: deptTree;
this.deptOptions = this.flattenDept(businessDeptTree);
@@ -2445,21 +2507,30 @@ export default {
const list = res.data.data || [];
this.platformCompanyOptions = list.map(item => ({
label: item.deptName || item.fullName || item.title || item.name,
value: item.id,
}));
// 雪花 ID 必须以字符串绑定,避免 JS Number 精度丢失导致多选提交异常
value: item.id == null || item.id === '' ? '' : String(item.id),
})).filter(item => item.value);
this.ensureCurrentPlatformOption();
});
},
ensureCurrentPlatformOption() {
const id = this.form.undertakeDeptId;
if (!id) return;
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: this.form.undertakeDeptName || String(id),
value: id,
});
}
const ids = this.parseUndertakeDeptIds(this.form.undertakeDeptId);
if (!ids.length) return;
const names = this.parseUndertakeDeptNames(this.form.undertakeDeptName);
ids.forEach((id, index) => {
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: names[index] || String(id),
value: id,
});
}
});
// 选项 value 与已选 ID 类型对齐,避免回显失败
this.form.undertakeDeptId = ids.map(id => {
const matched = this.platformCompanyOptions.find(item => String(item.value) === String(id));
return matched ? matched.value : id;
});
},
excludeExternalOrganization(list = []) {
return list.reduce((result, item) => {
@@ -2831,7 +2902,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 24px;
//margin-bottom: 24px;
}
:deep(.el-form-item__label) {
+50 -10
View File
@@ -99,11 +99,21 @@
<el-table :data="attachmentRows" border @selection-change="selectedAttachments = $event">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名称" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -184,6 +194,7 @@
title="查看临时额度申请"
append-to-body
destroy-on-close
align-center
:width="formDialogWidth"
class="temporary-credit-limit-dialog temporary-credit-limit-detail-dialog"
>
@@ -216,11 +227,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名称" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" show-overflow-tooltip>
@@ -615,6 +636,12 @@ export default {
}
const value = row[prop];
if (value === undefined || value === null || value === '') return '-';
if (
(prop === 'usedFundLimit' || prop === 'remainingFundLimit') &&
Number(value) === -1
) {
return '-';
}
return value;
},
applyDetail(detail) {
@@ -1188,10 +1215,10 @@ export default {
}
// 新增/编辑/查看:宽度与高度规则统一(Avue setPx 对 '1100px' 会拼成 '1100pxpx',故 option 用数字 1100)
// margin:auto 配合下方 unscoped 的 overlay flex,实现垂直水平居中
.temporary-credit-limit-dialog.el-dialog {
width: 1100px !important;
margin-top: 20px !important;
margin-bottom: 20px !important;
margin: auto !important;
height: auto !important;
max-height: calc(100vh - 40px) !important;
display: flex;
@@ -1329,3 +1356,16 @@ export default {
}
</style>
<!-- append-to-body 弹窗挂到 body,overlay 无 scoped 属性,需全局样式才能居中 -->
<style lang="scss">
.el-overlay-dialog:has(> .temporary-credit-limit-dialog) {
display: flex;
align-items: center;
justify-content: center;
}
.temporary-credit-limit-dialog.el-dialog {
margin: auto !important;
}
</style>
+18 -4
View File
@@ -96,11 +96,25 @@ const uploadReplaceFile = async file => {
if (!task?.id || !task.objectKey) throw new Error('创建分片上传任务失败');
replaceProgress.value = 0;
for (let partNumber = 1; partNumber <= task.chunkTotal; partNumber += 1) {
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
const part = file.slice((partNumber - 1) * chunkSize, Math.min(partNumber * chunkSize, file.size));
const response = await fetch(urlRes.data?.data, { method: 'PUT', body: part });
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
const url = urlRes.data?.data;
let etag = '';
const needProxy =
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
String(url || '')
.trim()
.toLowerCase()
.startsWith('http:');
if (!url || needProxy) {
const proxyRes = await api.uploadPartByProxy(task.id, partNumber, part);
etag = String(proxyRes.data?.data || '').replaceAll('"', '');
} else {
const response = await fetch(url, { method: 'PUT', body: part });
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
etag = String(response.headers.get('etag') || '').replaceAll('"', '');
}
if (!etag) throw new Error('未获取到分片 ETag');
await api.updateFileTask({ id: task.id, partNumber, etag });
replaceProgress.value = Math.round((partNumber / task.chunkTotal) * 100);
+138 -21
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="voucher-manage-page">
<section class="voucher-manage-page__search">
<section v-show="searchVisible" class="voucher-manage-page__search">
<el-form :model="query" label-position="right" label-width="88px">
<div class="voucher-manage-page__search-grid">
<el-form-item label="凭证批次号"
@@ -55,10 +55,25 @@
</section>
<section>
<div class="voucher-manage-page__toolbar">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button
><el-button plain @click="openProgress">上传进度</el-button>
<div class="voucher-manage-page__toolbar-left">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button>
<el-button plain @click="openProgress">上传进度</el-button>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="load"
/>
</div>
<el-table v-loading="loading" :data="rows" border class="voucher-manage-page__table">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
class="voucher-manage-page__table"
>
<el-table-column type="selection" width="52" fixed="left" align="center" /><el-table-column
type="index"
label="序号"
@@ -66,27 +81,47 @@
fixed="left"
align="center"
/>
<el-table-column prop="voucherBatchNo" label="凭证批次号" min-width="150" fixed="left"
<el-table-column
v-if="isColumnVisible('voucherBatchNo')"
prop="voucherBatchNo"
label="凭证批次号"
min-width="150"
fixed="left"
><template #default="{ row }"
><el-link class="voucher-manage-page__link" type="primary" @click="view(row)">{{
row.voucherBatchNo
}}</el-link></template
></el-table-column
>
<el-table-column prop="waybillBatchNo" label="运单批次号" min-width="170" /><el-table-column
<el-table-column
v-if="isColumnVisible('waybillBatchNo')"
prop="waybillBatchNo"
label="运单批次号"
min-width="170"
/><el-table-column
v-if="isColumnVisible('fileName')"
prop="fileName"
label="文件名"
min-width="200"
show-overflow-tooltip
/>
<el-table-column v-if="!isCarrier" prop="uploadSource" label="上传来源" width="100" /><el-table-column
v-if="!isCarrier"
<el-table-column
v-if="!isCarrier && isColumnVisible('uploadSource')"
prop="uploadSource"
label="上传来源"
width="100"
/><el-table-column
v-if="!isCarrier && isColumnVisible('carrierName')"
prop="carrierName"
label="承运商名称"
min-width="160"
><template #default="{ row }">{{ row.carrierName || '-' }}</template></el-table-column
>
<el-table-column prop="processStatus" label="处理状态" width="120"
<el-table-column
v-if="isColumnVisible('processStatus')"
prop="processStatus"
label="处理状态"
width="120"
><template #default="{ row }"
><span
:class="{ 'voucher-manage-page__process-failed': row.processStatus === '处理失败' }"
@@ -94,17 +129,35 @@
></template
></el-table-column
><el-table-column
v-if="isColumnVisible('voucherCount')"
prop="voucherCount"
label="凭证数量"
width="100"
/><el-table-column
v-if="isColumnVisible('relatedWaybillCount')"
prop="relatedWaybillCount"
label="凭证已关联数量"
width="125"
/> <el-table-column prop="unRelatedWaybillCount" label="凭证未关联数量" width="125" />
<el-table-column prop="createUserName" label="创建人" min-width="120" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
/><el-table-column
v-if="isColumnVisible('unRelatedWaybillCount')"
prop="unRelatedWaybillCount"
label="凭证未关联数量"
width="125"
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
prop="createUserName"
label="创建人"
min-width="120"
/>
<el-table-column
v-if="isColumnVisible('createTime')"
prop="createTime"
label="创建时间"
min-width="170"
/>
<el-table-column
v-if="isColumnVisible('auditStatus')"
prop="auditStatus"
label="审核状态"
width="120"
@@ -512,6 +565,7 @@ import { WarnTriangleFilled } from '@element-plus/icons-vue';
import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage';
import SectionCard from '@/components/section-card/main.vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
const router = useRouter();
const store = useStore();
@@ -539,6 +593,47 @@ const checkIfCarrier = () => {
isCarrier.value = topDeptName === '外部组织';
};
const VOUCHER_BASE_COLUMNS = [
{ prop: 'voucherBatchNo', label: '凭证批次号' },
{ prop: 'waybillBatchNo', label: '运单批次号' },
{ prop: 'fileName', label: '文件名' },
{ prop: 'uploadSource', label: '上传来源', carrierHidden: true },
{ prop: 'carrierName', label: '承运商名称', carrierHidden: true },
{ prop: 'processStatus', label: '处理状态' },
{ prop: 'voucherCount', label: '凭证数量' },
{ prop: 'relatedWaybillCount', label: '凭证已关联数量' },
{ prop: 'unRelatedWaybillCount', label: '凭证未关联数量' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'auditStatus', label: '审核状态' },
];
const searchVisible = ref(true);
const tableSize = ref('default');
const visibleColumnKeys = ref([]);
const columnSettingOptions = computed(() =>
VOUCHER_BASE_COLUMNS.filter(column => !isCarrier.value || !column.carrierHidden).map(column => ({
prop: column.prop,
label: column.label,
}))
);
const isColumnVisible = prop => visibleColumnKeys.value.includes(prop);
watch(
columnSettingOptions,
options => {
const props = options.map(column => column.prop);
if (!visibleColumnKeys.value.length) {
visibleColumnKeys.value = props;
return;
}
const visible = new Set(visibleColumnKeys.value);
visibleColumnKeys.value = props.filter(prop => visible.has(prop));
if (!visibleColumnKeys.value.length) visibleColumnKeys.value = props;
},
{ immediate: true }
);
onMounted(() => {
checkIfCarrier();
});
@@ -742,6 +837,30 @@ const calculateMd5 = async file => {
const buffer = await file.arrayBuffer();
return md5.base64(buffer);
};
const needsUploadProxy = url =>
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
String(url || '')
.trim()
.toLowerCase()
.startsWith('http:');
const uploadPartWithFallback = async (taskId, partNumber, part, signal) => {
const urlRes = await api.getPartUploadUrl(taskId, partNumber);
if (signal?.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
// HTTPS 页面直连 http://IP MinIO 会被 Mixed Content 拦截,回退服务端代传
if (!url || needsUploadProxy(url)) {
const proxyRes = await api.uploadPartByProxy(taskId, partNumber, part);
return String(proxyRes.data?.data || '').replaceAll('"', '');
}
const response = await fetch(url, {
method: 'PUT',
body: part,
signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
return String(response.headers.get('etag') || '').replaceAll('"', '');
};
const uploadParts = async (file, task, sessionId) => {
uploading.value = true;
activeUploadTaskId.value = task.id;
@@ -756,21 +875,13 @@ const uploadParts = async (file, task, sessionId) => {
) {
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
const part = file.slice(
(partNumber - 1) * chunkSize,
Math.min(partNumber * chunkSize, file.size)
);
const response = await fetch(url, {
method: 'PUT',
body: part,
signal: abortController.signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
const etag = await uploadPartWithFallback(task.id, partNumber, part, abortController.signal);
if (!etag) throw new Error('MinIO 未返回分片 ETag,请确认跨域配置已暴露 ETag 响应头');
await api.updateFileTask({ id: task.id, partNumber, etag });
ensureUploadSession(sessionId, task.id);
@@ -1329,9 +1440,15 @@ load();
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 12px 0;
}
&__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
&__table {
width: 100%;
}
+4
View File
@@ -1,6 +1,7 @@
<template>
<basic-container class="bill-ledger-page">
<bill-ledger-search
v-if="searchVisible"
:query="query"
:loading="loading"
@search="handleSearch"
@@ -20,10 +21,12 @@
:rows="rows"
:loading="loading"
:page="page"
v-model:search-visible="searchVisible"
@add="openCreate"
@view="openView"
@edit="openEdit"
@delete="handleDelete"
@refresh="loadData"
@page-change="handlePageChange"
@size-change="handleSizeChange"
/>
@@ -124,6 +127,7 @@ export default {
return {
query: emptyQuery(),
counts: { all: 0, within30: 0, within90: 0, over90: 0 },
searchVisible: true,
rows: [],
loading: false,
page: { current: 1, size: 10, total: 0 },
+2 -1
View File
@@ -140,7 +140,7 @@ import * as api from '@/api/payment/billPayment';
import * as billLedgerApi from '@/api/payment/billLedger';
import VehicleAttachmentTable from '@/components/vehicle-attachment-table/main.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
const emptyForm = () => ({
id: null,
@@ -348,6 +348,7 @@ export default {
bizId: this.form.id,
subjectName: this.form.paymentNo || this.form.billNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('提交成功');
this.goBack();
+5
View File
@@ -1,6 +1,7 @@
<template>
<basic-container class="bill-payment-page">
<bill-payment-search
v-if="searchVisible"
:query="query"
:loading="loading"
@search="handleSearch"
@@ -11,6 +12,7 @@
:loading="loading"
:page="page"
:mk-enabled="mkEnabled"
v-model:search-visible="searchVisible"
@add="openCreate"
@view="openView"
@edit="openEdit"
@@ -20,6 +22,7 @@
@approve="handleApprove"
@return="handleReturn"
@void="handleVoid"
@refresh="loadTable"
@page-change="handlePageChange"
@size-change="handleSizeChange"
/>
@@ -47,6 +50,7 @@ export default {
data() {
return {
query: emptyQuery(),
searchVisible: true,
rows: [],
loading: false,
page: { current: 1, size: 10, total: 0 },
@@ -128,6 +132,7 @@ export default {
bizId: row.id,
subjectName: row.paymentNo || row.billNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
@@ -52,12 +52,13 @@
<el-icon><Delete /></el-icon>
<span>重置</span>
</el-button>
<el-tooltip :content="expanded ? '折叠' : '展开'" placement="top">
<el-button text @click="expanded = !expanded">
<el-icon><component :is="expanded ? ArrowUp : ArrowDown" /></el-icon>
<span>{{ expanded ? '折叠' : '展开' }}</span>
</el-button>
</el-tooltip>
<el-button
v-if="showSearchToggle"
text
:icon="expanded ? ArrowUp : ArrowDown"
@click="expanded = !expanded"
>{{ expanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section>
@@ -81,10 +82,18 @@ export default {
Delete,
Search,
expanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 6,
billTypeOptions,
maturityStatusOptions,
};
},
computed: {
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
},
};
</script>
@@ -4,11 +4,19 @@
<el-button v-if="hasPermission('bill_ledger_add')" type="primary" @click="$emit('add')">
新增
</el-button>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table v-loading="loading" :data="rows" border>
<el-table v-loading="loading" :data="rows" :size="tableSize" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -60,20 +68,30 @@
<script>
import { mapGetters } from 'vuex';
import { billLedgerTableColumns } from '@/option/payment/billLedger';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
export default {
name: 'BillLedgerTable',
components: { TableHeaderTools },
props: {
rows: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
page: { type: Object, required: true },
searchVisible: { type: Boolean, default: true },
},
emits: ['add', 'view', 'edit', 'delete', 'page-change', 'size-change'],
emits: ['add', 'view', 'edit', 'delete', 'page-change', 'size-change', 'refresh', 'update:searchVisible'],
data() {
return { columns: billLedgerTableColumns };
return {
columns: billLedgerTableColumns,
visibleColumnKeys: billLedgerTableColumns.map(column => column.prop),
tableSize: 'default',
};
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
methods: {
hasPermission(code) {
@@ -96,7 +114,7 @@ export default {
.bill-ledger-table__toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: space-between;
padding: 12px 0;
}
.bill-ledger-table__links {
@@ -43,11 +43,13 @@
<div class="bill-payment-search__actions">
<el-button type="primary" :loading="loading" @click="$emit('search')">查询</el-button>
<el-button @click="$emit('reset')">重置</el-button>
<el-tooltip :content="expanded ? '折叠' : '展开'" placement="top">
<el-button text @click="expanded = !expanded">
<el-icon><component :is="expanded ? ArrowUp : ArrowDown" /></el-icon>
</el-button>
</el-tooltip>
<el-button
v-if="showSearchToggle"
text
:icon="expanded ? ArrowUp : ArrowDown"
@click="expanded = !expanded"
>{{ expanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section>
@@ -67,7 +69,20 @@ export default {
},
emits: ['search', 'reset'],
data() {
return { ArrowDown, ArrowUp, expanded: false, approvalStatusOptions };
return {
ArrowDown,
ArrowUp,
expanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 4,
approvalStatusOptions,
};
},
computed: {
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
},
mounted() {
this.loadOrganizationOptions();
@@ -4,11 +4,19 @@
<el-button v-if="hasPermission('bill_payment_add')" type="primary" @click="$emit('add')">
新增
</el-button>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table v-loading="loading" :data="rows" border>
<el-table v-loading="loading" :data="rows" :size="tableSize" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -115,15 +123,18 @@
<script>
import { mapGetters } from 'vuex';
import { billPaymentTableColumns } from '@/option/payment/billPayment';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import { isAdministrator } from '@/utils/auth';
export default {
name: 'BillPaymentTable',
components: { TableHeaderTools },
props: {
rows: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
page: { type: Object, required: true },
mkEnabled: { type: Boolean, default: false },
searchVisible: { type: Boolean, default: true },
},
emits: [
'add',
@@ -137,12 +148,21 @@ export default {
'void',
'page-change',
'size-change',
'refresh',
'update:searchVisible',
],
data() {
return { columns: billPaymentTableColumns };
return {
columns: billPaymentTableColumns,
visibleColumnKeys: billPaymentTableColumns.map(column => column.prop),
tableSize: 'default',
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -173,7 +193,8 @@ export default {
}
.bill-payment-table__toolbar {
display: flex;
justify-content: flex-start;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.bill-payment-table__links {
@@ -492,13 +492,14 @@ import { getDictionary } from '@/api/system/dictbiz';
import { invoiceApplicationDetailColumns } from '@/option/payment/invoiceApplication';
import VehicleAttachmentTable from '@/components/vehicle-attachment-table/main.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
let localId = 0;
const emptyLine = () => ({
localId: ++localId,
goodsCategory: '',
goodsName: '',
taxClassificationCode: '',
unit: '吨',
quantity: 0,
unitPriceNoTax: 0,
@@ -742,9 +743,11 @@ export default {
const item = this.findInvoiceItem(line);
if (item) {
line.taxRate = Number(item.defaultTaxRate || 0);
line.taxClassificationCode = item.taxClassificationCode || '';
} else if (clearInvalid) {
line.goodsName = '';
line.taxRate = 0;
line.taxClassificationCode = '';
}
this.recalculateLine(line);
},
@@ -752,6 +755,7 @@ export default {
const names = this.invoiceItemNames(line.goodsCategory);
if (!names.some(item => item.shortName === line.goodsName)) line.goodsName = '';
line.taxRate = 0;
line.taxClassificationCode = this.findInvoiceItem(line)?.taxClassificationCode || '';
if (names.length === 1) {
line.goodsName = names[0].shortName;
this.handleGoodsNameChange(line);
@@ -761,9 +765,11 @@ export default {
const item = this.findInvoiceItem(line);
if (!item) {
line.taxRate = 0;
line.taxClassificationCode = '';
return;
}
line.taxRate = Number(item.defaultTaxRate || 0);
line.taxClassificationCode = item.taxClassificationCode || '';
this.recalculateLine(line);
},
validateSettlements(rule, value, callback) {
@@ -1159,6 +1165,7 @@ export default {
lines: sheet.lines.map(line => ({
goodsCategory: line.goodsCategory,
goodsName: line.goodsName,
taxClassificationCode: line.taxClassificationCode,
unit: line.unit,
quantity: line.quantity,
unitPriceNoTax: line.unitPriceNoTax,
@@ -1201,6 +1208,7 @@ export default {
bizId: this.form.id,
subjectName: this.form.applicationNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('提交成功');
this.goBack();
+109 -22
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="invoice-page">
<section class="invoice-page__search">
<section v-if="searchVisible" class="invoice-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="invoice-page__search-grid">
<el-form-item label="单据号">
@@ -45,11 +45,13 @@
<div class="invoice-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-tooltip :content="searchExpanded ? '折叠' : '展开'" placement="top">
<el-button text @click="searchExpanded = !searchExpanded">
<el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon>
</el-button>
</el-tooltip>
<el-button
v-if="showSearchToggle"
text
:icon="searchExpanded ? ArrowUp : ArrowDown"
@click="searchExpanded = !searchExpanded"
>{{ searchExpanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section>
@@ -61,6 +63,7 @@
v-if="hasPermission('invoice_application_sync')"
type="primary"
plain
:loading="batchSyncing"
@click="handleBatchSync"
>批量同步</el-button
>
@@ -78,18 +81,26 @@
>导出</el-button
>
</div>
<div class="invoice-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button text :icon="Refresh" @click="loadTable" />
</el-tooltip>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selection = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -114,6 +125,12 @@
{{ displayValue(row[column.prop]) }}
</el-tag>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<template v-else-if="column.prop === 'kingdeeLockStatus'">
<el-tooltip v-if="lockTag(row).show" :content="lockTag(row).tip" placement="top">
<el-tag :type="lockTag(row).type" class="status-text">{{ lockTag(row).label }}</el-tag>
</el-tooltip>
<span v-else>-</span>
</template>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
@@ -182,7 +199,9 @@
>
<el-link
v-if="
hasPermission('invoice_application_void') && row.approvalStatus === 'approved'
hasPermission('invoice_application_void') &&
row.approvalStatus === 'approved' &&
row.kingdeeLockStatus !== 'locked'
"
type="danger"
@click="handleVoid(row)"
@@ -209,12 +228,13 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as XLSX from 'xlsx';
import * as api from '@/api/payment/invoiceApplication';
import * as formalSettlementApi from '@/api/settlement/formalSettlement';
import { invoiceApplicationTableColumns } from '@/option/payment/invoiceApplication';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
@@ -230,26 +250,39 @@ const emptyQuery = () => ({
export default {
name: 'InvoiceApplication',
components: { TableHeaderTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 5,
searchVisible: true,
tableSize: 'default',
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
columns: invoiceApplicationTableColumns,
visibleColumnKeys: invoiceApplicationTableColumns.map(column => column.prop),
rows: [],
selection: [],
loading: false,
batchSyncing: false,
page: { current: 1, size: 10, total: 0 },
mkEnabled: false,
};
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
},
mounted() {
this.loadOrganizationOptions();
@@ -351,6 +384,7 @@ export default {
bizId: row.id,
subjectName: row.applicationNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
@@ -375,8 +409,13 @@ export default {
},
async handleApprove(row) {
await this.$confirm('确认审批通过该开票申请?', '提示', { type: 'warning' });
await api.approve({ id: row.id });
this.$message.success('审批通过');
const res = await api.approve({ id: row.id });
// data=false 表示审批已通过但自动同步金蝶失败,msg 带具体原因
if (res.data.data === false) {
this.$message.warning(res.data.msg);
} else {
this.$message.success(res.data.msg || '审批通过');
}
this.loadTable();
},
async handleReturn(row) {
@@ -392,14 +431,31 @@ export default {
this.loadTable();
},
async handleBatchSync() {
if (this.batchSyncing) return;
const rows = this.selection.filter(row => row.approvalStatus === 'approved');
if (!rows.length) {
this.$message.warning('请选择至少一条审批通过的开票申请');
return;
}
await Promise.all(rows.map(row => api.syncKingdee(row.id)));
this.$message.success(`已同步${rows.length}条开票申请`);
this.loadTable();
this.batchSyncing = true;
try {
await Promise.all(rows.map(row => api.syncKingdee(row.id)));
this.$message.success(`已同步${rows.length}条开票申请`);
await this.loadTable();
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
const idSet = new Set(rows.map(row => String(row.id)));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
if (pendingCount) {
this.$message({
type: 'warning',
message: `其中${pendingCount}单附件未全部上传,可重推补传`,
duration: 8000,
});
}
} finally {
this.batchSyncing = false;
}
},
async handleExport() {
const data = this.unwrapData(await api.getList(1, 100000, this.query));
@@ -429,6 +485,38 @@ export default {
''
);
},
lockTag(row) {
if (row.kingdeeLockStatus === 'locked') {
return { show: true, type: 'danger', label: '金蝶锁定中', tip: '该单据已被金蝶锁定,不可编辑/作废' };
}
if (row.kingdeeLockStatus === 'failed') {
if ((row.kingdeeTryCount || 0) >= 5) {
return {
show: true,
type: 'danger',
label: '锁定失败·已达上限',
tip: row.kingdeeLockFailReason || '自动补偿次数已用完,请重推「同步金蝶」立即补锁',
};
}
return {
show: true,
type: 'warning',
label: '锁定失败·补偿中',
tip: row.kingdeeLockFailReason || '系统将自动补偿重锁',
};
}
// 金蝶解锁终态标识:删单解锁的源单已作废,此标签用于区分人工作废与金蝶删单
if (row.kingdeeUnlockTime) {
const isDelete = row.kingdeeUnlockOperationName === '金蝶删单';
return {
show: true,
type: isDelete ? 'danger' : 'warning',
label: row.kingdeeUnlockOperationName || '金蝶解锁',
tip: `${row.kingdeeUnlockOperator || '-'} 于 ${this.$dayjs(row.kingdeeUnlockTime).format('YYYY-MM-DD HH:mm:ss')} 解锁`,
};
}
return { show: false };
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
@@ -477,7 +565,6 @@ export default {
padding: 12px 0;
}
.invoice-page__toolbar-left,
.invoice-page__toolbar-right,
.invoice-page__links {
display: flex;
align-items: center;
+34 -241
View File
@@ -47,7 +47,7 @@
<el-col :span="6">
<el-form-item label="开票金额">
<el-input
:model-value="form.kingdeeInvoicePoolId ? formatMoney(form.invoiceAmount) : ''"
:model-value="form.invoiceNo ? formatMoney(form.invoiceAmount) : ''"
disabled
placeholder="从金蝶票据池带出"
/>
@@ -56,7 +56,7 @@
<el-col :span="6">
<el-form-item label="税额">
<el-input
:model-value="form.kingdeeInvoicePoolId ? formatMoney(form.taxAmount) : ''"
:model-value="form.invoiceNo ? formatMoney(form.taxAmount) : ''"
disabled
placeholder="从金蝶票据池带出"
/>
@@ -118,7 +118,13 @@
</section-card>
<section-card title="附件信息">
<vehicle-attachment-table v-model="form.attachments" :readonly="readonly" />
<!-- 金蝶票池认领的发票附件为 piaozone path 型,需带发票号走 A801-2 代理查看/下载 -->
<vehicle-attachment-table
v-model="form.attachments"
:readonly="readonly"
show-view-actions
:kingdee-invoice-no="form.invoiceNo"
/>
</section-card>
<section-card title="备注">
@@ -149,62 +155,13 @@
</div>
</el-form>
<el-dialog v-model="invoiceDialog.visible" title="查询金蝶票据池" width="86%" append-to-body>
<div class="receipt-form-page__dialog-search">
<el-input
v-model="invoiceDialog.keyword"
clearable
placeholder="发票号码、开票单位或受票单位"
@keyup.enter="loadInvoicePool"
/>
<el-button type="primary" @click="loadInvoicePool">查询</el-button>
</div>
<el-table
v-loading="invoiceDialog.loading"
:data="invoiceDialog.rows"
border
empty-text="暂无票据数据"
highlight-current-row
@current-change="invoiceDialog.current = $event"
@row-dblclick="confirmInvoice"
>
<el-table-column prop="invoiceNo" label="发票号码" min-width="180" />
<el-table-column prop="invoiceDate" label="开票日期" min-width="120" />
<el-table-column prop="invoiceType" label="发票类型" min-width="130" />
<el-table-column prop="issuerName" label="开票单位" min-width="170" />
<el-table-column prop="receiverName" label="受票单位" min-width="170" />
<el-table-column label="开票金额" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.invoiceAmount) }}</template>
</el-table-column>
<el-table-column label="税率" min-width="100" align="center">
<template #default="{ row }">{{ formatRate(row.taxRate) }}</template>
</el-table-column>
<el-table-column label="税额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.taxAmount) }}</template>
</el-table-column>
<el-table-column prop="kingdeeBillNo" label="金蝶单据号" min-width="150" />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-link type="primary" @click.stop="confirmInvoice(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<div class="receipt-form-page__dialog-pagination">
<el-pagination
v-model:current-page="invoiceDialog.page.current"
v-model:page-size="invoiceDialog.page.size"
:total="invoiceDialog.page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="refreshInvoicePoolPage"
@size-change="handleInvoicePoolSizeChange"
/>
</div>
<template #footer>
<el-button @click="invoiceDialog.visible = false">取消</el-button>
<el-button type="primary" @click="confirmInvoice()">确定</el-button>
</template>
</el-dialog>
<kingdee-invoice-pool-dialog
v-model="invoiceDialog.visible"
title="查询金蝶票据池"
:initial-invoice-no="form.invoiceNo"
:query-api="apiQueryInvoicePool"
@confirm="handleInvoicePicked"
/>
<el-dialog
v-model="settlementDialog.visible"
@@ -259,82 +216,13 @@
import { Search } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/invoiceReceipt';
import { getList as getCustomerList } from '@/api/vehicle/customer-archive';
import KingdeeInvoicePoolDialog from '@/components/kingdee-invoice-pool-dialog/main.vue';
import VehicleAttachmentTable from '@/components/vehicle-attachment-table/main.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
const mockInvoiceTypes = ['增值税专用发票', '增值税普通发票'];
const mockTaxRates = [3, 6, 9, 13];
const mockBankNames = ['中国工商银行', '中国农业银行', '中国银行', '中国建设银行', '招商银行'];
const randomDigits = length =>
Array.from({ length }, () => Math.floor(Math.random() * 10)).join('');
const formatMockDate = date => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const customerName = customer =>
customer.fullName || customer.shortName || customer.customerCode || `客商${customer.id}`;
const customerEmail = (customer, index) => {
const prefix = String(customer.customerCode || customer.id || `customer${index + 1}`)
.replace(/[^a-zA-Z0-9_-]/g, '')
.toLowerCase();
return `finance@${prefix || `customer${index + 1}`}.example.com`;
};
const createInvoicePoolMockRows = (customers, counterpartyName, departmentEmail) => {
const rows = [];
const baseId = Date.now();
customers.forEach((customer, customerIndex) => {
const name = customerName(customer);
['issuer', 'receiver'].forEach((role, roleIndex) => {
const count = 3 + Math.floor(Math.random() * 5);
for (let index = 0; index < count; index += 1) {
const rowIndex = rows.length;
const invoiceDateValue = new Date();
invoiceDateValue.setDate(invoiceDateValue.getDate() - Math.floor(Math.random() * 90));
const invoiceDate = formatMockDate(invoiceDateValue);
const taxRate = mockTaxRates[(customerIndex + roleIndex + index) % mockTaxRates.length];
const invoiceAmount = Math.floor(10000 + Math.random() * 190000);
const taxAmount = Number(
((invoiceAmount * Number(taxRate)) / (100 + Number(taxRate))).toFixed(2)
);
const bankName = mockBankNames[(customerIndex + index) % mockBankNames.length];
rows.push({
id: baseId + rowIndex,
invoiceNo: `25${randomDigits(18)}`,
invoiceDate,
invoiceType: mockInvoiceTypes[(customerIndex + index) % mockInvoiceTypes.length],
taxRate,
invoiceAmount,
taxAmount,
receiverName: role === 'receiver' ? name : counterpartyName,
issuerName: role === 'issuer' ? name : counterpartyName,
bankName,
bankAccount: randomDigits(19),
issuingBank: `${bankName}营业部`,
phone: customer.contactPhone || '',
customerEmails: customerEmail(customer, customerIndex),
departmentEmails: departmentEmail || '',
attachmentsJson: '[]',
kingdeeBillNo: `KD-FP-${invoiceDate.replaceAll('-', '')}${randomDigits(5)}`,
kingdeeStatus: 'unsynced',
});
}
});
});
return rows;
};
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
const emptyForm = () => ({
id: null,
kingdeeInvoicePoolId: null,
invoiceNo: '',
invoiceDate: '',
invoiceType: '',
@@ -346,8 +234,6 @@ const emptyForm = () => ({
bankName: '',
bankAccount: '',
issuingBank: '',
kingdeeBillNo: '',
kingdeeStatus: 'unsynced',
phone: '',
customerEmails: [],
departmentEmails: [],
@@ -363,24 +249,17 @@ const emptyForm = () => ({
export default {
name: 'InvoiceReceiptForm',
components: {
KingdeeInvoicePoolDialog,
VehicleAttachmentTable,
},
data() {
return {
Search,
form: emptyForm(),
invoicePoolMockRows: [],
invoicePoolMockLoaded: false,
customerEmailOptions: [],
departmentEmailOptions: [],
invoiceDialog: {
visible: false,
loading: false,
keyword: '',
sourceRows: [],
rows: [],
current: null,
page: { current: 1, size: 10, total: 0 },
},
settlementDialog: {
visible: false,
@@ -426,6 +305,12 @@ export default {
this.initialize();
},
methods: {
apiQueryInvoicePool(params) {
return api.queryInvoicePool(params);
},
handleInvoicePicked(rows) {
this.confirmInvoice(rows[0]);
},
hasPermission(code) {
return this.permission?.[code] !== false;
},
@@ -483,7 +368,7 @@ export default {
};
},
validateInvoice(rule, value, callback) {
if (!value || !this.form.kingdeeInvoicePoolId) {
if (!value) {
callback(new Error('请选择金蝶票据池发票'));
return;
}
@@ -509,99 +394,17 @@ export default {
}
callback();
},
async openInvoiceDialog() {
openInvoiceDialog() {
this.invoiceDialog.visible = true;
this.invoiceDialog.keyword = this.form.invoiceNo || '';
await this.loadInvoicePool();
},
async loadInvoicePool() {
this.invoiceDialog.loading = true;
this.invoiceDialog.page.current = 1;
this.invoiceDialog.current = null;
try {
if (!this.recordId) {
if (!this.invoicePoolMockLoaded) await this.loadInvoicePoolMockRows();
this.invoiceDialog.sourceRows = this.filterInvoicePoolMockRows(
this.invoiceDialog.keyword
);
this.refreshInvoicePoolPage();
return;
}
const data = this.unwrapData(await api.getInvoicePool(this.invoiceDialog.keyword));
this.invoiceDialog.sourceRows = Array.isArray(data) ? data : data?.records || [];
this.refreshInvoicePoolPage();
} finally {
this.invoiceDialog.loading = false;
}
},
async loadInvoicePoolMockRows() {
const pageSize = 500;
const firstData = this.unwrapData(await getCustomerList(1, pageSize, {}));
const firstRows = Array.isArray(firstData) ? firstData : firstData?.records || [];
const total = Number(firstData?.total || firstRows.length);
const pageCount = Math.ceil(total / pageSize);
const remainingPages = await Promise.all(
Array.from({ length: Math.max(pageCount - 1, 0) }, (_, index) =>
getCustomerList(index + 2, pageSize, {})
)
);
const customers = [
...firstRows,
...remainingPages.flatMap(response => {
const data = this.unwrapData(response);
return Array.isArray(data) ? data : data?.records || [];
}),
].filter(
(customer, index, rows) =>
rows.findIndex(item => String(item.id) === String(customer.id)) === index
);
const userInfo = this.$store.getters.userInfo || {};
const counterpartyName = userInfo.deptName || userInfo.dept_name || '本企业';
this.invoicePoolMockRows = createInvoicePoolMockRows(
customers,
counterpartyName,
userInfo.email || ''
);
this.invoicePoolMockLoaded = true;
},
refreshInvoicePoolPage() {
const page = this.invoiceDialog.page;
const total = this.invoiceDialog.sourceRows.length;
const maxPage = Math.max(Math.ceil(total / page.size), 1);
if (page.current > maxPage) page.current = maxPage;
const start = (page.current - 1) * page.size;
page.total = total;
this.invoiceDialog.current = null;
this.invoiceDialog.rows = this.invoiceDialog.sourceRows.slice(start, start + page.size);
},
handleInvoicePoolSizeChange() {
this.invoiceDialog.page.current = 1;
this.refreshInvoicePoolPage();
},
filterInvoicePoolMockRows(keyword) {
const normalizedKeyword = String(keyword || '')
.trim()
.toLowerCase();
if (!normalizedKeyword) return this.invoicePoolMockRows.map(item => ({ ...item }));
return this.invoicePoolMockRows
.filter(item =>
[item.invoiceNo, item.issuerName, item.receiverName].some(value =>
String(value || '')
.toLowerCase()
.includes(normalizedKeyword)
)
)
.map(item => ({ ...item }));
},
confirmInvoice(row, options = {}) {
const selected = row?.id ? row : this.invoiceDialog.current;
const selected = row;
if (!selected) {
this.$message.warning('请选择一张金蝶票据池发票');
return;
}
const preserveUserInput = options.preserveUserInput === true;
Object.assign(this.form, {
kingdeeInvoicePoolId: selected.id,
invoiceNo: selected.invoiceNo,
invoiceDate: selected.invoiceDate,
invoiceType: selected.invoiceType,
@@ -613,8 +416,6 @@ export default {
bankName: selected.bankName,
bankAccount: selected.bankAccount,
issuingBank: selected.issuingBank,
kingdeeBillNo: selected.kingdeeBillNo || '',
kingdeeStatus: selected.kingdeeStatus || 'unsynced',
phone: preserveUserInput ? this.form.phone : selected.phone || '',
customerEmails: preserveUserInput
? this.form.customerEmails
@@ -725,7 +526,6 @@ export default {
payload() {
return {
id: this.form.id,
kingdeeInvoicePoolId: this.form.kingdeeInvoicePoolId,
invoiceNo: this.form.invoiceNo,
invoiceDate: this.form.invoiceDate,
invoiceType: this.form.invoiceType,
@@ -737,8 +537,6 @@ export default {
bankName: this.form.bankName,
bankAccount: this.form.bankAccount,
issuingBank: this.form.issuingBank,
kingdeeBillNo: this.form.kingdeeBillNo,
kingdeeStatus: this.form.kingdeeStatus,
phone: this.form.phone,
customerEmails: this.form.customerEmails.join(';'),
departmentEmails: this.form.departmentEmails.join(';'),
@@ -773,6 +571,7 @@ export default {
bizId: this.form.id,
subjectName: this.form.invoiceNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('提交成功');
this.goBack();
@@ -782,13 +581,12 @@ export default {
this.$message.warning('请先选择金蝶票据池发票');
return;
}
const data = this.recordId
? this.unwrapData(await api.getInvoicePool(this.form.invoiceNo))
: this.filterInvoicePoolMockRows(this.form.invoiceNo);
const rows = Array.isArray(data) ? data : data?.records || [];
const invoice = rows.find(item => item.invoiceNo === this.form.invoiceNo);
const data = this.unwrapData(
await api.queryInvoicePool({ current: 1, size: 1, invoiceNo: this.form.invoiceNo })
);
const invoice = (data.records || []).find(item => item.invoiceNo === this.form.invoiceNo);
if (!invoice) {
this.$message.warning('金蝶票据池中未查询到该发票');
this.$message.warning('金蝶票池未查询到该发票');
return;
}
if (!this.confirmInvoice(invoice, { preserveUserInput: true })) return;
@@ -851,11 +649,6 @@ export default {
.receipt-form-page__dialog-search .el-input {
width: 360px;
}
.receipt-form-page__dialog-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.receipt-form-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
+75 -30
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="receipt-page">
<section class="receipt-page__search">
<section v-if="searchVisible" class="receipt-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="receipt-page__search-grid">
<el-form-item label="发票号码">
@@ -40,10 +40,10 @@
/>
</el-select>
</el-form-item>
<el-form-item v-if="searchExpanded" label="金蝶单据状态">
<el-select v-model="query.kingdeeStatus" clearable placeholder="请选择">
<el-form-item v-if="searchExpanded" label="金蝶状态">
<el-select v-model="query.kingdeePushStatus" clearable placeholder="请选择">
<el-option
v-for="item in kingdeeStatusOptions"
v-for="item in kingdeePushStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
@@ -54,11 +54,13 @@
<div class="receipt-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-tooltip :content="searchExpanded ? '折叠' : '展开'" placement="top">
<el-button text @click="searchExpanded = !searchExpanded">
<el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon>
</el-button>
</el-tooltip>
<el-button
v-if="showSearchToggle"
text
:icon="searchExpanded ? ArrowUp : ArrowDown"
@click="searchExpanded = !searchExpanded"
>{{ searchExpanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section>
@@ -70,7 +72,8 @@
v-if="hasPermission('invoice_receipt_sync')"
type="primary"
plain
@click="handleBatchSync"
:loading="pushLoading"
@click="handlePushKingdee"
>批量同步</el-button
>
<el-button v-if="hasPermission('invoice_receipt_add')" type="primary" @click="openCreate"
@@ -84,18 +87,26 @@
>导出</el-button
>
</div>
<div class="receipt-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button text :icon="Refresh" @click="loadTable" />
</el-tooltip>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selection = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -202,11 +213,12 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as XLSX from 'xlsx';
import * as api from '@/api/payment/invoiceReceipt';
import { invoiceReceiptTableColumns } from '@/option/payment/invoiceReceipt';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
@@ -218,31 +230,44 @@ const emptyQuery = () => ({
deptName: '',
deptPath: [],
approvalStatus: '',
kingdeeStatus: '',
kingdeePushStatus: '',
});
export default {
name: 'InvoiceReceipt',
components: { TableHeaderTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 6,
searchVisible: true,
tableSize: 'default',
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
kingdeePushStatusOptions: api.kingdeePushStatusOptions,
columns: invoiceReceiptTableColumns,
visibleColumnKeys: invoiceReceiptTableColumns.map(column => column.prop),
rows: [],
selection: [],
loading: false,
pushLoading: false,
page: { current: 1, size: 10, total: 0 },
mkEnabled: false,
};
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
},
mounted() {
this.loadOrganizationOptions();
@@ -316,6 +341,7 @@ export default {
bizId: row.id,
subjectName: row.invoiceNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
@@ -340,8 +366,13 @@ export default {
},
async handleApprove(row) {
await this.$confirm('确认审批通过该收票登记?', '提示', { type: 'warning' });
await api.approve({ id: row.id });
this.$message.success('审批通过');
const res = await api.approve({ id: row.id });
// data=false 表示审批已通过但自动同步金蝶失败,msg 带具体原因
if (res.data.data === false) {
this.$message.warning(res.data.msg);
} else {
this.$message.success(res.data.msg || '审批通过');
}
this.loadTable();
},
async handleReturn(row) {
@@ -356,15 +387,29 @@ export default {
this.$message.success('作废成功');
this.loadTable();
},
async handleBatchSync() {
async handlePushKingdee() {
if (this.pushLoading) return;
const rows = this.selection.filter(row => row.approvalStatus === 'approved');
if (!rows.length) {
this.$message.warning('请选择至少一条审批通过的收票登记');
return;
}
await Promise.all(rows.map(row => api.syncKingdee(row.id)));
this.$message.success(`已同步${rows.length}条收票登记`);
this.loadTable();
await this.$confirm(`确认将选中的 ${rows.length} 条收票登记同步至金蝶?`, '提示', {
type: 'warning',
});
this.pushLoading = true;
try {
const summary = this.unwrapData(await api.pushKingdee(rows.map(row => row.id)));
await this.loadTable();
// 附件联带同步提示:推送成功但附件未全部上传时在结果弹窗追加说明,重推即自动补传
const idSet = new Set(rows.map(row => String(row.id)));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
const attachmentTip = pendingCount ? `\n其中${pendingCount}单附件未全部上传,可重推补传` : '';
this.$alert((summary || '推送完成') + attachmentTip, '推送结果', { confirmButtonText: '知道了' });
} finally {
this.pushLoading = false;
}
},
async handleExport() {
const data = this.unwrapData(await api.getList(1, 100000, this.query));
@@ -381,8 +426,9 @@ export default {
当前处理人: item.currentProcessor,
创建人: item.createUserName,
创建时间: item.createTime,
金蝶单据号: item.kingdeeBillNo,
金蝶单据状态: item.kingdeeStatusName,
金蝶单据号: item.kingdeeReceivedInvoiceNo,
金蝶状态: item.kingdeePushStatusName,
失败原因: item.kingdeePushMsg,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(exportRows), '收票登记');
@@ -439,7 +485,6 @@ export default {
padding: 12px 0;
}
.receipt-page__toolbar-left,
.receipt-page__toolbar-right,
.receipt-page__links {
display: flex;
align-items: center;
+178 -89
View File
@@ -36,23 +36,20 @@
<el-col v-if="isSettlementRelatedType" :span="6"
><el-form-item label="结算单号" prop="referenceId">
<div class="payment-form-page__settlement-nos">
<template v-if="referenceSettlementLinks.length">
<el-link
v-for="item in referenceSettlementLinks"
:key="`${item.id || item.settlementNo}`"
type="primary"
@click="openSettlementDetail(item)"
>
{{ item.settlementNo }}
</el-link>
</template>
<el-input
v-else
v-model="referenceLabel"
readonly
:disabled="readonly"
placeholder="请选择预结算/正式结算单"
/>
<el-tooltip
:content="referenceLabel"
placement="top"
:disabled="!referenceLabel || referenceSettlementCount < 2"
>
<el-input
:model-value="referenceDisplayText"
readonly
:disabled="readonly"
placeholder="请输入"
class="payment-form-page__settlement-input"
@click="!readonly && openReference()"
/>
</el-tooltip>
<el-button
v-if="!readonly"
class="payment-form-page__settlement-select-btn"
@@ -251,7 +248,7 @@
</el-table-column>
</el-table>
</section-card>
<section-card v-if="isSettlementRelatedType" title="发票信息">
<section-card v-if="showInvoiceSection" title="发票信息">
<el-table :data="form.invoices" border>
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="settlementNo" label="结算单号" min-width="120">
@@ -281,8 +278,18 @@
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="payment-form-page__invoice-actions">
<el-link type="primary" @click="viewInvoiceAttachment(row)">查看</el-link>
<el-link type="primary" @click="downloadInvoiceAttachment(row)">下载</el-link>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="viewInvoiceAttachment(row)"
>查看</el-link
>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="downloadInvoiceAttachment(row)"
>下载</el-link
>
<el-link
v-if="!readonly"
type="danger"
@@ -345,19 +352,22 @@
value="special_approval" /><el-option
label="其他"
value="other" /></el-select></template></el-table-column
><el-table-column label="文件名" min-width="220"
><el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
><template #default="{ row }"
><div
class="payment-form-page__attachment-file-name-cell"
:title="attachmentFileName(row) || '-'"
>
<span
class="payment-form-page__attachment-file-name"
:class="{ 'is-disabled': !attachmentFileUrl(row) }"
@click="attachmentFileUrl(row) && previewAttachment(row)"
>{{ attachmentFileName(row) || '-' }}</span
>
</div></template
><span
class="attachment-file-name"
:class="{ 'is-disabled': !attachmentFileUrl(row) }"
role="button"
tabindex="0"
@click="attachmentFileUrl(row) && previewAttachment(row)"
@keydown.enter="attachmentFileUrl(row) && previewAttachment(row)"
@keydown.space.prevent="attachmentFileUrl(row) && previewAttachment(row)"
>{{ attachmentFileName(row) || '-' }}</span
></template
></el-table-column
><el-table-column label="附件描述" min-width="220"
><template #default="{ row }"
@@ -433,7 +443,7 @@
></el-table
>
<div v-else>
<div class="payment-form-page__reference-search">
<el-card class="payment-form-page__reference-search">
<el-form
:model="referenceQuery"
label-position="right"
@@ -471,7 +481,7 @@
<el-button @click="resetReferenceSearch">清空</el-button>
</div>
</el-form>
</div>
</el-card>
<el-tabs v-model="referenceTab" @tab-change="handleReferenceTabChange">
<el-tab-pane v-if="canSelectFormalSettlement" label="正式结算单" name="formal">
<el-table
@@ -663,7 +673,12 @@ import {
import { readSettlementTransfer, removeSettlementTransfer } from '@/utils/settlement-transfer';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
import PdfPreview from '@/components/pdf-preview/main.vue';
const attachmentViewerPlugins = [
@@ -673,6 +688,9 @@ const attachmentViewerPlugins = [
fallbackPlugin(),
];
const paymentApplicationListRoute = '/payment/payment-application';
const paymentApplicationListRefreshKey = 'payment-application-list-refresh';
const ATTACHMENT_MATERIALS = {
weighingSlip: { key: 'weighingSlip', label: '磅单', keywords: ['磅单'] },
entrustOrder: { key: 'entrustOrder', label: '委托单', keywords: ['委托单'] },
@@ -744,6 +762,8 @@ export default {
form: emptyForm(),
amountSyncing: false,
transferPayload: null,
// 金蝶侧发票附件查看/下载进行中(行组级 loading 守卫)
kingdeeAttachmentBusy: false,
referenceRows: [],
paymentRecords: [],
referenceVisible: false,
@@ -871,6 +891,10 @@ export default {
isSettlementRelatedType() {
return ['progress_advance', 'settlement_payment'].includes(this.form.paymentType);
},
// 项目预付、进度预付不展示发票信息,仅尾款付款展示
showInvoiceSection() {
return this.form.paymentType === 'settlement_payment';
},
canSelectPreSettlement() {
// 进度预付仅预结算;尾款付款可正式/预结算
return ['progress_advance', 'settlement_payment'].includes(this.form.paymentType);
@@ -923,6 +947,17 @@ export default {
referenceType: this.form.preSettlementId ? 'pre' : 'formal',
}));
},
referenceSettlementNos() {
return this.referenceSettlementLinks.map(item => item.settlementNo).filter(Boolean);
},
referenceSettlementCount() {
return this.referenceSettlementNos.length;
},
// 结算单号展示:单张显示单号;多张仅提示数量,明细在下方「结算信息」表,悬停可看全部单号
referenceDisplayText() {
if (this.referenceSettlementCount > 1) return `已选 ${this.referenceSettlementCount} 张结算单`;
return this.referenceLabel;
},
settlementRows() {
if (this.referenceRows.length) {
return this.referenceRows.map(item => ({
@@ -1046,7 +1081,7 @@ export default {
!this.form.preSettlementId &&
!this.form.preSettlementIds?.length
) {
callback(new Error('请选择结算单'));
callback(new Error('请输入'));
return;
}
callback();
@@ -1107,7 +1142,7 @@ export default {
callback();
},
validateInvoices() {
if (this.form.paymentType === 'project_advance') return;
if (!this.showInvoiceSection) return;
for (const invoice of this.form.invoices || []) {
if (String(invoice.settlementNo || '').length > 100) {
throw new Error('结算单号不能超过100个字符');
@@ -1198,7 +1233,7 @@ export default {
];
}
const referenceRow = this.createReferenceRow(data);
this.referenceRows = referenceRow.settlementNo ? [referenceRow] : [];
this.referenceRows = this.restoreReferenceRows(data, referenceRow);
this.paymentRecords = data.paymentRecords || [];
},
async initialize() {
@@ -1221,7 +1256,7 @@ export default {
invoices: data.invoices || [],
};
const referenceRow = this.createReferenceRow(data);
this.referenceRows = referenceRow.settlementNo ? [referenceRow] : [];
this.referenceRows = this.restoreReferenceRows(data, referenceRow);
this.paymentRecords = data.paymentRecords || [];
if (this.form.paymentType === 'project_advance') {
await this.loadProjectOptions();
@@ -1439,6 +1474,33 @@ export default {
this.$refs.formRef?.clearValidate();
});
},
restoreReferenceRows(data, fallbackRow) {
const relations = Array.isArray(data?.settlements) ? data.settlements : [];
const rows = relations
.map(item => {
const isPre = item.preSettlementId !== undefined && item.preSettlementId !== null && item.preSettlementId !== '';
const settlementNo = item.preSettlementNo || item.formalSettlementNo || '';
if (!settlementNo) return null;
return {
id: isPre ? item.preSettlementId : item.formalSettlementId,
settlementNo,
referenceType: isPre ? 'pre' : 'formal',
settlementAmount: Number(Number(item.settlementAmount || 0).toFixed(2)),
payableAmount: Number(Number(item.payableAmount ?? item.settlementAmount ?? 0).toFixed(2)),
appliedAmount: Number(Number(item.appliedAmount || 0).toFixed(2)),
fixedAppliedAmount: this.readonly,
};
})
.filter(Boolean);
if (rows.length > 1) {
this.form.preSettlementIds = rows.filter(item => item.referenceType === 'pre').map(item => item.id);
this.form.settlementIds = rows.filter(item => item.referenceType === 'formal').map(item => item.id);
if (this.form.preSettlementIds.length) this.form.preSettlementId = this.form.preSettlementIds[0];
if (this.form.settlementIds.length) this.form.settlementId = this.form.settlementIds[0];
return rows;
}
return fallbackRow?.settlementNo ? [fallbackRow] : [];
},
createReferenceRow(row) {
const settlementAmount = Number(row.settlementAmount || 0);
const appliedAmount = Number(row.advanceAppliedAmount || row.appliedPaymentAmount || 0);
@@ -1447,11 +1509,13 @@ export default {
id: row.preSettlementId || row.settlementId || row.id,
settlementNo: row.preSettlementNo || row.formalSettlementNo || row.settlementNo || '',
referenceType: isPre ? 'pre' : 'formal',
settlementAmount,
payableAmount:
row.payableAmount === undefined || row.payableAmount === null
settlementAmount: Number(Number(settlementAmount || 0).toFixed(2)),
payableAmount: Number(
(row.payableAmount === undefined || row.payableAmount === null
? Math.max(0, settlementAmount - appliedAmount)
: Number(row.payableAmount || 0),
: Number(row.payableAmount || 0)
).toFixed(2)
),
};
},
createInvoiceRows(invoices = [], settlementNo = '') {
@@ -1509,9 +1573,24 @@ export default {
}
const first = rows[0];
this.form.invoices = [];
this.referenceRows = rows.map(item =>
this.createReferenceRow({ ...item, referenceType: 'pre' })
const paymentType =
this.form.paymentType === 'settlement_payment' ? 'settlement_payment' : 'progress_advance';
const amountResponses = await Promise.all(
rows.map(item => {
const referenceId = item.preSettlementId || item.id;
if (!this.isValidReferenceId(referenceId)) return Promise.resolve(null);
return api.getReferenceAmount(paymentType, referenceId, this.form.id).catch(() => null);
})
);
this.referenceRows = rows.map((item, index) => {
const amount = this.unwrapData(amountResponses[index]) || {};
return this.createReferenceRow({
...item,
referenceType: 'pre',
settlementAmount: amount.settlementAmount ?? item.settlementAmount,
payableAmount: amount.payableAmount,
});
});
const settlementAmount = this.referenceRows.reduce(
(total, item) => total + Number(item.settlementAmount || 0),
0
@@ -1522,8 +1601,6 @@ export default {
);
const paymentRatio = Number(this.form.paymentRatio || 0);
// 尾款付款场景下选择预结算单时保留尾款付款类型,不强制改为进度预付
const paymentType =
this.form.paymentType === 'settlement_payment' ? 'settlement_payment' : 'progress_advance';
this.amountSyncing = true;
Object.assign(this.form, {
paymentType,
@@ -1586,8 +1663,19 @@ export default {
this.attachmentFileName(this.invoiceAttachment(row)) || `${row.invoiceNo || '发票'}.pdf`
);
},
viewInvoiceAttachment(row) {
async viewInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 预览,OSS 直链维持本地预览组件
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await viewKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
if (!this.attachmentFileUrl(attachment)) {
this.$message.warning('当前发票暂无可查看附件');
return;
@@ -1597,8 +1685,20 @@ export default {
[attachment]
);
},
downloadInvoiceAttachment(row) {
const url = this.invoiceAttachmentUrl(row);
async downloadInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 下载,OSS 直链维持前端直连下载
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await downloadKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
const url = this.attachmentFileUrl(attachment);
if (!url) {
this.$message.warning('当前发票暂无可下载附件');
return;
@@ -1866,6 +1966,7 @@ export default {
this.contractPaymentRatioExceeded = exceeded;
},
handleTypeChange() {
// 切换类型时清空发票;预付类型本身也不再展示/提交发票
this.form.invoices = [];
this.paymentRecords = [];
Object.assign(this.form, {
@@ -2053,8 +2154,20 @@ export default {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
this.form.projectName = project?.projectName || project?.projectShortName || '';
Object.assign(this.form, {
deptId: project?.undertakeDeptId || project?.businessDeptId || null,
deptName: project?.undertakeDeptName || project?.businessDeptName || '',
deptId:
String(project?.undertakeDeptId || '')
.split(/[,,]/)
.map(item => item.trim())
.find(Boolean) ||
project?.businessDeptId ||
null,
deptName:
String(project?.undertakeDeptName || '')
.split(/[,,、]/)
.map(item => item.trim())
.find(Boolean) ||
project?.businessDeptName ||
'',
contractId: null,
contractNo: '',
contractName: '',
@@ -2585,7 +2698,7 @@ export default {
attachmentsJson: JSON.stringify(attachments || []),
remark,
invoices:
paymentType === 'project_advance'
paymentType === 'project_advance' || paymentType === 'progress_advance'
? []
: invoices.map(invoice => ({
settlementNo: invoice.settlementNo || '',
@@ -2641,14 +2754,17 @@ export default {
bizId: this.form.id,
subjectName: this.form.paymentNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('提交成功');
// 通知列表页(keep-alive)重新拉取数据
sessionStorage.setItem(paymentApplicationListRefreshKey, '1');
this.goBack();
},
goBack() {
removeSettlementTransfer(this.$route.query.transferToken);
this.$router.$avueRouter?.closeTag?.();
this.$router.push('/payment/payment-application');
this.$router.push(paymentApplicationListRoute);
},
formatMoney(value) {
const amount = Number(value);
@@ -2666,13 +2782,18 @@ export default {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
width: 100%;
min-height: 32px;
}
.payment-form-page__settlement-nos :deep(.el-input) {
.payment-form-page__settlement-input {
flex: 1;
min-width: 120px;
min-width: 0;
}
.payment-form-page__settlement-input :deep(.el-input__inner) {
cursor: pointer;
}
.payment-form-page__settlement-input.is-disabled :deep(.el-input__inner) {
cursor: not-allowed;
}
.payment-form-page__settlement-select-btn {
flex-shrink: 0;
@@ -2749,38 +2870,6 @@ export default {
justify-content: center;
gap: 8px;
}
.payment-form-page__attachment-file-name-cell {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.payment-form-page__attachment-file-name {
display: block;
width: 100%;
overflow: hidden;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #409eff;
}
&.is-disabled {
color: var(--el-text-color-regular);
cursor: default;
&:hover {
color: var(--el-text-color-regular);
}
}
}
.payment-form-page__attachment-upload .vehicle-attachment-upload {
width: auto;
}
+321 -345
View File
@@ -1,278 +1,193 @@
<template>
<basic-container class="payment-page">
<section class="payment-page__search">
<el-form :model="query" label-position="right" label-width="88px">
<div class="payment-page__search-grid">
<el-form-item label="单据号"
><el-input v-model="query.paymentNo" clearable
/></el-form-item>
<el-form-item label="日期"
><el-date-picker
v-model="query.applyDateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="~"
start-placeholder="年/月/日"
end-placeholder="年/月/日"
/></el-form-item>
<el-form-item label="客户名称"
><el-input v-model="query.payeeName" clearable
/></el-form-item>
<el-form-item label="项目名称"
><el-input v-model="query.projectName" clearable
/></el-form-item>
<template v-if="searchExpanded">
<el-form-item label="所属组织"
><el-cascader
v-model="query.deptPath"
:options="organizationTreeOptions"
:props="organizationCascaderProps"
filterable
clearable
style="width: 100%"
placeholder="请选择 所属组织"
@change="val => { query.deptName = findOrgById(val)?.rawLabel || '' }"
/></el-form-item>
<el-form-item label="关联结算单"
><el-input v-model="query.settlementNo" clearable
/></el-form-item>
<el-form-item label="付款类型"
><el-select v-model="query.paymentType" clearable
><el-option
v-for="item in paymentTypeOptions"
:key="item.value"
v-bind="item" /></el-select
></el-form-item>
<el-form-item label="审核状态"
><el-select v-model="query.approvalStatus" clearable
><el-option
v-for="item in approvalStatusOptions"
:key="item.value"
v-bind="item" /></el-select
></el-form-item>
<el-form-item label="金蝶单据状态"
><el-select v-model="query.kingdeeStatus" clearable
><el-option
v-for="item in kingdeeStatusOptions"
:key="item.value"
v-bind="item" /></el-select
></el-form-item>
</template>
</div>
<div class="payment-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button text @click="searchExpanded = !searchExpanded"
><el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon
></el-button>
</div>
</el-form>
</section>
<section class="payment-page__table-panel">
<div class="payment-page__toolbar">
<div class="payment-page__toolbar-left">
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
@click="openCreate"
>新增付款申请</el-button
>
<el-button
v-if="hasPermission('payment_application_sync')"
type="primary"
plain
@click="handleSync"
>批量同步</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openSettlementPayment"
>申请付尾款</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openProjectAdvance"
>预付申请</el-button
>
<el-button
v-if="hasPermission('payment_application_export')"
type="primary"
plain
@click="handleExport"
>导出</el-button
>
</div>
<el-button text :icon="Refresh" @click="loadTable" />
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
<avue-crud
ref="crud"
:option="option"
:table-loading="loading"
:data="rows"
v-model:page="page"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
@click="openCreate"
>新增付款申请</el-button
>
<template #default="{ row }">
<el-link v-if="column.link" type="primary" @click="openView(row)">{{
row[column.prop]
}}</el-link>
<el-tag
v-else-if="column.status"
:type="statusType(row.approvalStatus)"
class="status-text"
>{{ row[column.prop] || '-' }}</el-tag
>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right" align="center">
<template #default="{ row }"
><div class="payment-page__links">
<el-link
v-if="hasPermission('payment_application_view')"
type="primary"
@click="openView(row)"
>查看</el-link
>
<el-link
v-if="
hasPermission('payment_application_edit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="openEdit(row)"
>编辑</el-link
>
<el-link
v-if="
hasPermission('payment_application_submit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="handleSubmit(row)"
>提交</el-link
>
<el-link
v-if="
mkEnabled &&
hasPermission('payment_application_approve') &&
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
>流程</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="primary"
@click="handleApprove(row)"
>通过</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="danger"
@click="handleReturn(row)"
>驳回</el-link
>
<el-link
v-if="
hasPermission('payment_application_void') && row.approvalStatus === 'approved'
"
type="danger"
@click="handleVoid(row)"
>作废</el-link
>
<el-link
v-if="hasPermission('payment_application_delete') && row.approvalStatus === 'draft'"
type="danger"
@click="handleDelete(row)"
>删除</el-link
>
</div></template
>
</el-table-column>
</el-table>
<div class="payment-page__pagination">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadTable"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-button
v-if="hasPermission('payment_application_sync')"
type="primary"
plain
@click="handleSync"
>批量同步</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openSettlementPayment"
>申请付尾款</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openProjectAdvance"
>预付申请</el-button
>
<el-button
v-if="hasPermission('payment_application_export')"
type="primary"
plain
@click="handleExport"
>导出</el-button
>
</template>
<template #paymentNo="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.paymentNo }}</el-link>
</template>
<template #approvalStatusName="{ row }">
<el-tag :type="statusType(row.approvalStatus)" class="status-text">{{
row.approvalStatusName || '-'
}}</el-tag>
</template>
<template #kingdeeLockStatus="{ row }">
<el-tooltip v-if="lockTag(row).show" :content="lockTag(row).tip" placement="top">
<el-tag :type="lockTag(row).type" class="status-text">{{ lockTag(row).label }}</el-tag>
</el-tooltip>
<span v-else>-</span>
</template>
<template #menu="{ row }">
<el-link
v-if="hasPermission('payment_application_view')"
type="primary"
@click="openView(row)"
>查看</el-link
>
<el-link
v-if="
hasPermission('payment_application_edit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="openEdit(row)"
>编辑</el-link
>
<el-link
v-if="
hasPermission('payment_application_submit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="handleSubmit(row)"
>提交</el-link
>
<el-link
v-if="
mkEnabled &&
hasPermission('payment_application_approve') &&
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
>流程</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="primary"
@click="handleApprove(row)"
>通过</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="danger"
@click="handleReturn(row)"
>驳回</el-link
>
<el-link
v-if="
hasPermission('payment_application_void') &&
row.approvalStatus === 'approved' &&
row.kingdeeLockStatus !== 'locked'
"
type="danger"
@click="handleVoid(row)"
>作废</el-link
>
<el-link
v-if="hasPermission('payment_application_delete') && row.approvalStatus === 'draft'"
type="danger"
@click="handleDelete(row)"
>删除</el-link
>
</template>
</avue-crud>
</basic-container>
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/paymentApplication';
import { paymentApplicationTableColumns } from '@/option/payment/paymentApplication';
import { paymentApplicationOption } from '@/option/payment/paymentApplication';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
const emptyQuery = () => ({
paymentNo: '',
applyDateRange: [],
payeeName: '',
projectName: '',
deptName: '',
deptPath: [],
settlementNo: '',
paymentType: '',
approvalStatus: '',
kingdeeStatus: '',
});
const paymentApplicationListRoute = '/payment/payment-application';
const paymentApplicationListRefreshKey = 'payment-application-list-refresh';
export default {
name: 'PaymentApplication',
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
paymentTypeOptions: api.paymentTypeOptions,
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
columns: paymentApplicationTableColumns,
rows: [],
selection: [],
loading: false,
page: { current: 1, size: 10, total: 0 },
syncLoading: false,
resultSyncing: false,
query: {},
option: {},
page: {
currentPage: 1,
pageSize: 10,
pageSizes: [10, 20, 50, 100],
total: 0,
},
mkEnabled: false,
};
},
computed: {
...mapGetters(['permission']),
visibleSearchFields() {
return this.searchExpanded ? this.columns : this.columns.slice(0, 4);
},
},
created() {
this.option = paymentApplicationOption({
paymentTypeOptions: api.paymentTypeOptions,
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
});
this.loadOrganizationOptions();
},
mounted() {
this.loadOrganizationOptions();
this.loadTable();
loadMkEnabled()
.then(enabled => {
this.mkEnabled = !!enabled;
@@ -281,7 +196,28 @@ export default {
this.mkEnabled = false;
});
},
activated() {
this.consumeListRefreshFlag();
},
watch: {
$route(to, from) {
if (
to.path === paymentApplicationListRoute &&
from?.path?.includes('/payment-application/form')
) {
this.consumeListRefreshFlag();
}
},
},
methods: {
/** 表单提交成功后带标记返回时,重新拉取列表 */
consumeListRefreshFlag() {
if (!sessionStorage.getItem(paymentApplicationListRefreshKey)) {
return;
}
sessionStorage.removeItem(paymentApplicationListRefreshKey);
this.onLoad(this.page);
},
hasPermission(code) {
return this.permission?.[code] !== false;
},
@@ -289,39 +225,52 @@ export default {
const body = response?.data || response || {};
return body?.data || body;
},
buildParams() {
const params = { ...this.query };
onLoad(page) {
this.loading = true;
api
.getList(page.currentPage, page.pageSize, { ...this.query })
.then(res => {
const data = this.unwrapData(res);
this.rows = data.records || [];
this.page.total = Number(data.total || 0);
})
.finally(() => {
this.loading = false;
this.selection = [];
this.$refs.crud?.toggleSelection?.();
});
},
searchChange(params, done) {
// 所属组织级联返回 id 路径数组,转成部门名称给后端
this.normalizeOrganizationSearch(params, 'deptName');
// 日期区间拆成后端需要的起止字段
const range = params.applyDateRange || [];
delete params.applyDateRange;
if (range.length === 2) {
params.applyStartDate = range[0];
params.applyEndDate = range[1];
}
return params;
this.query = {
...params,
applyStartDate: range[0] || '',
applyEndDate: range[1] || '',
};
this.page.currentPage = 1;
this.onLoad(this.page);
done();
},
async loadTable() {
this.loading = true;
try {
const data = this.unwrapData(
await api.getList(this.page.current, this.page.size, this.buildParams())
);
this.rows = data.records || [];
this.page.total = data.total || 0;
} finally {
this.loading = false;
}
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page);
},
handleSearch() {
this.page.current = 1;
this.loadTable();
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
resetSearch() {
this.query = emptyQuery();
this.handleSearch();
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
handleSizeChange() {
this.page.current = 1;
this.loadTable();
refreshChange() {
this.onLoad(this.page);
},
selectionChange(list) {
this.selection = list;
},
openCreate() {
this.$router.push({ path: '/payment/payment-application/form', query: { mode: 'add' } });
@@ -361,7 +310,7 @@ export default {
await this.$confirm('确认删除该付款申请?', '提示', { type: 'warning' });
await api.remove(row.id);
this.$message.success('删除成功');
this.loadTable();
this.refreshChange();
},
async handleSubmit(row) {
const previousApprovalStatus = row.approvalStatus || '';
@@ -371,14 +320,20 @@ export default {
bizId: row.id,
subjectName: row.paymentNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
this.refreshChange();
},
async handleApprove(row) {
await api.approve({ id: row.id });
this.$message.success('审批通过');
this.loadTable();
const res = await api.approve({ id: row.id });
// data=false 表示审批已通过但自动同步金蝶失败,msg 带具体原因
if (res.data.data === false) {
this.$message.warning(res.data.msg);
} else {
this.$message.success(res.data.msg || '审批通过');
}
this.refreshChange();
},
handleMkAudit(row) {
if (!row?.id) {
@@ -402,32 +357,79 @@ export default {
const { value } = await this.$prompt('请输入驳回原因', '审批驳回');
await api.returnBill({ id: row.id, reason: value });
this.$message.success('已驳回');
this.loadTable();
this.refreshChange();
},
async handleVoid(row) {
const { value } = await this.$prompt('请输入作废原因', '作废付款申请');
await api.voidBill({ id: row.id, reason: value });
this.$message.success('作废成功');
this.loadTable();
this.refreshChange();
},
async handleSyncRow(row) {
const data = this.unwrapData(await api.syncKingdee(row.id));
this.$message.success(`已生成金蝶付款单:${data}`);
this.loadTable();
await this.refreshChange();
this.warnAttachmentNotUploaded([row.id]);
},
async handleSync() {
if (this.syncLoading) return;
const rows = this.selection.filter(row => row.approvalStatus === 'approved');
if (!rows.length) {
this.$message.warning('请选择至少一条审批通过的付款申请');
return;
}
await api.syncKingdeeBatch(rows.map(row => row.id));
this.$message.success(`已同步${rows.length}条付款申请`);
this.loadTable();
this.syncLoading = true;
try {
const data = this.unwrapData(await api.syncKingdeeBatch(rows.map(row => row.id))) || [];
const failedList = data.filter(item => String(item).includes('同步失败'));
if (failedList.length) {
this.$message({
type: 'warning',
message: `同步完成:成功${data.length - failedList.length}条,失败${failedList.length}条。${failedList.join(';')}`,
duration: 8000,
});
} else {
this.$message.success(`已同步${data.length}条付款申请`);
}
await this.refreshChange();
this.warnAttachmentNotUploaded(rows.map(row => row.id));
} finally {
this.syncLoading = false;
}
},
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
warnAttachmentNotUploaded(ids) {
const idSet = new Set(ids.map(String));
const pendingCount = (this.rows || []).filter(row => idSet.has(String(row.id))
&& ['pending', 'partial'].includes(row.attachmentSyncStatus)).length;
if (pendingCount) {
this.$message({
type: 'warning',
message: `其中${pendingCount}单附件未全部上传,可重推补传`,
duration: 8000,
});
}
},
async handleSyncResult() {
if (this.resultSyncing) return;
this.resultSyncing = true;
try {
const data = this.unwrapData(await api.syncKingdeeResult());
this.$message.success(data || '金蝶付款结果回写完成');
this.loadTable();
} finally {
this.resultSyncing = false;
}
},
handleExport() {
const headers = this.columns.map(item => item.label);
const rows = this.rows.map(row => this.columns.map(item => row[item.prop] || ''));
const columns = this.option.column.filter(column => !column.hide && column.prop);
const headers = columns.map(item => item.label);
const rows = this.rows.map(row =>
columns.map(item =>
row[item.prop] === null || row[item.prop] === undefined ? '' : row[item.prop]
)
);
const csv = [headers, ...rows]
.map(item => item.map(value => `"${String(value).replaceAll('"', '""')}"`).join(','))
.join('\n');
@@ -444,6 +446,38 @@ export default {
''
);
},
lockTag(row) {
if (row.kingdeeLockStatus === 'locked') {
return { show: true, type: 'danger', label: '金蝶锁定中', tip: '该单据已被金蝶锁定,不可编辑/作废' };
}
if (row.kingdeeLockStatus === 'failed') {
if ((row.kingdeeTryCount || 0) >= 5) {
return {
show: true,
type: 'danger',
label: '锁定失败·已达上限',
tip: row.kingdeeLockFailReason || '自动补偿次数已用完,请重推「同步金蝶」立即补锁',
};
}
return {
show: true,
type: 'warning',
label: '锁定失败·补偿中',
tip: row.kingdeeLockFailReason || '系统将自动补偿重锁',
};
}
// 金蝶解锁终态标识:后端按 unlocked 锁记录回填(付款申请不作废,仅此标识可区分金蝶删单与推送失败)
if (row.kingdeeUnlockTime) {
const isDelete = row.kingdeeUnlockOperationName === '金蝶删单';
return {
show: true,
type: isDelete ? 'danger' : 'warning',
label: row.kingdeeUnlockOperationName || '金蝶解锁',
tip: `${row.kingdeeUnlockOperator || '-'} 于 ${this.$dayjs(row.kingdeeUnlockTime).format('YYYY-MM-DD HH:mm:ss')} 解锁`,
};
}
return { show: false };
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
@@ -455,54 +489,6 @@ export default {
</script>
<style scoped lang="scss">
.payment-page__search {
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.payment-page__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
.payment-page__search :deep(.el-form-item) {
margin-bottom: 8px;
}
.payment-page__search :deep(.el-input),
.payment-page__search :deep(.el-select),
.payment-page__search :deep(.el-date-editor) {
width: 100%;
}
.payment-page__search-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
}
.payment-page__table-panel {
margin-top: 8px;
}
.payment-page__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.payment-page__toolbar-left {
display: flex;
gap: 8px;
}
.payment-page__links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.payment-page__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.payment-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
@@ -512,14 +498,4 @@ export default {
:deep(.payment-page.basic-container .basic-container__card > .el-card__body) {
padding: 0;
}
@media (max-width: 1200px) {
.payment-page__search-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.payment-page__search-grid {
grid-template-columns: 1fr;
}
}
</style>
+76 -15
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="claim-record-page">
<section-card class="claim-record-page__search">
<section-card v-if="searchVisible" class="claim-record-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="claim-record-page__search-grid">
<el-form-item label="认领通知单">
@@ -56,6 +56,16 @@
<el-form-item v-show="searchExpanded" label="所属组织">
<el-input v-model="query.claimerDeptName" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-show="searchExpanded" label="推送状态">
<el-select v-model="query.pushStatus" clearable placeholder="请选择">
<el-option
v-for="item in pushStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-show="searchExpanded" label="金蝶单据状态">
<el-select v-model="query.kingdeeBillStatus" clearable placeholder="请选择">
<el-option
@@ -70,20 +80,34 @@
<div class="claim-record-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-tooltip :content="searchExpanded ? '折叠' : '展开'" placement="top">
<el-button text @click="searchExpanded = !searchExpanded">
<el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon>
</el-button>
</el-tooltip>
<el-button
v-if="showSearchToggle"
text
:icon="searchExpanded ? ArrowUp : ArrowDown"
@click="searchExpanded = !searchExpanded"
>{{ searchExpanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section-card>
<section class="claim-record-page__table-panel">
<el-table v-loading="loading" :data="rows" border>
<div class="claim-record-page__toolbar">
<el-button :loading="refreshing" @click="handleRefreshKingdeeResult">
查询金蝶认领结果
</el-button>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" :size="tableSize" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -107,7 +131,11 @@
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-link
v-if="hasPermission('receipt_claim_record_void') && row.claimStatus === 'claimed'"
v-if="
hasPermission('receipt_claim_record_void') &&
row.claimStatus === 'claimed' &&
row.pushStatus !== 'pushed'
"
type="danger"
@click="handleVoid(row)"
>作废</el-link
@@ -138,6 +166,7 @@ import { mapGetters } from 'vuex';
import * as api from '@/api/payment/receiptClaimRecord';
import { receiptClaimRecordTableColumns } from '@/option/payment/receiptClaimRecord';
import SectionCard from '@/components/section-card/main.vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import { isAdministrator } from '@/utils/auth';
const emptyQuery = () => ({
@@ -152,27 +181,42 @@ const emptyQuery = () => ({
claimDateRange: [],
claimerDeptName: '',
kingdeeBillStatus: '',
pushStatus: '',
});
export default {
name: 'ReceiptClaimRecord',
components: { SectionCard },
components: { SectionCard, TableHeaderTools },
data() {
return {
ArrowDown,
ArrowUp,
query: emptyQuery(),
searchExpanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 11,
searchVisible: true,
tableSize: 'default',
claimStatusOptions: api.claimStatusOptions,
kingdeeBillStatusOptions: api.kingdeeBillStatusOptions,
pushStatusOptions: api.pushStatusOptions,
columns: receiptClaimRecordTableColumns,
visibleColumnKeys: receiptClaimRecordTableColumns.map(column => column.prop),
rows: [],
loading: false,
refreshing: false,
page: { current: 1, size: 10, total: 0 },
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -182,8 +226,9 @@ export default {
},
methods: {
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
// 数字 0 是合法返回值(如回写/同步 0 条),须用 ?? 判空避免 0 被信封对象顶掉导致 Number() 得 NaN
const body = response?.data ?? response ?? {};
return body?.data ?? body;
},
queryParams() {
const transactionRange = this.query.transactionDateRange || [];
@@ -227,14 +272,24 @@ export default {
},
async handleVoid(row) {
await this.$confirm(
'作废后将回退本次认领金额,并生成审核通过状态的金蝶认领冲单,是否继续?',
'作废后将回退本次认领金额,是否继续?',
'作废认领记录',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
);
const kingdeeBillNo = this.unwrapData(await api.voidClaim(row.id));
this.$message.success(`作废成功,金蝶冲单号:${kingdeeBillNo}`);
await api.voidClaim(row.id);
this.$message.success('作废成功');
await this.loadTable();
},
async handleRefreshKingdeeResult() {
this.refreshing = true;
try {
const updated = Number(this.unwrapData(await api.queryKingdeeResult()) || 0);
this.$message.success(`金蝶认领结果回写完成,更新${updated}条`);
await this.loadTable();
} finally {
this.refreshing = false;
}
},
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
@@ -279,6 +334,12 @@ export default {
.claim-record-page__table-panel {
margin-top: 8px;
}
.claim-record-page__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.claim-record-page__pagination {
display: flex;
justify-content: flex-end;
+20 -3
View File
@@ -43,6 +43,12 @@
<el-table-column label="已认领金额" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.claimedAmount) }}</template>
</el-table-column>
<el-table-column label="金蝶未认领金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.unclaimedAmount) }}</template>
</el-table-column>
<el-table-column label="剩余可认领金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(flowRemaining(row)) }}</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="180" show-overflow-tooltip />
</el-table>
<el-row :gutter="32" class="receipt-claim-batch-form-page__claim-info">
@@ -295,10 +301,21 @@ export default {
0
);
},
flowRemaining(flow) {
return Math.max(Number(flow.unclaimedAmount || 0) - Number(flow.claimedAmount || 0), 0);
},
maxAllocation(row) {
const current = Number(row.settlement.allocations?.[row.flow.id] || 0);
const otherTotal = this.settlementAllocatedTotal(row.settlement) - current;
return Math.max(this.settlementRemaining(row.settlement) - otherTotal, 0);
// 流水维度:该流水其余分摊已占用部分同样挤占剩余可认领金额
const flowOtherTotal = this.flowAllocatedTotal(row.flow.id) - current;
return Math.max(
Math.min(
this.settlementRemaining(row.settlement) - otherTotal,
this.flowRemaining(row.flow) - flowOtherTotal
),
0
);
},
handleAllocationChange(settlement) {
const total = this.settlementAllocatedTotal(settlement);
@@ -336,11 +353,11 @@ export default {
for (const flow of this.flows) {
const total = this.flowAllocatedTotal(flow.id);
const remaining = Math.max(
Number(flow.receiptAmount || 0) - Number(flow.claimedAmount || 0),
Number(flow.unclaimedAmount || 0) - Number(flow.claimedAmount || 0),
0
);
if (total > remaining + 0.001) {
this.$message.warning(`流水${flow.receiptNoticeNo}的分摊金额不能超过剩余可认领金额`);
this.$message.warning(`流水${flow.receiptNoticeNo}的累计认领金额不能超过公告未认领金额`);
return;
}
}
+17 -2
View File
@@ -47,6 +47,14 @@
><el-form-item label="已认领金额"
><el-input :model-value="formatMoney(form.claimedAmount)" disabled /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="金蝶未认领金额"
><el-input :model-value="formatMoney(form.unclaimedAmount)" disabled /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="剩余可认领金额"
><el-input :model-value="formatMoney(claimableAmount)" disabled /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="摘要"><el-input v-model="form.summary" disabled /></el-form-item
></el-col>
@@ -197,6 +205,7 @@ const emptyForm = () => ({
transactionTime: '',
detailSerialNo: '',
claimedAmount: 0,
unclaimedAmount: 0,
claimerName: '',
claimerDeptName: '',
claimDate: '',
@@ -242,6 +251,12 @@ export default {
0
);
},
claimableAmount() {
return Math.max(
Number(this.form.unclaimedAmount || 0) - Number(this.form.claimedAmount || 0),
0
);
},
},
created() {
if (!this.batchMode) this.initialize();
@@ -331,8 +346,8 @@ export default {
this.$message.warning('分摊收款金额必须大于0');
return;
}
if (this.allocatedTotal > amount - Number(this.form.claimedAmount || 0) + 0.001) {
this.$message.warning('本次分摊金额不能超过流水剩余可认领金额');
if (this.allocatedTotal > this.claimableAmount + 0.001) {
this.$message.warning('累计认领金额不能超过公告未认领金额(剩余可认领金额)');
return;
}
for (const row of this.form.settlements)
+205 -98
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="receipt-flow-page">
<section class="receipt-flow-page__search">
<section v-if="searchVisible" class="receipt-flow-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="receipt-flow-page__search-grid">
<el-form-item label="认领通知单">
@@ -15,10 +15,17 @@
<el-form-item label="对方账号">
<el-input v-model="query.counterpartyAccount" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-show="searchExpanded" label="摘要">
<el-input v-model="query.summary" clearable placeholder="请输入" />
<el-form-item label="推送状态">
<el-select v-model="query.pushStatus" clearable placeholder="请选择">
<el-option
v-for="item in pushStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-show="searchExpanded" label="认领状态">
<el-form-item label="认领状态">
<el-select v-model="query.claimStatus" clearable placeholder="请选择">
<el-option
v-for="item in claimStatusOptions"
@@ -28,7 +35,7 @@
/>
</el-select>
</el-form-item>
<el-form-item v-show="searchExpanded" label="交易时间">
<el-form-item label="交易时间">
<el-date-picker
v-model="query.transactionTimeRange"
type="datetimerange"
@@ -39,46 +46,99 @@
clearable
/>
</el-form-item>
<el-form-item v-show="searchExpanded" label="对方开户行">
<el-input v-model="query.counterpartyBank" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-show="searchExpanded" label="摘要">
<el-input v-model="query.summary" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-show="searchExpanded" label="明细流水号">
<el-input v-model="query.detailSerialNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-show="searchExpanded" label="金蝶状态">
<el-select v-model="query.kingdeeBillStatus" clearable placeholder="请选择">
<el-option
v-for="item in kingdeeBillStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-show="searchExpanded" label="收款金额">
<div class="amount-range">
<el-input-number
v-model="query.receiptAmountStart"
:controls="false"
:min="0"
placeholder="最低金额"
/>
<span>~</span>
<el-input-number
v-model="query.receiptAmountEnd"
:controls="false"
:min="0"
placeholder="最高金额"
/>
</div>
</el-form-item>
<el-form-item v-show="searchExpanded" label="认领人">
<el-input v-model="query.claimerName" clearable placeholder="请输入" />
</el-form-item>
</div>
<div class="receipt-flow-page__search-actions">
<el-button @click="searchExpanded = !searchExpanded">
{{ searchExpanded ? '折叠' : '展开' }}
</el-button>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button
v-if="showSearchToggle"
text
:icon="searchExpanded ? ArrowUp : ArrowDown"
@click="searchExpanded = !searchExpanded"
>{{ searchExpanded ? '收起' : '展开' }}</el-button
>
</div>
</el-form>
</section>
<section class="receipt-flow-page__table-panel">
<div class="receipt-flow-page__toolbar">
<el-button
v-if="hasPermission('receipt_flow_sync')"
type="primary"
:loading="syncing"
@click="handleSync"
>
手动同步流水
</el-button>
<el-button
v-if="hasPermission('receipt_flow_claim')"
type="primary"
:disabled="!selectedRows.length"
@click="openBatchClaim"
>
批量认领
</el-button>
<div class="receipt-flow-page__toolbar-left">
<el-button
v-if="hasPermission('receipt_flow_sync')"
type="primary"
:loading="syncing"
@click="handleSync"
>
手动同步流水
</el-button>
<el-button
v-if="hasPermission('receipt_flow_claim')"
type="primary"
:disabled="!selectedRows.length"
@click="openBatchClaim"
>
批量认领
</el-button>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selectedRows = $event"
>
<el-table-column type="selection" width="52" align="center" />
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -90,7 +150,7 @@
</el-link>
<el-tag
v-else-if="column.status"
:type="statusType(row.claimStatus)"
:type="statusType(row, column)"
class="status-text"
>
{{ displayValue(row[column.prop]) }}
@@ -99,10 +159,23 @@
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-link v-if="canClaim(row)" type="primary" @click="openClaim(row)">认领</el-link>
<span v-else>-</span>
<el-link
v-if="canClaim(row)"
type="primary"
:disabled="pushingId === row.id"
@click="openClaim(row)"
>认领</el-link
>
<el-link
v-if="canPush(row)"
type="warning"
:disabled="pushingId === row.id"
@click="handlePush(row)"
>推送金蝶</el-link
>
<span v-if="!canClaim(row) && !canPush(row)">-</span>
</template>
</el-table-column>
</el-table>
@@ -153,7 +226,7 @@
{{ displayValue(detailDialog.data.detailSerialNo) }}
</el-descriptions-item>
<el-descriptions-item label="认领状态">
<el-tag :type="statusType(detailDialog.data.claimStatus)">
<el-tag :type="statusType(detailDialog.data, { prop: 'claimStatusName' })">
{{ displayValue(detailDialog.data.claimStatusName) }}
</el-tag>
</el-descriptions-item>
@@ -163,6 +236,23 @@
<el-descriptions-item label="剩余可认领金额">
{{ formatMoney(detailDialog.data.remainingAmount) }}
</el-descriptions-item>
<el-descriptions-item label="金蝶未认领金额">
{{ formatMoney(detailDialog.data.unclaimedAmount) }}
</el-descriptions-item>
<el-descriptions-item label="推送状态">
<el-tag :type="statusType(detailDialog.data, { prop: 'pushStatusName' })">
{{ displayValue(detailDialog.data.pushStatusName) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="金蝶收款单号">
{{ displayValue(detailDialog.data.kingdeeBillNo) }}
</el-descriptions-item>
<el-descriptions-item label="金蝶状态">
{{ displayValue(detailDialog.data.kingdeeBillStatusName) }}
</el-descriptions-item>
<el-descriptions-item label="推送失败原因">
{{ displayValue(detailDialog.data.pushFailReason) }}
</el-descriptions-item>
<el-descriptions-item label="摘要">
{{ displayValue(detailDialog.data.summary) }}
</el-descriptions-item>
@@ -177,7 +267,9 @@
</el-table-column>
<el-table-column prop="operatorName" label="认领人" min-width="120" align="center" />
<el-table-column label="状态变化" min-width="170" align="center">
<template #default="{ row }">{{ claimStatusChange(row) }}</template>
<template #default="{ row }">
{{ row.actionType === 'push' ? row.actionName : claimStatusChange(row) }}
</template>
</el-table-column>
<el-table-column prop="content" label="认领说明" min-width="220" />
</el-table>
@@ -190,97 +282,65 @@
</template>
<script>
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/receiptFlow';
import { receiptFlowTableColumns } from '@/option/payment/receiptFlow';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import { isAdministrator } from '@/utils/auth';
const receiptFlowMockTemplates = [
{
receiptNoticeNo: 'SKTZ20260827001',
payerName: '华东供应链管理有限公司',
receiptAmount: 48000,
counterpartyName: '上海安捷运输有限公司',
counterpartyAccount: '310000000000001001',
counterpartyBank: '中国工商银行上海分行',
summary: '运输服务款',
transactionTime: '2026-08-27 09:30:00',
detailSerialNo: 'MOCK-RF-20260827-001',
sourceUpdatedTime: '2026-08-27 09:30:00',
},
{
receiptNoticeNo: 'SKTZ20260827002',
payerName: '华东供应链管理有限公司',
receiptAmount: 26500,
counterpartyName: '江苏联运物流有限公司',
counterpartyAccount: '320100000000002002',
counterpartyBank: '中国建设银行南京分行',
summary: '物流服务款',
transactionTime: '2026-08-27 13:45:00',
detailSerialNo: 'MOCK-RF-20260827-002',
sourceUpdatedTime: '2026-08-27 13:45:00',
},
];
const randomDigits = length =>
Array.from({ length }, () => Math.floor(Math.random() * 10)).join('');
const formatMockDateTime = date => {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours()
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const createReceiptFlowMockRows = () => {
const count = 20 + Math.floor(Math.random() * 31);
return Array.from({ length: count }, (_, index) => {
const template = receiptFlowMockTemplates[index % receiptFlowMockTemplates.length];
const transactionDate = new Date();
transactionDate.setDate(transactionDate.getDate() - Math.floor(Math.random() * 7));
transactionDate.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0);
const transactionTime = formatMockDateTime(transactionDate);
const dateCode = transactionTime.slice(0, 10).replaceAll('-', '');
return {
...template,
receiptNoticeNo: `SKTZ${dateCode}${randomDigits(5)}`,
receiptAmount: Math.floor(10000 + Math.random() * 90000),
counterpartyAccount: `${template.counterpartyAccount.slice(0, -6)}${randomDigits(6)}`,
transactionTime,
detailSerialNo: `MOCK-RF-${dateCode}-${index + 1}${randomDigits(4)}`,
sourceUpdatedTime: transactionTime,
};
});
};
const emptyQuery = () => ({
receiptNoticeNo: '',
counterpartyName: '',
counterpartyBank: '',
counterpartyAccount: '',
summary: '',
detailSerialNo: '',
claimStatus: '',
pushStatus: '',
kingdeeBillStatus: '',
receiptAmountStart: undefined,
receiptAmountEnd: undefined,
claimerName: '',
transactionTimeRange: [],
});
export default {
name: 'ReceiptFlow',
components: { TableHeaderTools },
data() {
return {
ArrowDown,
ArrowUp,
query: emptyQuery(),
claimStatusOptions: api.claimStatusOptions,
pushStatusOptions: api.pushStatusOptions,
kingdeeBillStatusOptions: api.kingdeeBillStatusOptions,
columns: receiptFlowTableColumns,
visibleColumnKeys: receiptFlowTableColumns.map(column => column.prop),
searchVisible: true,
tableSize: 'default',
rows: [],
selectedRows: [],
loading: false,
syncing: false,
pushingId: null,
searchExpanded: false,
// 与 avue-crud 一致:搜索项数量超过 searchIndex(默认一行 4 项)时才显示“展开/收起”
searchIndex: 4,
searchItemCount: 7,
page: { current: 1, size: 10, total: 0 },
detailDialog: { visible: false, loading: false, data: {}, claimRecords: [] },
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
showSearchToggle() {
return this.searchItemCount > this.searchIndex;
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -290,8 +350,9 @@ export default {
},
methods: {
unwrapData(response) {
const body = response?.data || response || {};
return body?.data || body;
// 数字 0 是合法返回值(如回写/同步 0 条),须用 ?? 判空避免 0 被信封对象顶掉导致 Number() 得 NaN
const body = response?.data ?? response ?? {};
return body?.data ?? body;
},
async loadTable() {
this.loading = true;
@@ -327,9 +388,8 @@ export default {
async handleSync() {
this.syncing = true;
try {
const mockRows = createReceiptFlowMockRows();
const syncedCount = Number(this.unwrapData(await api.sync({ flows: mockRows })) || 0);
this.$message.success(`流水同步完成,共同步${syncedCount}条`);
const syncedCount = Number(this.unwrapData(await api.sync()) || 0);
this.$message.success(`流水同步完成,共同步${syncedCount}条认领公告`);
await this.loadTable();
} finally {
this.syncing = false;
@@ -388,8 +448,41 @@ export default {
canClaim(row) {
return this.hasPermission('receipt_flow_claim') && Number(row.remainingAmount || 0) > 0;
},
statusType(status) {
return { claimed: 'success', partial: 'warning', unclaimed: 'info' }[status] || '';
canPush(row) {
return (
this.hasPermission('receipt_flow_push') &&
!!row.billId &&
row.claimStatus === 'claimed' &&
row.pushStatus !== 'pushed'
);
},
async handlePush(row) {
try {
await this.$confirm(
`确认将流水【${row.receiptNoticeNo || row.detailSerialNo || ''}】推送金蝶生成收款单?`,
'推送金蝶',
{ type: 'warning' }
);
} catch {
return;
}
this.pushingId = row.id;
try {
const data = this.unwrapData(await api.pushKingdee(row.id));
const billNo = data?.kingdeeBillNo;
this.$message.success(billNo ? `推送成功,金蝶收款单号:${billNo}` : '推送成功');
await this.loadTable();
} finally {
this.pushingId = null;
}
},
statusType(row, column) {
if (column?.prop === 'pushStatusName') {
return { pushed: 'success', failed: 'danger' }[row.pushStatus] || 'info';
}
return (
{ claimed: 'success', partial: 'warning', unclaimed: 'info' }[row.claimStatus] || ''
);
},
claimStatusChange(row) {
const label = status =>
@@ -422,7 +515,14 @@ export default {
}
.receipt-flow-page__search :deep(.el-input),
.receipt-flow-page__search :deep(.el-select),
.receipt-flow-page__search :deep(.el-date-editor) {
.receipt-flow-page__search :deep(.el-date-editor),
.receipt-flow-page__search :deep(.el-input-number) {
width: 100%;
}
.receipt-flow-page__search .amount-range {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.receipt-flow-page__search-actions {
@@ -436,11 +536,15 @@ export default {
}
.receipt-flow-page__toolbar {
display: flex;
justify-content: flex-start;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.receipt-flow-page__toolbar-left {
display: flex;
gap: 8px;
}
.receipt-flow-page__pagination {
display: flex;
justify-content: flex-end;
@@ -452,6 +556,9 @@ export default {
.receipt-flow-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
.receipt-flow-page :deep(.el-table .cell .el-link + .el-link) {
margin-left: 12px;
}
.receipt-flow-page :deep(.el-table__row:nth-child(even) > td.el-table__cell) {
background: #fafafa;
}
@@ -14,7 +14,7 @@
label-width="auto"
>
<el-row :gutter="24">
<el-col v-for="field in fields" :key="field.prop" :span="6">
<el-col v-for="field in visibleFields" :key="field.prop" :span="6">
<el-form-item :label="field.label" :prop="field.prop">
<el-date-picker
v-if="field.type === 'date' && editable"
@@ -22,6 +22,7 @@
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
@change="fetchBfiExchangeRate"
/>
<el-select
v-else-if="field.type === 'project' && editable"
@@ -63,6 +64,12 @@
:precision="2"
:controls="false"
/>
<span
v-else-if="field.prop === 'formalSettlementNo'"
:class="{ 'form-readonly--code': form.formalSettlementNo }"
>
{{ form.formalSettlementNo || (form.id ? '-' : '系统自动生成') }}
</span>
<span v-else-if="field.money">{{ formatMoney(form[field.prop]) }}</span>
<span v-else-if="field.type === 'contract'">{{
displayValue(form.contractName)
@@ -175,6 +182,17 @@
<section-card title="结算明细">
<template #extra>
<div class="formal-editor__actions">
<el-button
v-if="hasPermission('formal_settlement_export')"
type="primary"
plain
@click="exportDetails"
>
导出
</el-button>
<el-button v-if="editable" type="primary" plain @click="openDetailDialog">
选择结算明细
</el-button>
<el-button type="primary" text @click="detailCollapsed = !detailCollapsed">
{{ detailCollapsed ? '展开明细' : '收起明细' }}
</el-button>
@@ -182,33 +200,46 @@
</template>
<el-form
:model="detailQuery"
inline
label-position="right"
label-width="88px"
label-width="auto"
class="formal-editor__detail-filter"
@submit.prevent
>
<el-form-item label="单据号">
<el-input v-model="detailQuery.documentNo" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.documentNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="运单号">
<el-input v-model="detailQuery.waybillNo" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.waybillNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="批次号">
<el-input v-model="detailQuery.batchNo" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.batchNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="货物名称">
<el-input v-model="detailQuery.cargoName" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.cargoName"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item class="formal-editor__detail-filter-actions">
<el-button type="primary" @click="handleDetailQuery">查询</el-button>
<el-button @click="resetDetailQuery">重置</el-button>
<el-button type="primary" :disabled="!details.length" @click="exportDetails">
导出
</el-button>
<el-button v-if="editable" type="primary" @click="openDetailDialog">
选择结算明细
</el-button>
</el-form-item>
</el-form>
<el-table v-show="!detailCollapsed" :data="pagedDetails" border>
@@ -221,24 +252,36 @@
:index="detailIndexMethod"
/>
<el-table-column
v-for="column in detailTableColumns"
:key="column.prop"
v-bind="column"
v-for="item in detailDisplayColumns"
:key="item.key"
:label="item.kind === 'fee' ? item.name : item.column.label"
:prop="item.kind === 'fee' ? undefined : item.column.prop"
:min-width="item.kind === 'fee' ? 130 : item.column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<span v-else-if="column.prop === 'transportQuantity'">
{{ formatDetailTransportQuantity(row[column.prop]) }}
<span v-if="item.kind === 'fee'">
{{ formatMoney(parseFeeItems(row.feeItemsJson || row.feeItems)[item.name]) }}
</span>
<span v-else-if="column.prop === 'mileage'">
{{ formatDetailMileage(row[column.prop]) }}
<el-link
v-else-if="item.column.link && row[item.column.prop] && !publicMode"
type="primary"
@click.stop="handleDetailLink(row, item.column)"
>
{{ row[item.column.prop] }}
</el-link>
<span v-else-if="item.column.money">{{ formatMoney(row[item.column.prop]) }}</span>
<span v-else-if="item.column.prop === 'transportQuantity'">
{{ formatDetailTransportQuantity(row[item.column.prop]) }}
</span>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeName(row[column.prop]) }}
<span v-else-if="item.column.prop === 'mileage'">
{{ formatDetailMileage(row[item.column.prop]) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
<span v-else-if="item.column.prop === 'transportType'">
{{ transportTypeName(row[item.column.prop]) }}
</span>
<span v-else>{{ displayValue(row[item.column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="150" fixed="right" align="center">
@@ -298,15 +341,23 @@
<span v-else>{{ attachmentTypeName(row.type) }}</span>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link
:disabled="!getAttachmentUrl(row)"
type="primary"
@click="previewAttachment(row)"
>
{{ row.originalName || row.name || '-' }}
</el-link>
<span
class="attachment-file-name"
:class="{ 'is-disabled': !getAttachmentUrl(row) }"
role="button"
tabindex="0"
@click="getAttachmentUrl(row) && previewAttachment(row)"
@keydown.enter="getAttachmentUrl(row) && previewAttachment(row)"
@keydown.space.prevent="getAttachmentUrl(row) && previewAttachment(row)"
>{{ row.originalName || row.name || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" min-width="120" align="center">
@@ -345,6 +396,16 @@
</div>
</section-card>
<project-advance-write-off
ref="projectAdvanceWriteOffRef"
v-model="projectAdvanceWriteOffs"
:editable="editable"
:contract-id="form.contractId"
:settlement-amount="form.settlementAmount"
settlement-type="formal"
:settlement-id="form.id"
/>
<section-card title="发票信息">
<div class="formal-editor__invoice-claim">
<el-form inline label-position="right" label-width="auto" @submit.prevent>
@@ -416,78 +477,61 @@
<span v-else>{{ formatMoney(row.matchedAmount) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center">
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row, $index }">
<div class="formal-editor__links">
<el-link type="primary" @click="viewInvoiceAttachment(row)">查看</el-link>
<el-link type="primary" @click="downloadInvoiceAttachment(row)">下载</el-link>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="viewInvoiceAttachment(row)"
>查看</el-link
>
<el-link
type="primary"
:disabled="kingdeeAttachmentBusy"
@click="downloadInvoiceAttachment(row)"
>下载</el-link
>
<el-link v-if="editable" type="danger" @click="removeInvoice($index)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="issuedInvoices.length" class="formal-editor__issued-invoices">
<div class="formal-editor__issued-title">
开票发票(本结算单开票申请经金蝶开票回写,文件由金蝶发票云直链提供)
</div>
<el-table :data="issuedInvoices" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="invoiceNo" label="发票号" min-width="150" align="center" />
<el-table-column prop="invoiceDate" label="开票日期" min-width="130" align="center">
<template #default="{ row }">{{ displayValue(row.invoiceDate) }}</template>
</el-table-column>
<el-table-column prop="invoiceAmount" label="价税合计" min-width="130" align="center">
<template #default="{ row }">{{ formatMoney(row.invoiceAmount) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<div class="formal-editor__links">
<el-link type="primary" @click="viewIssuedInvoice(row)">查看</el-link>
<el-link type="primary" @click="downloadIssuedInvoice(row)">下载</el-link>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section-card>
<el-dialog
<kingdee-invoice-pool-dialog
v-model="invoiceClaimDialog.visible"
title="查询可认领发票"
width="92%"
append-to-body
destroy-on-close
>
<el-table
ref="invoiceClaimTable"
v-loading="invoiceClaimDialog.loading"
:data="invoiceClaimPagedRows"
row-key="invoiceNo"
border
height="520"
@selection-change="invoiceClaimDialog.selected = $event"
>
<el-table-column
type="selection"
width="52"
fixed="left"
align="center"
reserve-selection
/>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="invoiceNo" label="发票号" min-width="180" align="center" />
<el-table-column prop="invoiceDate" label="开票日期" min-width="130" align="center" />
<el-table-column prop="invoiceType" label="发票类型" min-width="150" align="center" />
<el-table-column prop="issuerName" label="开票单位" min-width="180" align="center" />
<el-table-column prop="receiverName" label="受票单位" min-width="180" align="center" />
<el-table-column
prop="invoiceAmount"
label="发票金额(含税)"
min-width="160"
align="right"
>
<template #default="{ row }">{{ formatMoney(row.invoiceAmount) }}</template>
</el-table-column>
<el-table-column prop="matchedAmount" label="认领金额" min-width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.matchedAmount) }}</template>
</el-table-column>
</el-table>
<div class="formal-editor__pagination">
<el-pagination
v-model:current-page="invoiceClaimDialog.page.current"
v-model:page-size="invoiceClaimDialog.page.size"
:total="invoiceClaimDialog.page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
<template #footer>
<el-button @click="invoiceClaimDialog.visible = false">取消</el-button>
<el-button
type="primary"
:loading="invoiceClaimDialog.confirming"
@click="confirmInvoiceClaims"
>确认认领</el-button
>
</template>
</el-dialog>
confirm-text="确认认领"
multiple
:initial-invoice-no="String(invoiceClaimNo || '').trim()"
:query-api="invoicePoolQueryApi"
@confirm="handleInvoiceClaimConfirm"
/>
<section-card
v-if="readonly"
@@ -626,9 +670,10 @@
</section-card>
</div>
<template v-if="!pageMode" #footer>
<el-button v-if="editable" @click="handleSyncPlaceholder">同步</el-button>
<el-button @click="visible = false">取消</el-button>
<el-button
v-if="editable && !recordId"
v-if="editable"
type="primary"
plain
:loading="draftSaving"
@@ -641,9 +686,10 @@
>
</template>
<div v-if="pageMode && !publicMode" class="formal-editor__page-actions">
<el-button v-if="editable" @click="handleSyncPlaceholder">同步</el-button>
<el-button @click="visible = false">取消</el-button>
<el-button
v-if="editable && !recordId"
v-if="editable"
type="primary"
plain
:loading="draftSaving"
@@ -657,24 +703,27 @@
</div>
<el-dialog v-model="candidate.visible" title="选择预结算单" width="88%" append-to-body>
<el-form
:model="candidate.query"
inline
label-position="right"
label-width="88px"
class="formal-editor__candidate-search"
>
<el-form-item label="预结算单号"
<el-card>
<el-form
:model="candidate.query"
inline
label-position="right"
label-width="88px"
class="formal-editor__candidate-search"
>
<el-form-item label="预结算单号"
><el-input v-model="candidate.query.preSettlementNo" clearable
/></el-form-item>
<el-form-item label="合同编号"
/></el-form-item>
<el-form-item label="合同编号"
><el-input v-model="candidate.query.contractNo" clearable
/></el-form-item>
<el-form-item
/></el-form-item>
<el-form-item
><el-button @click="resetCandidates">重置</el-button
><el-button type="primary" @click="loadCandidates">查询</el-button></el-form-item
>
</el-form>
>
</el-form>
</el-card>
<el-table
ref="candidateTable"
v-loading="candidate.loading"
@@ -958,6 +1007,7 @@ import {
getNextNo,
getReceiptClaims,
claimInvoices as claimInvoicesApi,
queryInvoicePool,
save,
} from '@/api/settlement/formalSettlement';
import {
@@ -965,6 +1015,12 @@ import {
getDetailFees as getPreSettlementDetailFees,
} from '@/api/settlement/preSettlement';
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
import {
resolveReceivablePayableDetailId,
resolveWaybillId,
settlementDetailPath,
} from '@/utils/settlement-detail-link';
import { getBfiExchangeRate } from '@/api/base/currency';
import {
createFormalSettlementForm,
formalSettlementFormFields,
@@ -981,12 +1037,24 @@ import { h } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import { formatContractOptionLabel } from '@/utils/contract';
import { normalizeCurrencyCode } from '@/utils/currency';
import { downloadFileByUrl } from '@/utils/util';
import {
downloadKingdeeInvoiceFile,
isKingdeePathAttachment,
viewKingdeeInvoiceFile,
} from '@/utils/kingdee-attachment';
import KingdeeInvoicePoolDialog from '@/components/kingdee-invoice-pool-dialog/main.vue';
import ProjectAdvanceWriteOff from './project-advance-write-off.vue';
import * as XLSX from 'xlsx';
import { isAdministrator } from '@/utils/auth';
// 结算币种为本位币(人民币)时汇率恒为1,汇率日期、结算汇率与按汇率换算的本位币合计都不展示
const LOCAL_CURRENCY_HIDDEN_FIELDS = ['exchangeRateDate', 'exchangeRate', 'localSettlementAmount'];
export default {
name: 'FormalSettlementEditor',
components: { InfoFilled },
components: { InfoFilled, KingdeeInvoicePoolDialog, ProjectAdvanceWriteOff },
props: {
modelValue: Boolean,
recordId: [String, Number],
@@ -1015,18 +1083,20 @@ export default {
receiptClaims: [],
adjustments: [],
changeRecords: [],
projectAdvanceWriteOffs: [],
attachments: [],
selectedAttachmentRows: [],
invoices: [],
// 开票申请经金蝶回写的发票结果(只读展示,含板式文件/数电PDF下载地址)
issuedInvoices: [],
invoiceClaimNo: '',
invoiceClaimLoading: false,
// 金蝶侧发票附件查看/下载进行中(行组级 loading 守卫)
kingdeeAttachmentBusy: false,
invoiceClaimDialog: {
visible: false,
loading: false,
confirming: false,
rows: [],
selected: [],
page: { current: 1, size: 10, total: 0 },
confirming: false,
},
attachmentUploadFiles: [],
attachmentTypeOptions: [],
@@ -1118,7 +1188,7 @@ export default {
};
},
computed: {
...mapGetters(['userInfo']),
...mapGetters(['permission', 'userInfo']),
visible: {
get() {
return this.modelValue;
@@ -1146,9 +1216,40 @@ export default {
editable() {
return !this.readonly;
},
isAdmin() {
return isAdministrator(this.userInfo);
},
title() {
return this.readonly ? '查看正式结算单' : this.recordId ? '编辑正式结算单' : '新增正式结算单';
},
detailFeeItemNames() {
const names = new Set();
this.details.forEach(row => {
Object.keys(this.parseFeeItems(row.feeItemsJson || row.feeItems)).forEach(name =>
names.add(name)
);
});
return Array.from(names);
},
detailDisplayColumns() {
const columns = this.detailTableColumns;
const unitPriceIndex = columns.findIndex(column => column.prop === 'unitPrice');
const beforeColumns = unitPriceIndex < 0 ? columns : columns.slice(0, unitPriceIndex + 1);
const afterColumns = unitPriceIndex < 0 ? [] : columns.slice(unitPriceIndex + 1);
return [
...beforeColumns.map(column => ({ kind: 'field', key: column.prop, column })),
...this.detailFeeItemNames.map(name => ({ kind: 'fee', key: `fee-${name}`, name })),
...afterColumns.map(column => ({ kind: 'field', key: column.prop, column })),
];
},
// 币种为空按人民币处理,与后端预结算/正式结算对空币种的默认取值一致
isLocalCurrencySettlement() {
return normalizeCurrencyCode(this.form.currency) === 'RMB';
},
visibleFields() {
if (!this.isLocalCurrencySettlement) return this.fields;
return this.fields.filter(field => !LOCAL_CURRENCY_HIDDEN_FIELDS.includes(field.prop));
},
summaryTotal() {
return this.summaryFees.reduce((total, row) => total + Number(row.settlementAmount || 0), 0);
},
@@ -1180,11 +1281,6 @@ export default {
});
return Array.from(names);
},
invoiceClaimPagedRows() {
const { current, size } = this.invoiceClaimDialog.page;
const start = (current - 1) * size;
return this.invoiceClaimDialog.rows.slice(start, start + size);
},
},
watch: {
modelValue: {
@@ -1217,6 +1313,9 @@ export default {
},
methods: {
formatContractOptionLabel,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
async loadFormalDetail(id) {
const response = this.publicMode
? await getMkPublicDetail('formal-settlement', id)
@@ -1243,9 +1342,17 @@ export default {
}
this.adjustments = data.adjustments || [];
this.changeRecords = data.changeRecords || [];
this.projectAdvanceWriteOffs = (data.projectAdvanceWriteOffs || []).map(row => ({
...row,
writeOffAmount: Number(row.writeOffAmount || 0),
projectAdvanceAmount: Number(row.projectAdvanceAmount || 0),
availableWriteOffAmount: Number(row.availableWriteOffAmount || 0),
createTime: row.paymentCreateTime || row.createTime,
}));
this.attachments = this.parseAttachments(data.attachmentsJson);
this.selectedAttachmentRows = [];
this.invoices = data.invoices || [];
this.issuedInvoices = data.issuedInvoices || [];
this.sortAttachments();
this.contracts = this.filterContracts(this.allContracts, this.form.projectId);
},
@@ -1272,12 +1379,13 @@ export default {
this.receiptClaims = [];
this.adjustments = [];
this.changeRecords = [];
this.projectAdvanceWriteOffs = [];
this.attachments = [];
this.selectedAttachmentRows = [];
this.invoices = [];
this.issuedInvoices = [];
this.invoiceClaimNo = '';
this.invoiceClaimDialog.visible = false;
this.invoiceClaimDialog.rows = [];
this.invoiceClaimDialog.selected = [];
this.attachmentUploadFiles = [];
this.billingRuleDialog = {
@@ -1301,6 +1409,7 @@ export default {
if (this.initialData) await this.applyInitialData();
Object.assign(this.form, newRecordAudit);
await this.refreshFormalSettlementNo();
await this.fetchBfiExchangeRate();
return;
}
this.loading = true;
@@ -1535,6 +1644,7 @@ export default {
(settlementType === 'receivable' ? contract.partyA : contract.partyB),
settlementType,
settlementTypeName: settlementType === 'receivable' ? '应收' : '应付',
currency: contract.settlementCurrency || 'RMB',
});
this.sources = [];
this.details = [];
@@ -1542,6 +1652,23 @@ export default {
this.form.sourcePreSettlementIds = [];
this.form.sourceDetailIds = [];
if (refreshSettlementNo) this.refreshFormalSettlementNo();
if (this.form.currency !== 'RMB') this.fetchBfiExchangeRate();
},
async fetchBfiExchangeRate() {
const currency = this.form.currency;
if (!currency || currency === 'RMB') return;
if (!this.form.exchangeRateDate) return;
try {
const { data } = await getBfiExchangeRate(currency, this.form.exchangeRateDate);
const rateData = data?.data;
if (rateData?.excval != null) {
this.form.exchangeRate = Number(rateData.excval);
} else {
this.$message.warning(`未查询到 ${currency} 的BFI汇率数据`);
}
} catch (e) {
this.$message.warning('查询BFI汇率失败,请手动输入结算汇率');
}
},
openCandidateDialog() {
this.candidate.visible = true;
@@ -1979,6 +2106,21 @@ export default {
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage.current = 1;
},
// 结算明细的「单据号」跳到该应收应付单据在应收明细/应付明细页的详情,「运单号」跳到运单详情
async handleDetailLink(row, column) {
if (column.prop === 'documentNo') {
const settlementType = row.settlementType || this.form.settlementType || 'payable';
const detailId = await resolveReceivablePayableDetailId(row, settlementType);
if (!detailId) return this.$message.info('当前记录未找到对应的应收/应付明细');
this.$router.push({ path: settlementDetailPath(settlementType), query: { detailId } });
return;
}
if (column.prop === 'waybillNo') {
const waybillId = await resolveWaybillId(row);
if (!waybillId) return this.$message.info('当前记录未找到对应的运单详情');
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: waybillId } });
}
},
resetDetailQuery(apply = true) {
this.detailQuery = { documentNo: '', waybillNo: '', batchNo: '', cargoName: '' };
if (apply) this.handleDetailQuery();
@@ -2266,79 +2408,19 @@ export default {
this.adjust.saving = false;
}
},
claimInvoices() {
this.invoiceClaimLoading = true;
try {
const keyword = String(this.invoiceClaimNo || '').trim();
const defaultNo = `MOCK${this.$dayjs().format('YYYYMMDD')}`;
const baseNo = (keyword || defaultNo).slice(0, 29);
const numbers = [baseNo, `${baseNo}-02`, `${baseNo}-03`];
const invoiceAmounts = [1000, 2000, 3000];
const availableAmounts = [1000, 1000, 3000];
const preferredMatchedAmounts = [1000, 1000, 2000];
let remainingAmount = Math.max(
Number(this.form.settlementAmount || this.summaryTotal || 0) -
this.invoices.reduce((total, item) => total + Number(item.matchedAmount || 0), 0),
0
);
const existingNumbers = new Set(this.invoices.map(item => String(item.invoiceNo)));
const mockRows = numbers
.map((invoiceNo, index) => {
const matchedAmount = Math.min(
preferredMatchedAmounts[index],
availableAmounts[index],
remainingAmount
);
remainingAmount = Number((remainingAmount - matchedAmount).toFixed(2));
return {
invoiceNo,
invoiceDate: this.$dayjs().subtract(index, 'day').format('YYYY-MM-DD'),
invoiceType: index === 1 ? '增值税普通发票' : '增值税专用发票',
taxRate: [3, 6, 9][index],
invoiceAmount: invoiceAmounts[index],
availableInvoiceAmount: availableAmounts[index],
matchedAmount,
attachmentJson: '',
mockData: true,
};
})
.filter(item => !existingNumbers.has(item.invoiceNo));
if (!mockRows.length) {
this.$message.warning('当前发票号码的Mock数据已认领');
return;
}
this.invoices = [...this.invoices, ...mockRows];
this.$message.success(`已生成${mockRows.length}条Mock发票数据`);
} finally {
this.invoiceClaimLoading = false;
}
},
openInvoiceClaimDialog() {
const stamp = Date.now();
this.invoiceClaimDialog.loading = true;
this.invoiceClaimDialog.rows = Array.from({ length: 80 }, (_, index) => {
const amount = Math.floor(1000 + Math.random() * 49000);
return {
invoiceNo: `MOCK${stamp}${String(index + 1).padStart(3, '0')}`.slice(0, 32),
invoiceDate: this.$dayjs()
.subtract(Math.floor(Math.random() * 90), 'day')
.format('YYYY-MM-DD'),
invoiceType: index % 2 ? '增值税普通发票' : '增值税专用发票',
issuerName: this.form.payerName || 'Mock开票单位',
receiverName: this.form.payeeName || 'Mock受票单位',
invoiceAmount: amount,
availableInvoiceAmount: amount,
matchedAmount: amount,
taxRate: [3, 6, 9, 13][index % 4],
attachmentJson: '',
mockData: true,
};
});
this.invoiceClaimDialog.selected = [];
this.invoiceClaimDialog.page.current = 1;
this.invoiceClaimDialog.page.total = 80;
this.invoiceClaimDialog.visible = true;
this.invoiceClaimDialog.loading = false;
},
invoicePoolQueryApi(params) {
return queryInvoicePool(params);
},
async handleInvoiceClaimConfirm(rows) {
// 统一票池行附件字段为复数 attachmentsJson,认领请求契约是单数 attachmentJson,中转处归一
this.invoiceClaimDialog.selected = rows.map(row => ({
...row,
attachmentJson: row.attachmentJson || row.attachmentsJson,
}));
await this.confirmInvoiceClaims();
},
async confirmInvoiceClaims() {
if (!this.invoiceClaimDialog.selected.length) {
@@ -2364,7 +2446,11 @@ export default {
if (Number(this.form.settlementAmount || 0) > 0) {
remaining = Math.max(0, remaining - matchedAmount);
}
return { ...row, matchedAmount };
return {
...row,
invoiceType: row.invoiceTypeName || row.invoiceType || '',
matchedAmount,
};
})
.filter(row => row.matchedAmount > 0);
if (!invoices.length) {
@@ -2450,16 +2536,64 @@ export default {
const attachment = this.invoiceAttachment(row);
return attachment.originalName || attachment.name || `${row.invoiceNo || '发票'}.pdf`;
},
viewInvoiceAttachment(row) {
async viewInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 预览,OSS 直链维持 window.open
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await viewKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
const url = this.invoiceAttachmentUrl(row);
if (!url) return this.$message.warning('当前Mock发票暂无可查看附件');
if (!url) return this.$message.warning('当前发票暂无可查看附件');
window.open(url, '_blank');
},
downloadInvoiceAttachment(row) {
async downloadInvoiceAttachment(row) {
const attachment = this.invoiceAttachment(row);
// 金蝶侧附件(path 型)经后端代理 A801-2 下载,OSS 直链维持前端直连下载
if (isKingdeePathAttachment(attachment)) {
if (this.kingdeeAttachmentBusy) return;
this.kingdeeAttachmentBusy = true;
try {
await downloadKingdeeInvoiceFile(row.invoiceNo, this.invoiceAttachmentName(row));
} finally {
this.kingdeeAttachmentBusy = false;
}
return;
}
const url = this.invoiceAttachmentUrl(row);
if (!url) return this.$message.warning('当前Mock发票暂无可下载附件');
if (!url) return this.$message.warning('当前发票暂无可下载附件');
downloadFileByUrl(url, this.invoiceAttachmentName(row));
},
// 开票发票预览地址:数电PDF优先(OFD浏览器无法预览),退预览图、板式文件
issuedInvoicePreviewUrl(row) {
return row.invoicePdfFileUrl || row.invoiceImageUrl || row.invoiceFileUrl || '';
},
// 开票发票下载地址:数电PDF优先,退板式文件、数电XML
issuedInvoiceDownloadUrl(row) {
return row.invoicePdfFileUrl || row.invoiceFileUrl || row.invoiceXmlFileUrl || '';
},
// 下载文件名按直链扩展名推断(数电票 .pdf / 板式文件 .ofd|.pdf),缺省 .pdf
issuedInvoiceFileName(row, url) {
const match = String(url || '').split('?')[0].match(/\.([a-zA-Z0-9]+)$/);
const extension = match ? `.${match[1].toLowerCase()}` : '.pdf';
return `${row.invoiceNo || '发票'}${extension}`;
},
viewIssuedInvoice(row) {
const url = this.issuedInvoicePreviewUrl(row);
if (!url) return this.$message.warning('当前发票暂无可查看文件,可稍后重试开票结果同步');
window.open(url, '_blank');
},
downloadIssuedInvoice(row) {
const url = this.issuedInvoiceDownloadUrl(row);
if (!url) return this.$message.warning('当前发票暂无可下载文件,可稍后重试开票结果同步');
downloadFileByUrl(url, this.issuedInvoiceFileName(row, url));
},
buildSavePayload() {
return {
id: this.form.id,
@@ -2505,10 +2639,18 @@ export default {
matchedAmount: Number(row.matchedAmount || 0),
attachmentJson: row.attachmentJson || '',
})),
projectAdvanceWriteOffs: this.$refs.projectAdvanceWriteOffRef?.toSavePayload?.() || [],
remark: this.form.remark,
};
},
handleSyncPlaceholder() {
this.$message.info('开发中');
},
async handleDraftSave() {
// 保存草稿:不做表单校验,直接落草稿并返回列表刷新
if (this.$refs.projectAdvanceWriteOffRef?.validateBeforeSave?.() === false) {
return;
}
this.draftSaving = true;
try {
await save({
@@ -2516,8 +2658,8 @@ export default {
approvalStatus: 'draft',
});
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
this.visible = false;
} finally {
this.draftSaving = false;
}
@@ -2536,6 +2678,9 @@ export default {
return this.$message.warning('请完善手工费用的费用类型和费用项');
}
if (!this.validateInvoices()) return;
if (this.$refs.projectAdvanceWriteOffRef?.validateBeforeSave?.() === false) {
return;
}
// 附件类型仅做提示,不阻断正式结算单新增/保存提交。
// this.validateAttachments();
this.saving = true;
@@ -2816,25 +2961,32 @@ export default {
</script>
<style scoped lang="scss">
.form-readonly--code {
color: var(--el-color-primary);
}
.formal-editor--page {
padding-bottom: 72px;
}
.formal-editor__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.formal-editor__detail-filter {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
align-items: center;
gap: 8px 16px;
margin-bottom: 8px;
margin-bottom: 12px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.formal-editor__detail-filter-actions {
grid-column: 1 / -1;
width: auto;
margin-left: 0;
white-space: nowrap;
justify-self: end;
}
.formal-editor__page-actions {
@@ -2941,6 +3093,14 @@ export default {
.formal-editor__invoice-claim :deep(.el-input) {
width: 360px;
}
.formal-editor__issued-invoices {
margin-top: 16px;
}
.formal-editor__issued-title {
margin-bottom: 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.formal-editor__negative-amount {
color: var(--el-color-danger);
}
@@ -2957,9 +3117,29 @@ export default {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
/* 结算明细筛选区:覆盖全局 el-form-item 24px 行距,保证筛选控件与「查询/重置」同行居中对齐 */
.formal-editor__detail-filter :deep(.el-form-item) {
display: flex;
min-width: 0;
margin-right: 0;
margin-bottom: 0;
}
.formal-editor__detail-filter :deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
.formal-editor__detail-filter :deep(.el-input) {
width: 100%;
}
.formal-editor__detail-filter-actions :deep(.el-form-item__content) {
flex: none;
justify-content: flex-end;
flex-wrap: nowrap;
}
@media (max-width: 1200px) {
.formal-editor__detail-filter {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
}
</style>
@@ -21,6 +21,7 @@
type="primary"
plain
:disabled="selection.length !== 1"
:loading="syncLoading"
@click="$emit('sync')"
>同步金蝶</el-button
>
@@ -39,16 +40,20 @@
>导出</el-button
>
</div>
<div class="fs-table-panel__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="$emit('refresh')" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="rows"
:size="tableSize"
border
highlight-current-row
@current-change="handleCurrentChange"
@@ -57,7 +62,7 @@
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -87,6 +92,12 @@
<span v-else-if="column.prop === 'kingdeeSyncStatus'">{{
kingdeeSyncName(row.kingdeeSyncStatus)
}}</span>
<template v-else-if="column.prop === 'kingdeeLockStatus'">
<el-tooltip v-if="lockTag(row).show" :content="lockTag(row).tip" placement="top">
<el-tag :type="lockTag(row).type" class="status-text">{{ lockTag(row).label }}</el-tag>
</el-tooltip>
<span v-else>-</span>
</template>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
@@ -119,22 +130,25 @@
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import {
invoiceStatusName,
kingdeeSyncStatusOptions,
paymentStatusOptions,
} from '@/option/settlement/formalSettlementSearch';
import SettlementTableTools from './settlement-table-tools.vue';
export default {
name: 'FormalSettlementTablePanel',
components: { SettlementTableTools },
props: {
rows: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
syncLoading: { type: Boolean, default: false },
page: { type: Object, required: true },
hasPermission: { type: Function, required: true },
mkEnabled: { type: Boolean, default: false },
searchVisible: { type: Boolean, default: true },
},
emits: [
'create',
@@ -146,14 +160,47 @@ export default {
'page-change',
'action',
'update:selection',
'update:searchVisible',
],
data() {
return {
Refresh,
selection: [],
tableSize: 'default',
visibleColumnKeys: [],
knownColumnProps: [],
};
},
computed: {
columnSettingOptions() {
return this.columns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleColumns() {
if (!this.visibleColumnKeys.length) return this.columns;
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
watch: {
columns: {
immediate: true,
handler(columns) {
const props = columns.map(column => column.prop);
if (!this.visibleColumnKeys.length) {
this.visibleColumnKeys = props;
this.knownColumnProps = props;
return;
}
const visible = new Set(this.visibleColumnKeys);
const known = new Set(this.knownColumnProps || []);
props.forEach(prop => {
if (!known.has(prop)) visible.add(prop);
});
this.knownColumnProps = props;
this.visibleColumnKeys = props.filter(prop => visible.has(prop));
},
},
rows() {
this.selection = [];
this.$refs.tableRef?.clearSelection();
@@ -228,6 +275,38 @@ export default {
kingdeeSyncName(value) {
return kingdeeSyncStatusOptions.find(item => item.value === value)?.label || value || '-';
},
lockTag(row) {
if (row.kingdeeLockStatus === 'locked') {
return { show: true, type: 'danger', label: '金蝶锁定中', tip: '该单据已被金蝶锁定,不可编辑/作废' };
}
if (row.kingdeeLockStatus === 'failed') {
if ((row.kingdeeTryCount || 0) >= 5) {
return {
show: true,
type: 'danger',
label: '锁定失败·已达上限',
tip: row.kingdeeLockFailReason || '自动补偿次数已用完,请重推「同步金蝶」立即补锁',
};
}
return {
show: true,
type: 'warning',
label: '锁定失败·补偿中',
tip: row.kingdeeLockFailReason || '系统将自动补偿重锁',
};
}
// 金蝶解锁终态标识:删单解锁的源单已作废,此标签用于区分人工作废与金蝶删单
if (row.kingdeeUnlockTime) {
const isDelete = row.kingdeeUnlockOperationName === '金蝶删单';
return {
show: true,
type: isDelete ? 'danger' : 'warning',
label: row.kingdeeUnlockOperationName || '金蝶解锁',
tip: `${row.kingdeeUnlockOperator || '-'} 于 ${this.$dayjs(row.kingdeeUnlockTime).format('YYYY-MM-DD HH:mm:ss')} 解锁`,
};
}
return { show: false };
},
displayValue(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
@@ -245,11 +324,6 @@ export default {
align-items: center;
padding: 12px 0;
}
.fs-table-panel__toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.fs-table-panel__links {
display: flex;
flex-wrap: wrap;
@@ -16,7 +16,7 @@
class="pre-settlement-editor__form"
>
<el-row :gutter="24">
<el-col v-for="field in formFields" :key="field.prop" :span="field.span || 6">
<el-col v-for="field in visibleFormFields" :key="field.prop" :span="field.span || 6">
<el-form-item :label="field.label" :prop="field.prop">
<el-input
v-if="field.type === 'contract'"
@@ -40,9 +40,9 @@
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
:disabled="!editable || form.currency === 'RMB'"
:disabled="!editable"
placeholder="请选择"
@change="recalculateLocalAmount"
@change="handleExchangeRateDateChange"
/>
<el-input-number
v-else-if="field.type === 'number'"
@@ -50,7 +50,7 @@
:min="0"
:precision="2"
:controls="false"
:disabled="!editable || form.currency === 'RMB'"
:disabled="!editable"
@change="recalculateLocalAmount"
/>
<el-input
@@ -66,7 +66,11 @@
<span v-else-if="field.prop === 'settlementType'" class="form-readonly">
{{ settlementTypeName }}
</span>
<span v-else-if="field.prop === 'preSettlementNo'" class="form-readonly">
<span
v-else-if="field.prop === 'preSettlementNo'"
class="form-readonly"
:class="{ 'form-readonly--code': form.preSettlementNo }"
>
{{ form.preSettlementNo || (!form.id ? '系统自动生成' : '-') }}
</span>
<span v-else-if="field.prop === 'createTime'" class="form-readonly">
@@ -243,60 +247,52 @@
:index="detailIndexMethod"
/>
<el-table-column
v-for="column in visibleDetailColumns"
:key="column.prop"
:label="column.label"
:prop="column.prop"
:min-width="column.minWidth"
v-for="item in detailDisplayColumns"
:key="item.key"
:label="item.kind === 'fee' ? item.name : item.column.label"
:prop="item.kind === 'fee' ? undefined : item.column.prop"
:min-width="item.kind === 'fee' ? 130 : item.column.minWidth"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span v-if="item.kind === 'fee'">
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[item.name],
row.currency || form.currency
)
}}
</span>
<el-link
v-if="column.link && row[column.prop]"
v-else-if="item.column.link && row[item.column.prop] && !publicMode"
type="primary"
@click="openAdjustDialog(row, true)"
@click.stop="handleDetailLink(row, item.column)"
>
{{ row[column.prop] }}
{{ row[item.column.prop] }}
</el-link>
<span v-else-if="column.money && column.prop === 'unitPrice'">
<span v-else-if="item.column.money && item.column.prop === 'unitPrice'">
{{ formatUnitPrice(row) }}
</span>
<span v-else-if="column.money">
{{ formatMoney(row[column.prop], row.currency || form.currency) }}
<span v-else-if="item.column.money">
{{ formatMoney(row[item.column.prop], row.currency || form.currency) }}
</span>
<span v-else-if="column.prop === 'transportQuantityText'">
<span v-else-if="item.column.prop === 'transportQuantityText'">
{{ formatQuantity(row) }}
</span>
<span v-else-if="pageMode && column.prop === 'mileage'">
{{ formatDetailMileage(row[column.prop]) }}
<span v-else-if="item.column.prop === 'mileage'">
{{ formatDetailMileage(row[item.column.prop]) }}
</span>
<span v-else-if="column.prop === 'transportType'">
{{ transportTypeName(row[column.prop]) }}
<span v-else-if="item.column.prop === 'transportType'">
{{ transportTypeName(row[item.column.prop]) }}
</span>
<span v-else-if="column.prop === 'cargoName'">
<span v-else-if="item.column.prop === 'cargoName'">
{{ formatCargoNames(row) }}
</span>
<span v-else-if="column.prop === 'cargoType'">
<span v-else-if="item.column.prop === 'cargoType'">
{{ formatCargoTypes(row) }}
</span>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column
v-for="feeItem in detailFeeItemNames"
:key="`fee-${feeItem}`"
:label="feeItem"
min-width="130"
align="center"
>
<template #default="{ row }">
{{
formatMoney(
parseFeeItems(row.feeItemsJson)[feeItem],
row.currency || form.currency
)
}}
<span v-else>{{ displayValue(row[item.column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" :width="editable ? 150 : 90" fixed="right" align="center">
@@ -354,11 +350,21 @@
<span v-else>{{ attachmentTypeName(row.type) }}</span>
</template>
</el-table-column>
<el-table-column label="文件名称" min-width="260" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -411,6 +417,16 @@
</div>
</section-card>
<project-advance-write-off
ref="projectAdvanceWriteOffRef"
v-model="projectAdvanceWriteOffs"
:editable="editable"
:contract-id="form.contractId"
:settlement-amount="form.settlementAmount"
settlement-type="pre"
:settlement-id="form.id"
/>
<section-card v-if="form.id && readonly" title="预付信息">
<el-table :data="advances" border>
<el-table-column type="index" label="序号" width="64" align="center" />
@@ -766,12 +782,13 @@
</el-table-column>
<el-table-column label="里程(KM)" min-width="140" align="center">
<template #default="{ row }">
<span v-if="adjustDialog.readonly">{{ formatDetailMileage(row.mileage) }}</span>
<el-input-number
v-else
v-model="row.mileage"
:min="0"
:precision="2"
:controls="false"
:disabled="adjustDialog.readonly"
@change="recalculateAdjustRow(row, 'mileage')"
/>
</template>
@@ -929,19 +946,28 @@ import { h } from 'vue';
import { mapGetters } from 'vuex';
import { InfoFilled } from '@element-plus/icons-vue';
import ChangeRecordDetailDialog from '@/components/change-record-detail-dialog/main.vue';
import ProjectAdvanceWriteOff from './project-advance-write-off.vue';
import { getMkPublicDetail } from '@/api/mk-process';
import {
adjustDetail,
getCandidateDetails,
getContractList,
getContractOptions,
getDetail,
getDetailFees,
getFeeOptions,
getNextNo,
removeDetail,
save,
submit,
} from '@/api/settlement/preSettlement';
import { calculateAdjustedFee, getFeeDetail } from '@/api/settlement/receivable-payable-detail';
import {
resolveReceivablePayableDetailId,
resolveWaybillId,
settlementDetailPath,
} from '@/utils/settlement-detail-link';
import { getBfiExchangeRate } from '@/api/base/currency';
import { getDictionary } from '@/api/system/dictbiz';
import {
emptyPreSettlementForm,
@@ -963,14 +989,18 @@ import {
settlementSummaryColumns,
} from '@/option/settlement/preSettlementTable';
import { formatContractOptionLabel } from '@/utils/contract';
import { normalizeCurrencyCode } from '@/utils/currency';
import { downloadFileByUrl } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import * as XLSX from 'xlsx';
import { isAdministrator } from '@/utils/auth';
// 结算币种为本位币(人民币)时汇率恒为1,汇率日期、结算汇率与按汇率换算的本位币合计都不展示
const LOCAL_CURRENCY_HIDDEN_FIELDS = ['exchangeRateDate', 'exchangeRate', 'localSettlementAmount'];
export default {
name: 'PreSettlementEditor',
components: { InfoFilled, ChangeRecordDetailDialog },
components: { InfoFilled, ChangeRecordDetailDialog, ProjectAdvanceWriteOff },
props: {
modelValue: {
type: Boolean,
@@ -1073,6 +1103,7 @@ export default {
size: 10,
},
advances: [],
projectAdvanceWriteOffs: [],
changeRecords: [],
attachments: [],
attachmentTypeOptions: [],
@@ -1177,6 +1208,14 @@ export default {
settlementTypeName() {
return this.form.settlementType === 'receivable' ? '应收' : '应付';
},
// 币种为空按人民币处理,与后端 normalizeRate 的取默认值一致
isLocalCurrencySettlement() {
return normalizeCurrencyCode(this.form.currency) === 'RMB';
},
visibleFormFields() {
if (!this.isLocalCurrencySettlement) return this.formFields;
return this.formFields.filter(field => !LOCAL_CURRENCY_HIDDEN_FIELDS.includes(field.prop));
},
contractDialogCategory() {
return '承运商合同';
},
@@ -1198,6 +1237,17 @@ export default {
});
return Array.from(names);
},
detailDisplayColumns() {
const columns = this.visibleDetailColumns;
const unitPriceIndex = columns.findIndex(column => column.prop === 'unitPrice');
const beforeColumns = unitPriceIndex < 0 ? columns : columns.slice(0, unitPriceIndex + 1);
const afterColumns = unitPriceIndex < 0 ? [] : columns.slice(unitPriceIndex + 1);
return [
...beforeColumns.map(column => ({ kind: 'field', key: column.prop, column })),
...this.detailFeeItemNames.map(name => ({ kind: 'fee', key: `fee-${name}`, name })),
...afterColumns.map(column => ({ kind: 'field', key: column.prop, column })),
];
},
// 按 preSettlementDetailId 分组的明细数据(用于拼接货物名称和类型)
groupedDetails() {
const groups = {};
@@ -1272,6 +1322,11 @@ export default {
},
async initialize() {
this.resetEditor();
// 新增页在保存前没有创建人/创建日期,先按当前登录用户与当前时间展示将要落库的值
if (!this.recordId) {
this.form.createUserName = this.userInfo?.realName || this.userInfo?.userName || '';
this.form.createTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
}
if (!this.publicMode) {
await this.loadFeeOptions();
await this.loadFeeCategoryOptions();
@@ -1279,7 +1334,19 @@ export default {
await this.loadAttachmentTypeOptions();
}
if (this.recordId) await this.loadDetail();
else if (this.initialData) await this.applyInitialData();
else if (this.initialData) {
await this.applyInitialData();
this.form.exchangeRateDate = this.$dayjs().format('YYYY-MM-DD');
await this.refreshPreSettlementNo();
await this.fetchBfiExchangeRate();
} else if (!this.publicMode) {
await this.refreshPreSettlementNo();
}
},
async refreshPreSettlementNo() {
if (this.form.id) return;
const response = await getNextNo();
this.form.preSettlementNo = response.data?.data || '';
},
async applyInitialData() {
const rows = Array.isArray(this.initialData?.rows) ? this.initialData.rows : [];
@@ -1287,6 +1354,19 @@ export default {
const first = rows[0];
const settlementType = this.initialData.settlementType || first.settlementType || 'payable';
const contractId = first.contractId || '';
let contractPartyA = first.payerName || '';
let contractPartyB = first.payeeName || '';
if (contractId && (!contractPartyA || !contractPartyB)) {
try {
const { data } = await getContractOptions(first.contractNo || '');
const matched = (data?.data || []).find(item => String(item.id) === String(contractId));
contractPartyA = contractPartyA || matched?.partyA || '';
contractPartyB = contractPartyB || matched?.partyB || '';
} catch (error) {
contractPartyA = '';
contractPartyB = '';
}
}
if (!this.contractOptions.some(item => String(item.id) === String(contractId))) {
this.contractOptions.push({
id: contractId,
@@ -1297,8 +1377,8 @@ export default {
deptId: first.deptId,
deptName: first.deptName,
settlementType,
partyA: first.payerName,
partyB: first.payeeName,
partyA: contractPartyA,
partyB: contractPartyB,
});
}
this.handleContractChange(contractId);
@@ -1310,8 +1390,8 @@ export default {
projectName: first.projectName || this.form.projectName,
deptId: first.deptId || this.form.deptId,
deptName: first.deptName || this.form.deptName,
payerName: first.payerName || this.form.payerName,
payeeName: first.payeeName || this.form.payeeName,
payerName: this.form.payerName || contractPartyA,
payeeName: this.form.payeeName || contractPartyB,
settlementType,
currency: first.currency || 'RMB',
localCurrency: first.localCurrency || 'RMB',
@@ -1381,6 +1461,7 @@ export default {
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage = { current: 1, size: 10 };
this.advances = [];
this.projectAdvanceWriteOffs = [];
this.changeRecords = [];
this.attachments = [];
this.selectedAttachmentRows = [];
@@ -1420,6 +1501,13 @@ export default {
createUserName: row.createUserName || detail.createUserName,
createTime: row.createTime || detail.createTime,
}));
this.projectAdvanceWriteOffs = (detail.projectAdvanceWriteOffs || []).map(row => ({
...row,
writeOffAmount: Number(row.writeOffAmount || 0),
projectAdvanceAmount: Number(row.projectAdvanceAmount || 0),
availableWriteOffAmount: Number(row.availableWriteOffAmount || 0),
createTime: row.paymentCreateTime || row.createTime,
}));
this.changeRecords = detail.changeRecords || [];
this.attachments = this.parseAttachments(detail.attachmentsJson);
this.sortAttachments();
@@ -1564,6 +1652,7 @@ export default {
this.form.payerName = '';
this.form.payeeName = '';
this.summaryFees = [];
this.projectAdvanceWriteOffs = [];
return;
}
this.form.contractId = contract.id;
@@ -1574,9 +1663,16 @@ export default {
this.form.deptId = contract.deptId;
this.form.deptName = contract.deptName;
this.form.settlementType = contract.settlementType || 'payable';
this.form.payerName = contract.partyA;
this.form.payeeName = contract.partyB;
// 部分候选合同只带 id/名称,缺甲方乙方;此时保留已带出的收付方,不要清成 undefined
if (contract.partyA) this.form.payerName = contract.partyA;
if (contract.partyB) this.form.payeeName = contract.partyB;
this.form.currency = contract.settlementCurrency || 'RMB';
this.summaryFees = [];
this.projectAdvanceWriteOffs = [];
if (this.form.currency !== 'RMB') {
this.form.exchangeRateDate = this.$dayjs().format('YYYY-MM-DD');
this.fetchBfiExchangeRate();
}
},
async saveDraft(shouldSubmit) {
await this.$refs.formRef?.validate();
@@ -1593,6 +1689,9 @@ export default {
this.$message.warning(`请完善结算合计第${invalidManualFeeIndex + 1}行的费用类型和费用项`);
return;
}
if (this.$refs.projectAdvanceWriteOffRef?.validateBeforeSave?.() === false) {
return;
}
const stateKey = shouldSubmit ? 'submitting' : 'saving';
this[stateKey] = true;
try {
@@ -1603,13 +1702,14 @@ export default {
if (shouldSubmit) {
const previousApprovalStatus = this.form.approvalStatus || '';
await submit({ id: this.form.id });
await submitMkApprovalFlow({
const mkStarted = await submitMkApprovalFlow({
bizType: 'pre-settlement',
bizId: this.form.id,
subjectName: this.form.preSettlementNo || this.form.contractName || '',
approvalStatus: previousApprovalStatus,
mkEnabled: await loadMkEnabled().catch(() => false),
});
this.$message.success('审批流程已发起');
this.$message.success(mkStarted ? '审批流程已发起' : '提交成功');
this.visible = false;
this.$emit('success', this.form.id);
return;
@@ -1639,6 +1739,7 @@ export default {
remark: row.remark,
manualFlag: row.manualFlag || 0,
})),
projectAdvanceWriteOffs: this.$refs.projectAdvanceWriteOffRef?.toSavePayload?.() || [],
};
},
addSummaryFee() {
@@ -1695,7 +1796,7 @@ export default {
return option?.dictValue || value;
},
formatDetailMileage(value) {
if (value === undefined || value === null || value === '' || Number(value) === -1) return '';
if (value === undefined || value === null || value === '' || Number(value) === -1) return '-';
return value;
},
removeSummaryFee(index) {
@@ -1851,6 +1952,26 @@ export default {
.toFixed(2)
);
},
handleExchangeRateDateChange() {
this.fetchBfiExchangeRate();
this.recalculateLocalAmount();
},
async fetchBfiExchangeRate() {
if (!this.form.currency || this.form.currency === 'RMB') return;
if (!this.form.exchangeRateDate) return;
try {
const { data } = await getBfiExchangeRate(this.form.currency, this.form.exchangeRateDate);
const rateData = data?.data;
if (rateData?.excval != null) {
this.form.exchangeRate = Number(rateData.excval);
this.recalculateLocalAmount();
} else {
this.$message.warning(`未查询到 ${this.form.currency} 的BFI汇率数据`);
}
} catch (e) {
this.$message.warning('查询BFI汇率失败,请手动输入结算汇率');
}
},
recalculateLocalAmount() {
const rate = this.form.currency === 'RMB' ? 1 : Number(this.form.exchangeRate || 0);
this.form.localSettlementAmount = (Number(this.form.settlementAmount || 0) * rate).toFixed(2);
@@ -1947,6 +2068,21 @@ export default {
this.appliedDetailQuery = { ...this.detailQuery };
this.detailPage.current = 1;
},
// 结算明细的「单据号」跳到该应收应付单据在应收明细/应付明细页的详情,「运单号」跳到运单详情
async handleDetailLink(row, column) {
if (column.prop === 'documentNo') {
const settlementType = row.settlementType || this.form.settlementType || 'payable';
const detailId = await resolveReceivablePayableDetailId(row, settlementType);
if (!detailId) return this.$message.info('当前记录未找到对应的应收/应付明细');
this.$router.push({ path: settlementDetailPath(settlementType), query: { detailId } });
return;
}
if (column.prop === 'waybillNo') {
const waybillId = await resolveWaybillId(row);
if (!waybillId) return this.$message.info('当前记录未找到对应的运单详情');
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: waybillId } });
}
},
resetDetailQuery() {
this.detailQuery = {
documentNo: '',
@@ -2952,6 +3088,10 @@ export default {
line-height: 32px;
color: #303133;
}
.form-readonly--code {
color: var(--el-color-primary);
}
}
.dialog-section-title {
@@ -0,0 +1,300 @@
<template>
<section-card title="项目预付核销">
<template v-if="editable" #extra>
<el-button type="primary" :disabled="!contractId" @click="openSelectDialog">选择</el-button>
</template>
<el-table :data="rows" border>
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
prop="paymentNo"
label="预付单号"
min-width="160"
align="center"
show-overflow-tooltip
/>
<el-table-column label="项目预付金额" min-width="140" align="center">
<template #default="{ row }">{{ formatMoney(row.projectAdvanceAmount) }}</template>
</el-table-column>
<el-table-column label="可核销金额" min-width="140" align="center">
<template #default="{ row }">{{ formatMoney(row.availableWriteOffAmount) }}</template>
</el-table-column>
<el-table-column label="本次核销金额" min-width="160" align="center">
<template #default="{ row }">
<el-input-number
v-if="editable"
v-model="row.writeOffAmount"
:min="0"
:precision="2"
:controls="false"
class="project-advance-write-off__amount"
@change="handleAmountChange(row)"
/>
<span v-else>{{ formatMoney(row.writeOffAmount) }}</span>
</template>
</el-table-column>
<el-table-column
prop="kingdeeBillNo"
label="金蝶预付单号"
min-width="160"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.kingdeeBillNo || '-' }}</template>
</el-table-column>
<el-table-column
prop="createUserName"
label="创建人"
min-width="120"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.createUserName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.createTime || row.paymentCreateTime) }}</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="90" align="center" fixed="right">
<template #default="{ $index }">
<el-link type="danger" @click="removeRow($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialog.visible"
title="选择项目预付单"
width="960px"
append-to-body
destroy-on-close
class="dialog-form-title"
@closed="handleDialogClosed"
>
<el-table
v-loading="dialog.loading"
:data="dialog.rows"
border
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" :selectable="rowSelectable" />
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
prop="paymentNo"
label="预付单号"
min-width="160"
align="center"
show-overflow-tooltip
/>
<el-table-column label="项目预付金额" min-width="140" align="center">
<template #default="{ row }">{{ formatMoney(row.projectAdvanceAmount) }}</template>
</el-table-column>
<el-table-column label="可核销金额" min-width="140" align="center">
<template #default="{ row }">{{ formatMoney(row.availableWriteOffAmount) }}</template>
</el-table-column>
<el-table-column
prop="kingdeeBillNo"
label="金蝶预付单号"
min-width="160"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.kingdeeBillNo || '-' }}</template>
</el-table-column>
<el-table-column
prop="createUserName"
label="创建人"
min-width="120"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.createUserName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" :disabled="!dialog.selection.length" @click="confirmSelect">
确定
</el-button>
</template>
</el-dialog>
</section-card>
</template>
<script>
import { getProjectAdvanceWriteOffOptions } from '@/api/payment/paymentApplication';
export default {
name: 'ProjectAdvanceWriteOff',
props: {
modelValue: {
type: Array,
default: () => [],
},
editable: {
type: Boolean,
default: true,
},
contractId: {
type: [String, Number],
default: '',
},
settlementAmount: {
type: [String, Number],
default: 0,
},
settlementType: {
type: String,
required: true,
},
settlementId: {
type: [String, Number],
default: '',
},
},
emits: ['update:modelValue'],
data() {
return {
dialog: {
visible: false,
loading: false,
rows: [],
selection: [],
},
};
},
computed: {
rows: {
get() {
return Array.isArray(this.modelValue) ? this.modelValue : [];
},
set(value) {
this.$emit('update:modelValue', value);
},
},
},
methods: {
formatMoney(value) {
const amount = Number(value);
return Number.isFinite(amount) ? amount.toFixed(2) : '0.00';
},
formatDateTime(value) {
if (!value) return '-';
return this.$dayjs(value).format('YYYY-MM-DD HH:mm:ss');
},
money(value) {
const amount = Number(value);
return Number.isFinite(amount) ? Number(amount.toFixed(2)) : 0;
},
openSelectDialog() {
if (!this.contractId) {
this.$message.warning('请先选择合同');
return;
}
this.dialog.visible = true;
this.loadOptions();
},
async loadOptions() {
this.dialog.loading = true;
try {
const res = await getProjectAdvanceWriteOffOptions({
contractId: this.contractId,
excludeSettlementType: this.settlementType,
excludeSettlementId: this.settlementId || undefined,
});
this.dialog.rows = res?.data?.data || [];
} finally {
this.dialog.loading = false;
}
},
rowSelectable(row) {
const selectedIds = new Set(this.rows.map(item => String(item.paymentApplicationId)));
return !selectedIds.has(String(row.paymentApplicationId));
},
handleSelectionChange(list) {
this.dialog.selection = list || [];
},
confirmSelect() {
const nextRows = [...this.rows];
const existed = new Set(nextRows.map(item => String(item.paymentApplicationId)));
this.dialog.selection.forEach(item => {
const id = String(item.paymentApplicationId);
if (existed.has(id)) return;
nextRows.push({
paymentApplicationId: item.paymentApplicationId,
paymentNo: item.paymentNo,
projectAdvanceAmount: this.money(item.projectAdvanceAmount),
availableWriteOffAmount: this.money(item.availableWriteOffAmount),
writeOffAmount: this.money(item.availableWriteOffAmount),
kingdeeBillNo: item.kingdeeBillNo || '',
createUserName: item.createUserName || '',
createTime: item.createTime,
paymentCreateTime: item.createTime,
});
});
this.rows = nextRows;
this.dialog.visible = false;
this.validateTotal();
},
handleDialogClosed() {
this.dialog.rows = [];
this.dialog.selection = [];
},
handleAmountChange(row) {
const available = this.money(row.availableWriteOffAmount);
let amount = this.money(row.writeOffAmount);
if (amount < 0) amount = 0;
if (amount > available) {
amount = available;
this.$message.warning('本次核销金额不能超过可核销金额');
}
row.writeOffAmount = amount;
this.validateTotal();
},
validateTotal() {
const total = this.rows.reduce((sum, row) => sum + this.money(row.writeOffAmount), 0);
const settlementAmount = this.money(this.settlementAmount);
if (settlementAmount > 0 && total > settlementAmount) {
this.$message.warning('项目预付核销合计不能超过结算单金额');
return false;
}
return true;
},
validateBeforeSave() {
for (const row of this.rows) {
const amount = this.money(row.writeOffAmount);
if (amount <= 0) {
this.$message.warning(`预付单${row.paymentNo || ''}本次核销金额必须大于0`);
return false;
}
if (amount > this.money(row.availableWriteOffAmount)) {
this.$message.warning(`预付单${row.paymentNo || ''}本次核销金额不能超过可核销金额`);
return false;
}
}
return this.validateTotal();
},
removeRow(index) {
const nextRows = [...this.rows];
nextRows.splice(index, 1);
this.rows = nextRows;
},
toSavePayload() {
return this.rows.map(row => ({
paymentApplicationId: row.paymentApplicationId,
writeOffAmount: this.money(row.writeOffAmount),
}));
},
},
};
</script>
<style scoped lang="scss">
.project-advance-write-off__amount {
width: 100%;
}
:deep(.project-advance-write-off__amount .el-input__inner) {
text-align: right;
}
</style>
@@ -193,11 +193,21 @@
<el-table :data="attachments" border @selection-change="selectedAttachments = $event">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column label="文件名" min-width="220" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
<template>
<table-header-tools v-bind="$attrs" />
</template>
<script>
import TableHeaderTools from '@/components/table-header-tools/main.vue';
export default {
name: 'SettlementTableTools',
components: { TableHeaderTools },
inheritAttrs: false,
};
</script>
@@ -1278,10 +1278,13 @@ export default {
delete snapshot._externalKey;
delete snapshot.feeItems;
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
return snapshot;
});
},
serializeExternalRows() {
const duplicateIds = new Set(this.duplicateRows.map(row => String(row.id)));
return this.externalDetails.map(row => {
const snapshot = { ...row };
delete snapshot._sourceRows;
@@ -1290,6 +1293,7 @@ export default {
snapshot.feeItemsJson = row.feeItemsJson || JSON.stringify(row.feeItems || {});
snapshot.actualDepartureTime = this.normalizeExternalDateTime(row.actualDepartureTime);
snapshot.actualCompletionTime = this.normalizeExternalDateTime(row.actualCompletionTime);
snapshot.suspectedDuplicate = duplicateIds.has(String(row.id));
return snapshot;
});
},
@@ -1336,6 +1340,11 @@ export default {
}
},
async handleComplete() {
const unmatchedInternal = this.internalDetails.filter(item => item.matchResult !== 'matched').length;
const unmatchedExternal = this.externalDetails.filter(item => item.matchStatus !== 'matched').length;
if (this.duplicateRows.length || unmatchedInternal || unmatchedExternal) {
return this.$message.warning('所有内外部账单明细必须一一匹配且不存在疑似重复');
}
if (
Number(this.form.differenceCount || 0) !== 0 ||
Number(this.form.differenceQuantity || 0) !== 0 ||
@@ -1694,8 +1703,11 @@ export default {
},
normalizeExternalDateTime(value) {
if (!value) return null;
const text = String(value);
return text.length === 10 ? `${text}T00:00:00` : text;
const text = String(value).trim();
if (!text) return null;
const date = this.$dayjs(text);
if (date.isValid()) return date.format('YYYY-MM-DD HH:mm:ss');
return text.length === 10 ? `${text} 00:00:00` : text.replace('T', ' ');
},
refreshLocalStats() {
const internalQuantity = this.internalDetails.reduce(
@@ -10,22 +10,26 @@
>
<el-button type="primary" plain @click="$emit('export')">导出</el-button>
</div>
<div class="tr-table-panel__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="$emit('refresh')" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -73,24 +77,68 @@
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import SettlementTableTools from './settlement-table-tools.vue';
export default {
name: 'TransportReconciliationTablePanel',
components: { SettlementTableTools },
props: {
rows: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
page: { type: Object, required: true },
hasPermission: { type: Function, required: true },
searchVisible: { type: Boolean, default: true },
},
emits: ['create', 'export', 'refresh', 'page-change', 'update:selection', 'action'],
emits: [
'create',
'export',
'refresh',
'page-change',
'update:selection',
'action',
'update:searchVisible',
],
data() {
return {
Refresh,
selection: [],
tableSize: 'default',
visibleColumnKeys: [],
knownColumnProps: [],
};
},
computed: {
columnSettingOptions() {
return this.columns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleColumns() {
if (!this.visibleColumnKeys.length) return this.columns;
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
watch: {
columns: {
immediate: true,
handler(columns) {
const props = columns.map(column => column.prop);
if (!this.visibleColumnKeys.length) {
this.visibleColumnKeys = props;
this.knownColumnProps = props;
return;
}
const visible = new Set(this.visibleColumnKeys);
const known = new Set(this.knownColumnProps || []);
props.forEach(prop => {
if (!known.has(prop)) visible.add(prop);
});
this.knownColumnProps = props;
this.visibleColumnKeys = props.filter(prop => visible.has(prop));
},
},
},
methods: {
handleSelectionChange(value) {
this.selection = value;
@@ -127,11 +175,6 @@ export default {
align-items: center;
padding: 12px 0;
}
.tr-table-panel__toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.tr-table-panel__links {
display: flex;
flex-wrap: wrap;
@@ -8,6 +8,7 @@
:record-id="recordId"
:readonly="readonly"
:initial-data="editorInitialData"
@success="markListNeedRefresh"
/>
</basic-container>
</template>
@@ -16,6 +17,8 @@
import FormalSettlementEditor from './components/formal-settlement-editor.vue';
import { readSettlementTransfer, removeSettlementTransfer } from '@/utils/settlement-transfer';
const LIST_REFRESH_KEY = 'formal-settlement-list-refresh';
export default {
name: 'FormalSettlementForm',
components: { FormalSettlementEditor },
@@ -54,6 +57,9 @@ export default {
this.syncTagTitle();
},
methods: {
markListNeedRefresh() {
sessionStorage.setItem(LIST_REFRESH_KEY, '1');
},
goBack() {
removeSettlementTransfer(this.$route.query.transferToken);
this.$router.$avueRouter.closeTag();
+41 -7
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="formal-page">
<section class="formal-page__search">
<section v-show="searchVisible" class="formal-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="formal-page__search-grid">
<el-form-item v-for="field in visibleSearchFields" :key="field.prop" :label="field.label">
@@ -64,7 +64,9 @@
:loading="loading"
:page="page"
:has-permission="hasPermissionFn"
:sync-loading="syncLoading"
:mk-enabled="mkEnabled"
v-model:search-visible="searchVisible"
@create="openCreate"
@payment="openPaymentDialog"
@sync="handleSync"
@@ -83,7 +85,9 @@
:loading="loading"
:page="page"
:has-permission="hasPermissionFn"
:sync-loading="syncLoading"
:mk-enabled="mkEnabled"
v-model:search-visible="searchVisible"
@create="openCreate"
@payment="openPaymentDialog"
@sync="handleSync"
@@ -212,12 +216,14 @@ export default {
ArrowUp,
query: emptyQuery(),
searchExpanded: false,
searchVisible: true,
activeSettlementType: 'payable',
searchFields: formalSettlementSearchFields,
columns: formalSettlementTableColumns,
rows: [],
selection: [],
loading: false,
syncLoading: false,
page: { current: 1, size: 10, total: 0 },
editor: { visible: false, id: null, readonly: false },
paymentDialog: {
@@ -259,6 +265,12 @@ export default {
this.mkEnabled = false;
});
},
activated() {
if (sessionStorage.getItem('formal-settlement-list-refresh') === '1') {
sessionStorage.removeItem('formal-settlement-list-refresh');
this.loadTable();
}
},
methods: {
hasPermission(code) {
return this.permission?.[code] !== false;
@@ -356,6 +368,7 @@ export default {
bizId: row.id,
subjectName: row.formalSettlementNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
@@ -384,8 +397,13 @@ export default {
'提示',
{ type: 'warning' }
);
await api.approve({ id: row.id });
this.$message.success('审批通过');
const res = await api.approve({ id: row.id });
// data=false 表示审批已通过但自动同步金蝶失败,msg 带具体原因
if (res.data.data === false) {
this.$message.warning(res.data.msg);
} else {
this.$message.success(res.data.msg || '审批通过');
}
this.loadTable();
},
async handleReturn(row) {
@@ -412,12 +430,28 @@ export default {
return this.selection[0];
},
async handleSync() {
if (this.syncLoading) return;
const row = this.selectedOne('同步金蝶');
if (!row) return;
const response = await api.syncKingdee(row.id);
const data = this.unwrapData(response);
this.$message.success(`同步成功,金蝶单据号:${data}`);
this.loadTable();
this.syncLoading = true;
try {
const response = await api.syncKingdee(row.id);
const data = this.unwrapData(response);
this.$message.success(`同步成功,金蝶单据号:${data}`);
await this.loadTable();
// 附件联带同步提示:同步成功但附件未全部上传时追加 warning,重推即自动补传
const freshRow = (this.rows || []).find(item => String(item.id) === String(row.id));
if (freshRow && ['pending', 'partial'].includes(freshRow.attachmentSyncStatus)) {
this.$message({
type: 'warning',
message: '该单附件未全部上传,可重推补传',
duration: 8000,
});
}
} finally {
this.syncLoading = false;
}
},
async openPaymentDialog() {
if (!this.selection.length) return this.$message.warning('请至少选择一条正式结算单');
+26 -11
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="pre-settlement-page">
<section class="pre-settlement-page__search">
<section v-show="searchVisible" class="pre-settlement-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="pre-settlement-page__search-grid">
<template v-for="field in visibleSearchFields" :key="field.prop">
@@ -94,16 +94,19 @@
导出
</el-button>
</div>
<div class="pre-settlement-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
class="pre-settlement-page__table"
@selection-change="handleSelectionChange"
@@ -111,7 +114,7 @@
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in tableColumns"
v-for="column in visibleTableColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
@@ -285,7 +288,7 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import {
approve,
@@ -306,6 +309,7 @@ import { downloadFile } from '@/utils/util';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import PreSettlementEditor from './components/pre-settlement-editor.vue';
import SettlementTableTools from './components/settlement-table-tools.vue';
import { isAdministrator } from '@/utils/auth';
const emptyQuery = () => ({
@@ -323,18 +327,20 @@ const emptyQuery = () => ({
export default {
name: 'PreSettlement',
components: { PreSettlementEditor },
components: { PreSettlementEditor, SettlementTableTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
loading: false,
query: emptyQuery(),
searchFields: preSettlementSearchFields,
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
tableColumns: preSettlementTableColumns,
visibleColumnKeys: preSettlementTableColumns.map(column => column.prop),
rows: [],
selection: [],
page: {
@@ -365,6 +371,15 @@ export default {
visibleSearchFields() {
return this.searchExpanded ? this.searchFields : this.searchFields.slice(0, 4);
},
columnSettingOptions() {
return this.tableColumns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleTableColumns() {
return this.tableColumns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
watch: {
'$route.query.detailNo'(detailNo) {
@@ -646,6 +661,7 @@ export default {
bizId: row.id,
subjectName: row.preSettlementNo || '',
approvalStatus: previousApprovalStatus,
mkEnabled: this.mkEnabled,
});
this.$message.success('提交成功');
this.loadTable();
@@ -776,7 +792,6 @@ export default {
}
&__toolbar-left,
&__toolbar-right,
&__links {
display: flex;
flex-wrap: wrap;

Some files were not shown because too many files have changed in this diff Show More