diff --git a/openspec/changes/add-employee-collection/design.md b/openspec/changes/add-employee-collection/design.md new file mode 100644 index 0000000..d2fa7a1 --- /dev/null +++ b/openspec/changes/add-employee-collection/design.md @@ -0,0 +1,88 @@ +## Context + +员工代收款是 8 月迭代新增的财务能力,普通员工与超级管理员共用同一套接口,靠登录态区分数据范围。后台管理端需要新增三类页面,并复用已有的表格、搜索、详情与上传组件。`docs/admin-openapi.yaml` 未随仓库提供,接口字段以后端契约(需求文档 + 创建订单接口 OpenAPI 片段)为准,类型集中在一个文件便于联调收敛。 + +## Goals / Non-Goals + +**Goals** + +- 在财务管理下提供收款方式、员工代收款账单、核销申请三类页面。 +- 复用 `ArtTableFullScreen`、`ArtSearchBar`、`ArtTableHeader`、`ArtTable`、`DetailPage`、`VoucherUpload`、`PaymentVoucherDialog`、`useCheckedColumns` 等既有组件与约定。 +- 复用既有企微审批场景配置能力,仅新增业务类型。 + +**Non-Goals** + +- 不实现后端接口、数据库、Worker、企微回调。 +- 不实现 H5/C 端页面与支付流程。 +- 不新增导出任务场景(需求文档未要求)。 + +## Decisions + +### 接口契约 + +| 能力 | 关键字段 | +|---|---| +| 统一响应 | `{ code, data, msg, timestamp }` | +| 列表分页 | 账单列表返回 `{ items, total, page, size }`,取数处对 `items` / `list` / `records` 做兼容 | +| 收款方式 | `{ id, code, name, sort, enabled, remark, created_at, updated_at }`,列表接口返回 `{ items, page, size, total }`,支持 `page` / `page_size` / `enabled` / `keyword` 筛选 | +| 账单列表项 | `{ id, source_type, source_type_name, source_no, debtor_snapshot, customer_snapshot, receivable_amount, received_amount, reserved_amount, remaining_amount, status, status_name, approval_pending, closed_reason, created_at, updated_at }` | +| 账单详情 | `data` 为 `{ bill, refunds, allocations, applications }`;`refunds` 为退款冲销(`refund_id`、`source_order_id`、`refund_amount`、`reduced_amount`、`bill_receivable_amount`、`outcome_name`),`allocations` 为账单侧分摊(含 `application_id` / `application_status_name` / `attempt_id`),`applications` 内嵌该申请的 `attempts` | +| 账单统计 | `{ receivable_total, received_total, unsettled_total, pending_bill_count }` | +| 账单筛选 | `page`、`page_size`、`source_type`、`source_no`、`status`、`debtor_account_id`、`customer_id`、`created_from`(`YYYY-MM-DD`)、`created_to`(`YYYY-MM-DD`) | +| 核销申请请求体 | `{ payment_method_id, paid_amount, paid_at, payer_name, external_transaction_no, payment_voucher_keys, remark, allocations: [{ bill_id, amount }], acting_reason }` | +| 核销申请列表项 | `{ id, applicant_account_id, acting_operator_id, payment_method_id, payment_method_name, paid_amount, payer_name, external_transaction_no, status, status_name, terminal_reason, decided_at, created_at, updated_at }` | +| 核销申请详情 | `data` 为 `{ application, allocations, attempts }`,`attempts` 保存每次提交的完整材料快照,重新提交不清空历史 | + +账单状态为数字枚举 `0` 待核销 / `1` 部分核销 / `2` 已核销 / `3` 已关闭;申请状态为数字枚举 `0` 审批中 / `1` 已通过 / `2` 已驳回 / `3` 已撤销或已关闭;两者展示均优先使用后端 `status_name`。 + +### 页面与路由组织 + +在 `/finance` 下新增: + +| 路由 | 页面 | 说明 | +|---|---|---| +| `/finance/employee-collection/bills` | 员工代收款账单 | 统计 + 列表 | +| `/finance/employee-collection/bills/detail/:id` | 账单详情 | 隐藏菜单 | +| `/finance/employee-collection/applications` | 核销申请 | 列表 + 创建 | +| `/finance/employee-collection/applications/detail/:id` | 核销申请详情 | 隐藏菜单 | +| `/finance/employee-collection/payment-methods` | 收款方式管理 | 仅超管 | + +账单与申请拆分为独立菜单,符合项目「列表页 + 详情页」的既有组织方式,避免单页堆叠过多交互。账单列表的「店铺」筛选用远程搜索复用 `ShopService.getShops`,与退款列表一致。 + +账单详情与核销申请详情保持只读:页面只在顶部保留「返回」导航,创建核销申请、关闭账单、修改并重新提交等操作入口统一放在列表页的操作列,详情页不出现业务操作按钮。 + +### 权限编码 + +新增 `src/config/constants/augustIteration.ts`,沿用 `模块:动作` 风格,例如 `employee_collection:bill_close`、`employee_collection:application_create`。页面级 `permissions` 用于菜单可见性,按钮级编码用于 `hasAuth()` / `v-permission`。 + +### 金额、时间与附件 + +- 金额统一以「分」传输,展示时通过 `fenToYuan` / `formatCurrency` 转换,与退款、代理充值保持一致。 +- 所有时间字段统一通过 `formatDateTime` 格式化为 `YYYY-MM-DD HH:mm:ss`,不在模板中直接输出后端原始时间字符串。 +- 附件仅返回对象 Key(`payment_voucher_keys`),展示复用 `PaymentVoucherDialog`,由预签名下载接口换取访问地址。 + +### 核销申请分摊 + +创建申请时按账单逐条录入核销金额,并填写付款事实(付款金额、付款方名称、付款时间、外部交易流水号),前端校验: + +- 至少选择 1 张账单,最多 N 张; +- 单张核销金额不得大于账单未核销金额,且大于 0; +- 付款金额(`paid_amount`,分)不得小于各账单分摊之和(允许存在差额); +- 超管代办时 `acting_reason` 必填; +- 付款凭证至少 1 个 Key。 + +提交成功后自动发起企微审批;仅已驳回申请可再次进入弹窗修改并重新提交,重新提交生成新的审批实例,历史审批记录只读展示。未配置企微审批场景时后端返回 503,前端展示「企微审批场景未配置,请联系管理员」并保留已填内容。 + +### 企微审批场景 + +`WecomBusinessType` 增加 `employee_collection_approval`,企微审批场景页面下拉新增「员工代收款审批」。模板控件同步、字段查询、字段映射保存全部复用既有 `WecomService`。 + +### 订单付款凭证规则 + +线下订单创建时,满足「平台账号(`user_type` 为 1 或 2)操作 + 非赠送套餐 + 实际收款金额大于 0」条件的订单会生成员工代收款账单,此时 `payment_voucher_key` 非必填,字段结构不变,付款凭证改在核销申请中提交。前端以当前登录账号类型、所选套餐是否赠送、套餐有效价格(`effective_retail_price` / `suggested_retail_price` / `retail_price`)判断是否展示提示并放宽必填;赠送套餐或其他非平台账号的线下订单仍需上传凭证。 + +## Risks / Trade-offs + +- **接口字段以契约文档为准**:`docs/admin-openapi.yaml` 未入库,字段来自需求文档与创建订单接口片段;类型集中在一个文件,便于联调时收敛修改。 +- **员工/超管同接口**:前端不做数据范围过滤,仅做展示与操作可见性控制,数据隔离以后端为准。 +- **关闭账单与审批中申请**:前端依据 `approval_pending` 与状态字段禁用关闭按钮,最终一致性以后端校验为准。 diff --git a/openspec/changes/add-employee-collection/proposal.md b/openspec/changes/add-employee-collection/proposal.md new file mode 100644 index 0000000..f99ecd4 --- /dev/null +++ b/openspec/changes/add-employee-collection/proposal.md @@ -0,0 +1,25 @@ +# Change: 员工代收款功能前端对接 + +## Why + +8 月产品迭代新增「员工代收款」能力:员工线下代收款项后,通过创建核销申请、经企业微信审批完成账单核销;超级管理员可维护收款方式、查看全部账单并关闭账单。后端接口已按 `docs/产品迭代8月份/员工代收款功能简介.md` 落地,后台管理端目前缺少收款方式管理、账单列表与核销申请页面,普通员工与超级管理员的分类视图、以及线下订单付款凭证规则尚未接入。 + +本次已按后端实际契约对齐字段:列表响应统一使用 `items` / `total` / `page` / `size`;账单状态(`0` 待核销 / `1` 部分核销 / `2` 已核销 / `3` 已关闭)与申请状态(`0` 审批中 / `1` 已通过 / `2` 已驳回 / `3` 已撤销或已关闭)为数字枚举;收款方式列表返回 `{ items, page, size, total }` 并支持 `enabled` / `keyword` 筛选;核销申请请求体使用 `paid_amount`、`paid_at`、`payer_name`、`external_transaction_no`、`payment_voucher_keys` 与 `allocations`。 + +## What Changes + +- 新增 `employee-collection` 能力的前端类型与服务封装:收款方式 4 个接口、员工代收款账单 4 个接口、核销申请 4 个接口。 +- 新增「收款方式管理」页面(仅超级管理员):列表 + 新增/编辑弹窗 + 删除;已被核销申请引用的方式不可删除、不可修改 `code`(未被引用时可改),可停用。 +- 新增「员工代收款账单」页面:应收/已核销/未核销/待处理账单统计 + 列表 + 详情;普通员工仅见本人账单,超级管理员可见全部;存在审批中申请时账单不可关闭,关闭必须填写原因。 +- 新增「核销申请」页面:列表 + 详情(含分摊账单、付款凭证、付款信息与全部审批尝试记录)+ 创建/重新提交弹窗;一笔线下收款可核销 1~N 张账单,填写付款金额、付款方名称、付款时间、外部交易流水号、选择收款方式并上传付款凭证;提交后自动发起企微审批;仅已驳回申请可修改并重新提交,重新提交生成新的审批实例且历史记录不被覆盖。 +- 扩展企业微信审批场景:新增 `employee_collection_approval` 业务类型,复用既有企微应用列表、模板控件同步与业务字段查询接口。 +- 调整订单创建:由平台账号操作、实际收款金额大于 0 且非赠送的线下订单会生成员工代收款账单,该场景 `payment_voucher_key` 改为非必填,付款凭证改在核销申请中提交,字段结构保持不变。 +- 附件接口仅返回对象 Key,统一通过系统既有预签名下载接口展示。 +- **不实现后端接口、数据库、Worker、企微回调**;**不实现 H5/C 端页面**。 + +## Impact + +- Affected specs: `employee-collection` +- Affected code: `src/types/api/employeeCollection.ts`、`src/api/modules/employeeCollection.ts`、`src/views/finance/employee-collection/*`、`src/router/routes/asyncRoutes.ts`、`src/router/routesAlias.ts`、`src/config/constants/augustIteration.ts`、`src/types/api/wecom.ts`、`src/views/settings/wecom/scenes/index.vue`、`src/views/order-management/order-list/index.vue`、`src/locales/langs/{zh,en}.json` +- Dependencies: `docs/产品迭代8月份/员工代收款功能简介.md` +- Contract note: `docs/admin-openapi.yaml` 未随仓库提供,字段以需求文档描述的后端契约与创建订单接口的 OpenAPI 片段为准;类型集中在单一文件,联调时便于收敛。 diff --git a/openspec/changes/add-employee-collection/specs/employee-collection/spec.md b/openspec/changes/add-employee-collection/specs/employee-collection/spec.md new file mode 100644 index 0000000..970e70e --- /dev/null +++ b/openspec/changes/add-employee-collection/specs/employee-collection/spec.md @@ -0,0 +1,155 @@ +## ADDED Requirements + +### Requirement: 收款方式管理 +超级管理员 MUST 能够维护线下收款方式,包括名称、编码、排序、状态与备注;普通员工只能看到启用的收款方式。收款方式列表接口 MUST 返回 `{ items, page, size, total }`,并 MUST 支持 `enabled` 与 `keyword` 筛选。已被核销申请引用的收款方式 MUST NOT 被删除;引用后修改稳定编码 MUST 被后端拒绝,前端 MUST 展示错误提示。 + +#### Scenario: 超级管理员新增收款方式 +- **GIVEN** 超级管理员已登录并拥有收款方式新增权限 +- **WHEN** 其填写名称、唯一编码、排序、状态与备注并提交 +- **THEN** 系统 MUST 调用新增接口并在成功后刷新列表 +- **AND** 新增成功后 MUST 清空并关闭弹窗 + +#### Scenario: 删除被引用的收款方式 +- **GIVEN** 某收款方式已被业务引用 +- **WHEN** 超级管理员尝试删除该方式 +- **THEN** 前端 MUST 阻止删除或展示后端返回的业务错误 +- **AND** MUST 提示改为停用 + +#### Scenario: 修改收款方式编码 +- **GIVEN** 超级管理员打开编辑弹窗 +- **WHEN** 其修改稳定编码并提交 +- **THEN** 前端 MUST 提交最新编码 +- **AND** 若该方式已被核销申请引用,后端拒绝时前端 MUST 展示错误提示 + +### Requirement: 员工代收款账单统计 +账单页面 MUST 展示应收金额、已核销金额、未核销金额与待处理账单数量四项统计,数据 MUST 来自 `GET /api/admin/employee-collection-bills/statistics`,字段为 `receivable_total`、`received_total`、`unsettled_total`、`pending_bill_count`;前端 MUST NOT 通过遍历当前分页数据自行计算。 + +#### Scenario: 加载账单统计 +- **GIVEN** 用户进入员工代收款账单页面 +- **WHEN** 页面初始化或筛选条件变化 +- **THEN** 前端 MUST 调用账单统计接口 +- **AND** MUST 将后端返回的「分」按元格式化后展示应收、已核销与未核销金额 + +### Requirement: 员工代收款账单列表与详情 +账单列表响应 MUST 为 `{ items, total, page, size }`,前端 MUST 兼容 `items` / `list` / `records` 等列表字段。列表 MUST 支持按来源(`source_type`)、来源单号(`source_no`)、账单状态、客户/店铺(`customer_id`)与创建时间(`created_from` / `created_to`,`YYYY-MM-DD`)筛选,并展示账单编号、来源、关联单号、负责员工、客户/店铺、应收金额、已核销金额、未核销金额与状态。账单状态 MUST 为数字枚举:`0` 待核销、`1` 部分核销、`2` 已核销、`3` 已关闭,展示 MUST 优先使用后端 `status_name`。账单详情响应 MUST 为 `{ bill, refunds, allocations, applications }`:`refunds` MUST 展示退款金额、冲减应收、冲销前应收与处理结果,`applications` MUST 可展开查看该申请的审批尝试记录。 + +#### Scenario: 普通员工查看账单 +- **GIVEN** 普通员工已登录 +- **WHEN** 其打开账单列表 +- **THEN** 列表 MUST 只展示后端返回的本人账单数据 +- **AND** MUST NOT 展示仅超管可见的操作入口 + +#### Scenario: 打开账单详情 +- **GIVEN** 用户拥有账单详情权限 +- **WHEN** 其点击账单号或详情操作 +- **THEN** 前端 MUST 跳转账单详情页并加载对应账单(详情数据取自 `data.bill`) +- **AND** 详情 MUST 展示退款冲销、核销分摊与关联核销申请 + +### Requirement: 关闭账单 +超级管理员 MUST 能够关闭账单,且关闭原因必填(最多 500 字符)。仅待核销或部分核销账单可关闭;存在审批中的核销申请(`approval_pending` 为真)时,账单 MUST NOT 被关闭。 + +#### Scenario: 存在审批中申请时关闭账单 +- **GIVEN** 账单存在审批中的核销申请 +- **WHEN** 用户查看该账单操作 +- **THEN** 关闭入口 MUST 被禁用或不可见 +- **AND** MUST 展示不可关闭的原因提示 + +#### Scenario: 关闭原因必填 +- **GIVEN** 账单可关闭 +- **WHEN** 超级管理员打开关闭弹窗并留空原因提交 +- **THEN** 前端 MUST 阻止提交并提示填写原因 + +### Requirement: 创建核销申请 +员工 MUST 能够使用一笔线下收款核销 1~N 张账单。创建申请 MUST 提交收款方式 `payment_method_id`、付款金额 `paid_amount`(分,大于 0)、付款方名称 `payer_name`、付款时间 `paid_at`(带时区 RFC3339)、外部交易流水号 `external_transaction_no`、付款凭证 `payment_voucher_keys`(1~5 个对象 Key)与账单分摊 `allocations`。超级管理员代办时 MUST 填写 `acting_reason`,本人办理 MUST NOT 提交该字段。提交成功后系统 MUST 自动发起企业微信审批。 + +#### Scenario: 一笔收款核销多张账单 +- **GIVEN** 用户选择了多张可核销账单 +- **WHEN** 其录入各账单核销金额、选择收款方式并填写付款事实后提交 +- **THEN** 前端 MUST 校验各账单核销金额大于 0 且不超过账单未核销余额 +- **AND** MUST 以 `allocations: [{ bill_id, amount }]` 提交账单分摊明细 + +#### Scenario: 付款金额小于核销合计 +- **GIVEN** 用户已录入各账单核销金额 +- **WHEN** 其填写的付款金额小于核销合计即提交 +- **THEN** 前端 MUST 阻止提交并提示付款金额不能小于核销合计 + +#### Scenario: 缺少付款凭证 +- **GIVEN** 用户已选择账单与收款方式 +- **WHEN** 其未上传任何付款凭证即提交 +- **THEN** 前端 MUST 阻止提交并提示上传付款凭证 + +#### Scenario: 缺少付款事实 +- **GIVEN** 用户已选择账单与收款方式 +- **WHEN** 其未填写付款方名称、付款时间或外部交易流水号即提交 +- **THEN** 前端 MUST 阻止提交并提示补齐必填项 + +#### Scenario: 超管代办未填写原因 +- **GIVEN** 超级管理员以代办身份创建申请 +- **WHEN** 其未填写 `acting_reason` 即提交 +- **THEN** 前端 MUST 阻止提交并提示填写代办原因 + +#### Scenario: 企微审批场景未配置 +- **GIVEN** 企业微信审批场景尚未配置 +- **WHEN** 用户提交核销申请 +- **THEN** 前端 MUST 展示后端返回的 503 提示「企微审批场景未配置,请联系管理员」 +- **AND** MUST 保留用户已填写的内容且不产生申请数据 + +### Requirement: 核销申请列表与详情 +核销申请列表响应 MUST 为 `{ items, page, size, total }`,MUST 支持按状态、收款方式与创建时间筛选;列表项 MUST 包含 `payment_method_name`、`paid_amount`、`status`、`status_name` 与 `created_at`,状态 MUST 优先展示后端 `status_name`。申请详情响应 MUST 为 `{ application, allocations, attempts }`;`attempts` MUST 按提交顺序展示全部审批尝试记录,包含付款金额、付款方、流水号、付款凭证与审批意见。 + +#### Scenario: 查看审批历史 +- **GIVEN** 申请存在多次审批尝试记录 +- **WHEN** 用户打开申请详情 +- **THEN** 详情 MUST 按提交顺序展示每次尝试的提交材料与审批状态 +- **AND** 历史材料 MUST NOT 因重新提交而被覆盖 + +### Requirement: 核销申请重新提交 +仅已驳回(`status` 为 `2`)的申请 MUST 允许修改并重新提交;重新提交 MUST 生成新的企业微信审批实例,且历史审批记录 MUST NOT 被覆盖。重新提交入口 MUST 位于核销申请列表的操作列,详情页 MUST 只读且 MUST NOT 展示业务操作按钮(仅保留返回导航)。 + +#### Scenario: 重新提交被驳回申请 +- **GIVEN** 申请状态为已驳回 +- **WHEN** 用户在核销申请列表点击「修改并重新提交」并修改账单分摊、收款方式、付款事实或付款凭证后提交 +- **THEN** 前端 MUST 调用修改接口重新提交 +- **AND** 成功后 MUST 刷新详情并展示新的审批实例状态 + +#### Scenario: 非驳回申请不可修改 +- **GIVEN** 申请处于审批中或已通过 +- **WHEN** 用户查看核销申请列表 +- **THEN** 修改并重新提交入口 MUST 不可见或不可用 + +#### Scenario: 详情页只读 +- **GIVEN** 用户打开账单详情或核销申请详情 +- **WHEN** 页面渲染完成 +- **THEN** 页面 MUST NOT 展示创建核销申请、关闭账单或修改并重新提交等业务操作按钮 +- **AND** MUST 只保留返回导航 + +### Requirement: 企业微信审批场景配置 +企业微信审批场景 MUST 支持 `employee_collection_approval` 业务类型,复用既有的应用列表、模板控件同步、业务字段查询与字段映射保存接口。 + +#### Scenario: 配置员工代收款审批场景 +- **GIVEN** 超级管理员打开企微审批场景页面 +- **WHEN** 其选择业务类型「员工代收款审批」 +- **THEN** 前端 MUST 使用 `employee_collection_approval` 调用模板同步、字段查询与保存接口 + +### Requirement: 附件预签名展示 +附件接口 MUST 只返回对象存储 Key(`payment_voucher_keys`);前端 MUST 通过系统既有的预签名下载接口获取实际访问地址后再展示。 + +#### Scenario: 查看付款凭证 +- **GIVEN** 申请包含付款凭证 Key +- **WHEN** 用户点击查看付款凭证 +- **THEN** 前端 MUST 先批量换取预签名地址 +- **AND** 图片 MUST 支持预览,非图片 MUST 支持查看或下载 + +### Requirement: 订单付款凭证规则调整 +由平台账号(`user_type` 为 1 或 2)操作、实际收款金额大于 0 且非赠送的线下订单会生成员工代收款账单;该场景 `payment_voucher_key` MUST 变为非必填,付款凭证改在核销申请中提交,订单字段结构 MUST 保持不变。其余线下订单 MUST 继续要求付款凭证。 + +#### Scenario: 线下订单生成代收款账单 +- **GIVEN** 当前登录账号为平台账号,所选套餐非赠送且实际收款金额大于 0,支付方式为线下支付 +- **WHEN** 用户创建该订单 +- **THEN** 前端 MUST 不再强制要求上传付款凭证 +- **AND** MUST 提示付款凭证将在核销申请中提交 + +#### Scenario: 赠送套餐或非平台账号的线下订单 +- **GIVEN** 所选套餐为赠送套餐,或当前账号非平台账号,或实际收款金额为 0 +- **WHEN** 用户以线下支付方式创建订单 +- **THEN** 前端 MUST 继续要求上传付款凭证 diff --git a/openspec/changes/add-employee-collection/tasks.md b/openspec/changes/add-employee-collection/tasks.md new file mode 100644 index 0000000..30dfb3a --- /dev/null +++ b/openspec/changes/add-employee-collection/tasks.md @@ -0,0 +1,48 @@ +## 1. Contract and API Types + +- [x] 1.1 新增 `src/types/api/employeeCollection.ts`:收款方式、账单、核销申请、统计、查询参数与请求/响应类型;列表统一使用 `items` / `total` / `page` / `size`。 +- [x] 1.2 定义账单状态(`0` 待核销 / `1` 部分核销 / `2` 已核销 / `3` 已关闭)与申请状态(`0` 审批中 / `1` 已通过 / `2` 已驳回 / `3` 已撤销或已关闭)数字枚举,并保留后端 `*_name` 展示字段。 +- [x] 1.3 金额字段以「分」传输;附件字段使用 `payment_voucher_keys` 对象存储 Key 数组,展示复用预签名下载接口。 +- [x] 1.4 新增 `src/api/modules/employeeCollection.ts` 并在 `src/api/modules/index.ts`、`src/types/api/index.ts` 导出。 +- [x] 1.5 按后端实际契约收敛字段:账单详情为 `{ bill, refunds, allocations, applications }`,核销申请详情为 `{ application, allocations, attempts }`,付款金额/付款方/付款时间/外部交易流水号以 `paid_amount` / `payer_name` / `paid_at` / `external_transaction_no` 提交。 + +## 2. Permissions, Routes, and Menu + +- [x] 2.1 新增 `src/config/constants/augustIteration.ts`,集中声明页面与按钮权限编码。 +- [x] 2.2 在 `src/router/routesAlias.ts` 与 `src/router/routes/asyncRoutes.ts` 的财务管理下新增账单、核销申请、收款方式路由。 +- [x] 2.3 在 `src/locales/langs/zh.json`、`en.json` 的 `menus.financialManagement` 下补充菜单文案。 + +## 3. Payment Methods Page + +- [x] 3.1 实现收款方式列表(名称、编码、排序、状态、备注、创建时间);接口返回 `{ items, page, size, total }`,复用 `ArtTableFullScreen` / `ArtSearchBar` / `ArtTableHeader` / `ArtTable`。 +- [x] 3.2 实现新增/编辑弹窗;编辑时允许提交 `code`,被核销申请引用时由后端拒绝并提示。 +- [x] 3.3 实现删除操作,被引用的方式给出提示并引导停用。 + +## 4. Bills Pages + +- [x] 4.1 实现账单统计卡片(应收、已核销、未核销、待处理账单数量),数据来自 `GET /api/admin/employee-collection-bills/statistics`。 +- [x] 4.2 实现账单列表与筛选(来源、来源单号、状态、客户/店铺、创建时间范围),展示账单号、来源、关联单号、负责员工、客户/店铺、应收/已核销/未核销金额与状态。 +- [x] 4.3 实现账单详情,展示账单信息、退款冲销、核销分摊与关联核销申请(关联申请可展开查看审批尝试记录)。 +- [x] 4.4 实现超管关闭账单弹窗,关闭原因必填;存在审批中申请时禁用关闭并给出说明。 +- [x] 4.5 列表「创建核销申请」入口按权限与账单状态控制可用性。 + +## 5. Applications Pages + +- [x] 5.1 实现核销申请列表与筛选(状态、收款方式、创建时间),展示申请编号、收款方式、付款金额、状态与提交时间。 +- [x] 5.2 实现创建/重新提交弹窗:选择 1~N 张可核销账单、按账单录入分摊金额、填写付款金额/付款方名称/付款时间/外部交易流水号、选择收款方式并上传付款凭证。 +- [x] 5.3 超管代办时必须填写 `acting_reason`,否则禁止提交。 +- [x] 5.4 实现申请详情,展示申请信息、分摊账单、付款凭证与全部审批尝试记录;历史记录只读,不被重新提交覆盖。 +- [x] 5.5 仅已驳回申请展示「修改并重新提交」,提交成功后生成新的审批实例并刷新详情。 +- [x] 5.6 统一处理 503「企微审批场景未配置」等业务错误,保留用户已填内容。 + +## 6. WeCom Scene and Order Rule + +- [x] 6.1 扩展 `WecomBusinessType` 增加 `employee_collection_approval`,并在企微审批场景页面新增可选业务类型。 +- [x] 6.2 场景保存复用既有 `inspectTemplate`、`getBusinessFields`、`saveScene` 接口,无需新增企微接口。 +- [x] 6.3 调整线下订单创建:平台账号操作、非赠送且实际收款金额大于 0 时 `payment_voucher_key` 非必填,字段结构不变。 + +## 7. Verification + +- [x] 7.1 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`,确保类型与编码通过。 +- [ ] 7.2 校验普通员工与超管的菜单、列与操作可见性差异。 +- [ ] 7.3 校验金额分/元转换、附件预签名展示与 503 错误兜底。 diff --git a/openspec/changes/update-agent-self-recharge-and-offline-approval/design.md b/openspec/changes/update-agent-self-recharge-and-offline-approval/design.md new file mode 100644 index 0000000..292efe4 --- /dev/null +++ b/openspec/changes/update-agent-self-recharge-and-offline-approval/design.md @@ -0,0 +1,92 @@ +## Context + +AUG26-017 给代理预存款带来两类变化:在线自充的收款方式从「由支付配置自动决定」改为「超管配置允许范围」;线下预存款审批从「金额 + 支付凭证」补齐为「收款方式 + 交易流水号 + 其他凭证」。后端接口边界已经确认: + +| 用途 | 接口 | +|---|---| +| 在线可用方式 | `GET /api/admin/agent-self-recharge-payment-methods`,代理/平台可用,超管 403;旧接口 `GET /api/admin/agent-recharges/payment-methods` 保留但前端不再调用 | +| 允许范围读取 | `GET /api/admin/system-configs`(`module`、`page`、`page_size`,返回 `list` / `page` / `page_size` / `total`) | +| 允许范围写入 | `PUT /api/admin/system-configs/{key}`,请求体 `{ key, value }`,`value` 为字符串化配置值 | +| 交易流水号识别 | `POST /api/admin/agent-recharges/payment-voucher-ocr`,请求 `{ payment_voucher_key }`,响应 `{ external_transaction_no }` | +| 线下创建 | `POST /api/admin/agent-recharges`,`payment_method=offline` 时新增 `offline_payment_method_id`、`external_transaction_no`、`other_voucher_key` | +| 列表与详情 | `GET /api/admin/agent-recharges`、`GET /api/admin/agent-recharges/{id}` 新增 5 个响应字段 | + +## Goals / Non-Goals + +**Goals** + +- 在线充值只展示后端返回的实际可用方式,并优雅处理空列表。 +- 超管可在既有系统配置页面维护允许范围,无需新增接口或页面。 +- 线下预存款申请可提交收款方式、交易流水号与其他凭证,并支持 OCR 预填流水号。 +- 列表与详情正确展示两类交易号与收款方式快照。 + +**Non-Goals** + +- 不新增「代理自充设置」独立页面与专用配置接口。 +- 不实现允许范围的审计查询接口(后端记录操作者、前后值与时间,前端不查询)。 +- 不实现后端接口、商户池交集计算、企微审批回调。 +- 不实现 H5/C 端在线充值,不为企业账号做分支。 + +## Decisions + +### 允许范围沿用受控系统配置 + +允许范围配置项的读取与写入统一走系统配置:读取 `GET /api/admin/system-configs`,写入 `PUT /api/admin/system-configs/{key}`。系统配置页已是元数据驱动的通用渲染器(`value_type` 决定控件、`enum_values` 决定枚举选项、`readonly` 与 `sensitive` 决定保护策略),因此后端的允许范围配置项注册后即可在页面中展示与编辑。 + +前端 MUST NOT 硬编码该配置项的 `config_key`,也 MUST NOT 新增专用设置写接口。该配置项归属 `c2b.payment` 模块,属于现有 `SystemConfigModule` 取值,无需扩展模块枚举。 + +允许范围的枚举取值为 `wechat_only`(仅微信支付)、`alipay_only`(仅支付宝支付)、`both`(同时支持微信与支付宝);系统配置页展示与选择时使用中文标签,未命中映射时回退展示原值。 + +超管入口使用一个「代理自充设置」菜单项,跳转到系统配置页面并带 `module=c2b.payment` 过滤条件;系统配置页面除了既有的 `config_key` query,还需支持从 `route.query.module` 初始化模块筛选,保证跳转后列表已按模块收敛。 + +### 在线可用方式 + +| 项 | 取值 | +|---|---| +| 接口 | `GET /api/admin/agent-self-recharge-payment-methods` | +| 响应 | `{ methods: (wechat \| alipay)[], min_amount, max_amount }` | +| 空列表 | 只提示「当前暂无可用的在线支付方式」,禁用提交,不解释被限制还是无可用商户 | +| 超管 | 该接口对超管返回 403,前端在超管视角不请求它,仅通过系统配置查看允许范围 | +| 存量单 | 配置变更不影响已创建的待支付单,前端不在配置变更后刷新待支付单 | + +金额上下限优先使用接口返回的 `min_amount` / `max_amount`(单位分),接口未返回时回退既有常量。 + +### 线下预存款创建字段 + +| 字段 | 必填 | 说明 | +|---|---|---| +| `offline_payment_method_id` | 是 | 取自 `GET /api/admin/employee-collection-payment-methods` 的启用项(`enabled=true`,`page_size=100`) | +| `external_transaction_no` | 是 | 交易流水号,OCR 预填后人工确认,可编辑;前端不做重复校验 | +| `other_voucher_key` | 否 | 其他凭证对象键数组,最多 5 个 | +| `payment_voucher_key` | 是 | 支付凭证,至少 1 个,与其他凭证分开提交 | + +历史线下单的收款方式只用 `offline_payment_method_code` / `offline_payment_method_name` 快照展示,不用 `offline_payment_method_id` 反查字典当前值。 + +列表筛选保持后端已有参数集合(`page`、`page_size`、`shop_id`、`status`、`recharge_source`、`start_date`、`end_date`),不新增交易流水号筛选,交易流水号只做展示。 + +### OCR 预填交互 + +- 入口:线下代充弹窗支付凭证区的「识别凭证」按钮,取已上传的第一个 `payment_voucher_key`;未上传凭证时禁用。 +- 请求:`POST /api/admin/agent-recharges/payment-voucher-ocr`;通过 `BaseService.post` 的第三个参数传 `{ timeout: 30000 }`,请求期间按钮与交易流水号字段展示 loading 并防重复点击。 +- 成功:把 `external_transaction_no` 写入交易流水号输入框,字段保持可编辑并提示对照凭证核对。 +- 失败(凭证不是图片、对象不存在、识别服务异常):只提示,不清空已填内容、不阻断手工填写与提交。 +- 只预填交易流水号,金额、付款人、付款时间、备注一律不预填。 + +### 凭证上传类型 + +取上传地址时必须显式声明 `content_type` 为 `image/jpeg`,否则 OCR 会以「不是图片」直接拒绝。`VoucherUpload` 新增可选 `contentType` prop,代理充值的支付凭证与其他凭证固定传 `image/jpeg`,并把它透传给 `StorageService.getUploadUrl` 与 `StorageService.uploadFile`。 + +### 交易流水号展示 + +`payment_transaction_id` 是在线渠道返回的权威交易号,只有在线单有值;`external_transaction_no` 是线下人工申报的交易流水号。两者独立展示、互不覆盖:在线单只展示前者,线下单只展示后者。 + +### 详情页保持只读 + +代理充值详情页只做信息展示,顶部仅保留返回导航;本次新增的交易流水号、收款方式与其他凭证都只读呈现,不引入任何业务操作按钮。创建、确认线下充值、驳回等操作入口仍留在列表页操作列。 + +## Risks / Trade-offs + +- **配置 Key 由后端注册决定**:前端不硬编码,配置项按 `c2b.payment` 模块渲染;若后端最终调整模块归属,只需同步调整菜单跳转的 `module` 参数。 +- **OCR 端到端约 15 至 16 秒**:必须配置不低于 30 秒的超时并展示 loading,否则用户容易重复点击。 +- **强制声明 `image/jpeg`**:按需求文档要求统一声明为 `image/jpeg`,上传非 JPEG 图片时以声明类型为准。 +- **收款方式字典项被引用后会冻结**:展示历史单依赖快照字段,避免字典改名或停用导致历史数据展示漂移。 diff --git a/openspec/changes/update-agent-self-recharge-and-offline-approval/proposal.md b/openspec/changes/update-agent-self-recharge-and-offline-approval/proposal.md new file mode 100644 index 0000000..31995d0 --- /dev/null +++ b/openspec/changes/update-agent-self-recharge-and-offline-approval/proposal.md @@ -0,0 +1,36 @@ +# Change: 代理自充收款方式配置与线下预存款审批字段 + +## Why + +8 月迭代 AUG26-017(对应 PRD-008-021 与 PRD-008-013)包含两项要求:代理在线自充的收款方式由超级管理员维护「允许范围」(仅微信 / 仅支付宝 / 同时支持),代理实际可用方式取允许范围与当前可用商户方式的交集,交集为空时不允许创建在线充值单;线下预存款审批需要补齐「收款方式」「交易流水号」「其他凭证」,其中交易流水号支持从付款凭证 OCR 预填。 + +后台管理端现状与该要求有差距:在线充值直接调用旧接口 `GET /api/admin/agent-recharges/payment-methods`,线下代充表单只有店铺、支付凭证与运营备注,充值列表与详情也没有交易流水号、线下收款方式快照与其他凭证字段。 + +本次按后端实际 OpenAPI 契约对齐:可用方式改读 `GET /api/admin/agent-self-recharge-payment-methods`;允许范围的读取与写入沿用受控系统配置 `GET /api/admin/system-configs` 与 `PUT /api/admin/system-configs/{key}`;交易流水号识别使用 `POST /api/admin/agent-recharges/payment-voucher-ocr`。 + +## What Changes + +- 在线充值可用支付方式改由 `GET /api/admin/agent-self-recharge-payment-methods` 提供,读取 `methods`、`min_amount`、`max_amount`;旧接口 `GET /api/admin/agent-recharges/payment-methods` 保留但前端不再调用。 +- `methods` 为空时只提示「当前暂无可用的在线支付方式」并禁用提交,不解释是被允许范围限制还是无可用商户。 +- 代理自充允许范围不新增专用接口与专用页面:配置项归属 `c2b.payment` 模块,超管沿用「系统配置」页面,通过 `GET /api/admin/system-configs` 读取、`PUT /api/admin/system-configs/{key}` 提交字符串化 `value`;前端不硬编码配置 Key,允许范围对代理与平台账号不可见。 +- 新增「代理自充设置」菜单(仅超级管理员),跳转到系统配置页面并带 `module=c2b.payment` 过滤条件;系统配置页面支持从路由 query 初始化模块筛选。 +- 线下预存款创建弹窗新增:收款方式(数据源 `GET /api/admin/employee-collection-payment-methods` 的启用项,提交 `offline_payment_method_id`)、交易流水号(必填、可编辑)、其他凭证(可选,最多 5 个 `other_voucher_key`);支付凭证 `payment_voucher_key` 仍必填且至少 1 个,与其他凭证分开提交。 +- 新增付款凭证 OCR 预填:`POST /api/admin/agent-recharges/payment-voucher-ocr` 请求 `{ payment_voucher_key }`、响应只有 `{ external_transaction_no }`;结果只作预填且始终可编辑,识别失败不阻断人工填写与提交,请求超时不低于 30 秒并展示 loading。 +- 充值列表与详情展示新增字段:`external_transaction_no`、`offline_payment_method_id`、`offline_payment_method_code`、`offline_payment_method_name`、`other_voucher_key`;线下单收款方式使用编码/名称快照展示,不用 `id` 反查字典当前值。 +- 代理充值列表不新增交易流水号筛选:`GET /api/admin/agent-recharges` 未提供该查询参数,交易流水号仅做展示。 +- 付款凭证与其他凭证获取上传地址时显式声明 `content_type` 为 `image/jpeg`。 +- 交易流水号 `external_transaction_no` 与在线渠道 `payment_transaction_id` 独立展示、互不覆盖;前端不做交易流水号重复校验。 +- 不实现后端接口、商户池逻辑、企微审批回调与 H5/C 端页面。 + +## Impact + +- Affected specs: `agent-recharge`、`system-config-management` +- Affected code: + - `src/types/api/agentRecharge.ts`、`src/api/modules/agentRecharge.ts`、`src/types/api/index.ts` + - `src/views/finance/agent-recharge/index.vue`、`src/views/finance/agent-recharge/detail.vue` + - `src/views/settings/system-configs/index.vue`、`src/types/api/systemConfig.ts` + - `src/router/routesAlias.ts`、`src/router/routes/asyncRoutes.ts` + - `src/components/business/VoucherUpload.vue` + - `src/locales/langs/{zh,en}.json` +- Dependencies: `docs/产品迭代8月份/代理.md` +- Out of scope: 代理自充设置独立页面、允许范围审计查询接口、企业账号分支、H5/C 端在线充值。 diff --git a/openspec/changes/update-agent-self-recharge-and-offline-approval/specs/agent-recharge/spec.md b/openspec/changes/update-agent-self-recharge-and-offline-approval/specs/agent-recharge/spec.md new file mode 100644 index 0000000..85152a6 --- /dev/null +++ b/openspec/changes/update-agent-self-recharge-and-offline-approval/specs/agent-recharge/spec.md @@ -0,0 +1,132 @@ +## ADDED Requirements + +### Requirement: 代理在线自充可用收款方式 + +前端 SHALL 通过 `GET /api/admin/agent-self-recharge-payment-methods` 获取代理在线自充的可用支付方式与金额范围,并在该接口返回空 `methods` 时禁止创建在线充值单。 + +#### Scenario: 读取可用支付方式 + +- **WHEN** 平台账号或代理账号进入代理充值页面并打开在线充值弹窗 +- **THEN** 前端 MUST 调用 `GET /api/admin/agent-self-recharge-payment-methods` +- **AND** 前端 MUST 使用响应 `methods` 渲染可选的微信与支付宝方式 +- **AND** 前端 MUST 使用响应 `min_amount` 与 `max_amount` 作为金额上下限 +- **AND** 前端 MUST NOT 再调用 `GET /api/admin/agent-recharges/payment-methods` + +#### Scenario: 可用方式为空 + +- **GIVEN** `GET /api/admin/agent-self-recharge-payment-methods` 返回空 `methods` +- **WHEN** 用户打开在线充值弹窗 +- **THEN** 前端 MUST 提示「当前暂无可用的在线支付方式」 +- **AND** 前端 MUST 禁用在线充值提交 +- **AND** 前端 MUST NOT 解释为空的原因是被允许范围限制还是无可用商户 +- **AND** 前端 MUST NOT 因空列表报错或阻断页面其余功能 + +#### Scenario: 超级管理员不通过该接口读取允许范围 + +- **WHEN** 当前登录账号为超级管理员 +- **THEN** 前端 MUST NOT 为读取允许范围调用 `GET /api/admin/agent-self-recharge-payment-methods` +- **AND** 前端 MUST 统一处理该接口对超级管理员返回的 403 + +#### Scenario: 允许范围变更不影响存量待支付单 + +- **WHEN** 允许范围配置发生变更 +- **THEN** 前端 MUST NOT 刷新或改写已创建待支付充值单的支付方式与商户信息 + +### Requirement: 线下预存款申请字段 + +线下预存款创建请求 SHALL 在 `payment_method` 为 `offline` 时提交 `offline_payment_method_id`、`external_transaction_no` 与可选的 `other_voucher_key`,并与既有的 `payment_voucher_key` 分开提交。 + +#### Scenario: 提交线下预存款申请 + +- **GIVEN** 用户以平台账号或超级管理员身份打开线下代充弹窗 +- **WHEN** 用户提交申请 +- **THEN** 请求体 MUST 包含 `amount`、`payment_method` 为 `offline` 与 `shop_id` +- **AND** MUST 包含必填 `offline_payment_method_id`,取值来自 `GET /api/admin/employee-collection-payment-methods` 的启用项 +- **AND** MUST 包含必填 `external_transaction_no` +- **AND** MUST 包含至少 1 个 `payment_voucher_key` +- **AND** MAY 包含最多 5 个 `other_voucher_key` + +#### Scenario: 支付凭证与其他凭证分开提交 + +- **WHEN** 用户上传支付凭证与其他凭证 +- **THEN** 支付凭证 MUST 通过 `payment_voucher_key` 提交 +- **AND** 其他凭证 MUST 通过 `other_voucher_key` 提交 +- **AND** 前端 MUST NOT 将两类凭证合并到同一字段 + +#### Scenario: 交易流水号与在线交易号互不覆盖 + +- **WHEN** 前端提交或展示线下预存款申请 +- **THEN** `external_transaction_no` MUST 只作为线下人工申报的交易流水号 +- **AND** 前端 MUST NOT 用 `payment_transaction_id` 覆盖或替代 `external_transaction_no` +- **AND** 前端 MUST NOT 对 `external_transaction_no` 做重复性校验 + +### Requirement: 付款凭证交易流水号识别预填 + +前端 SHALL 通过 `POST /api/admin/agent-recharges/payment-voucher-ocr` 以已上传的付款凭证对象键换取交易流水号预填值,识别失败 MUST NOT 阻断人工填写与提交。 + +#### Scenario: 识别成功预填交易流水号 + +- **GIVEN** 线下代充弹窗已上传至少 1 个付款凭证 +- **WHEN** 用户点击识别凭证 +- **THEN** 前端 MUST 调用 `POST /api/admin/agent-recharges/payment-voucher-ocr`,请求体为 `{ payment_voucher_key }` +- **AND** 前端 MUST 将响应 `external_transaction_no` 写入交易流水号输入框 +- **AND** 交易流水号输入框 MUST 保持可编辑并提示用户对照凭证核对 + +#### Scenario: 识别失败不阻断提交 + +- **GIVEN** OCR 返回失败,包括凭证不是图片、对象不存在或识别服务异常 +- **WHEN** 用户点击识别凭证 +- **THEN** 前端 MUST 展示失败提示 +- **AND** 前端 MUST NOT 阻断用户手动填写交易流水号并提交 +- **AND** 前端 MUST NOT 清空用户已填写内容 + +#### Scenario: 识别请求展示加载态并使用足够超时 + +- **WHEN** 前端发起 OCR 请求 +- **THEN** 前端 MUST 在请求期间展示 loading 并防止重复提交 +- **AND** 请求超时 MUST NOT 低于 30 秒 + +#### Scenario: 只预填交易流水号 + +- **WHEN** OCR 识别成功 +- **THEN** 前端 MUST 只预填 `external_transaction_no` +- **AND** 前端 MUST NOT 预填金额、付款人、付款时间或备注 + +### Requirement: 代理充值交易流水号与收款方式展示 + +代理充值列表与详情 SHALL 展示线下预存款的 `external_transaction_no` 与收款方式快照,在线充值只展示 `payment_transaction_id`。 + +#### Scenario: 列表展示线下补充字段 + +- **WHEN** 列表返回 `payment_method` 为 `offline` 的充值单 +- **THEN** 列表 MUST 展示 `external_transaction_no` +- **AND** 列表 MUST 使用 `offline_payment_method_name` 展示收款方式 + +#### Scenario: 详情展示收款方式快照 + +- **WHEN** 详情返回历史线下充值单 +- **THEN** 前端 MUST 使用 `offline_payment_method_code` 与 `offline_payment_method_name` 展示收款方式 +- **AND** 前端 MUST NOT 用 `offline_payment_method_id` 反查字典当前值 +- **AND** 前端 MUST 展示 `other_voucher_key` 对应的其他凭证 + +#### Scenario: 两类交易号独立展示 + +- **WHEN** 详情展示在线充值单 +- **THEN** 前端 MUST 只展示 `payment_transaction_id` +- **AND** 前端 MUST NOT 把 `external_transaction_no` 当作在线渠道交易号展示 + +#### Scenario: 列表不新增交易流水号筛选 + +- **WHEN** 前端实现代理充值列表筛选 +- **THEN** 前端 MUST 只使用 `page`、`page_size`、`shop_id`、`status`、`recharge_source`、`start_date` 与 `end_date` +- **AND** 前端 MUST NOT 新增交易流水号筛选条件 + +### Requirement: 付款凭证上传类型声明 + +代理充值付款凭证与其他凭证在获取上传地址时 SHALL 显式声明 `content_type` 为 `image/jpeg`。 + +#### Scenario: 上传地址声明图片类型 + +- **WHEN** 前端为代理充值线下凭证请求上传地址 +- **THEN** 请求 MUST 携带 `content_type` 为 `image/jpeg` +- **AND** 上传请求 MUST 使用相同的内容类型 diff --git a/openspec/changes/update-agent-self-recharge-and-offline-approval/specs/system-config-management/spec.md b/openspec/changes/update-agent-self-recharge-and-offline-approval/specs/system-config-management/spec.md new file mode 100644 index 0000000..63732c0 --- /dev/null +++ b/openspec/changes/update-agent-self-recharge-and-offline-approval/specs/system-config-management/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: 代理自充允许范围配置项 + +超级管理员维护的代理自充允许范围 SHALL 作为受控系统配置项,通过既有系统配置页面读取与更新,前端 MUST NOT 新增专用接口或专用页面。 + +#### Scenario: 通过系统配置读取允许范围 + +- **WHEN** 超级管理员进入系统配置页面 +- **THEN** 前端 MUST 调用 `GET /api/admin/system-configs` 获取配置列表 +- **AND** 后端注册的代理自充允许范围配置项 MUST 依据返回元数据渲染 +- **AND** 前端 MUST NOT 为读取允许范围调用专用接口 + +#### Scenario: 以枚举控件编辑允许范围 + +- **GIVEN** 允许范围配置项返回非空 `enum_values` +- **WHEN** 超级管理员编辑该项 +- **THEN** 页面 MUST 使用枚举选择控件渲染允许范围 +- **AND** 页面 MUST 以字符串化 `value` 调用 `PUT /api/admin/system-configs/{key}` +- **AND** 页面 MUST 在提交前校验取值属于 `enum_values` +- **AND** 允许范围枚举值 MUST 以中文标签展示,包括仅微信支付、仅支付宝支付与同时支持微信与支付宝 + +#### Scenario: 不硬编码配置 Key + +- **WHEN** 前端实现允许范围配置项的展示与编辑 +- **THEN** 前端 MUST NOT 硬编码该配置项的 `config_key` +- **AND** 前端 MUST 依据 `config_key`、`module`、`value_type`、`control` 与 `enum_values` 等元数据驱动渲染 +- **AND** 该配置项归属 `c2b.payment` 模块,前端 MUST NOT 扩展模块枚举取值 + +#### Scenario: 代理自充设置菜单带入模块筛选 + +- **GIVEN** 当前登录账号为超级管理员 +- **WHEN** 用户点击「代理自充设置」菜单 +- **THEN** 前端 MUST 跳转到系统配置页面并携带 `module=c2b.payment` +- **AND** 系统配置页面 MUST 使用该 query 初始化模块筛选并据此查询配置列表 +- **AND** 非超级管理员 MUST NOT 看到该菜单 + +#### Scenario: 允许范围对代理与平台不可见 + +- **WHEN** 当前登录账号不是超级管理员 +- **THEN** 前端 MUST NOT 向代理与平台账号展示允许范围配置的入口或当前值 +- **AND** 代理与平台账号 MUST 只能通过 `GET /api/admin/agent-self-recharge-payment-methods` 获取实际可用方式 diff --git a/openspec/changes/update-agent-self-recharge-and-offline-approval/tasks.md b/openspec/changes/update-agent-self-recharge-and-offline-approval/tasks.md new file mode 100644 index 0000000..6f3e14b --- /dev/null +++ b/openspec/changes/update-agent-self-recharge-and-offline-approval/tasks.md @@ -0,0 +1,55 @@ +## 1. Contract and API Types + +- [x] 1.1 `src/types/api/agentRecharge.ts`:`AgentRecharge` 新增 `external_transaction_no`、`offline_payment_method_id`、`offline_payment_method_code`、`offline_payment_method_name`、`other_voucher_key`(`string[] | null`),保持 `payment_transaction_id` 与 `payment_voucher_key` 语义不变。 +- [x] 1.2 扩展 `CreateAgentRechargeOfflineRequest`:新增必填 `offline_payment_method_id`、必填 `external_transaction_no`、可选 `other_voucher_key`(最多 5 个)。 +- [x] 1.3 新增 `AgentRechargePaymentVoucherOcrRequest { payment_voucher_key }` 与 `AgentRechargePaymentVoucherOcrResponse { external_transaction_no }`。 +- [x] 1.4 在 `src/types/api/index.ts` 导出新增类型。 + +## 2. Agent Recharge Service + +- [x] 2.1 `src/api/modules/agentRecharge.ts` 新增 `getSelfRechargePaymentMethods()`,请求 `GET /api/admin/agent-self-recharge-payment-methods`。 +- [x] 2.2 新增 `recognizePaymentVoucher(data)`,请求 `POST /api/admin/agent-recharges/payment-voucher-ocr`,超时不低于 30000 毫秒。 +- [x] 2.3 保留旧 `getPaymentMethods()` 方法,但页面不再调用。 + +## 3. Online Recharge Available Methods + +- [x] 3.1 在线充值弹窗改用 `getSelfRechargePaymentMethods()` 读取 `methods` / `min_amount` / `max_amount`,并把 `wechat`、`alipay` 映射为微信、支付宝选项。 +- [x] 3.2 `methods` 为空时提示「当前暂无可用的在线支付方式」并禁用提交,不解释原因。 +- [x] 3.3 超管视角不请求该接口(403 兜底);允许范围变更后不刷新已创建的待支付单。 + +## 4. Super Admin Allow-Range Config + +- [x] 4.1 允许范围沿用系统配置页通用渲染:`GET /api/admin/system-configs` 读取、`PUT /api/admin/system-configs/{key}` 提交字符串化 `value`;不新增专用接口与专用页面。 +- [x] 4.2 `wechat_only` / `alipay_only` / `both` 在系统配置页展示与选择时显示中文标签,未命中映射时回退原值。 +- [x] 4.3 新增「代理自充设置」菜单(仅超级管理员),跳转到系统配置页面并带 `module=c2b.payment`;系统配置页支持从 `route.query.module` 初始化模块筛选。 +- [x] 4.4 在 `zh.json`、`en.json` 的 `menus.settings` 下补充 `agentSelfRecharge` 中英文文案,并清理系统配置页预存的空样式块。 + +## 5. Offline Pre-deposit Create Dialog + +- [x] 5.1 新增「收款方式」下拉,数据来自 `GET /api/admin/employee-collection-payment-methods`(`enabled=true`、`page_size=100`),必填,提交 `offline_payment_method_id`。 +- [x] 5.2 新增「交易流水号」输入(必填、可编辑),并提示对照凭证核对。 +- [x] 5.3 新增「其他凭证」上传(可选,最多 5 个),提交 `other_voucher_key`;支付凭证仍必填且至少 1 个,两者分开提交。 +- [x] 5.4 `VoucherUpload` 新增可选 `contentType` prop 并透传给 `getUploadUrl` 与 `uploadFile`;代理充值线下凭证固定传 `image/jpeg`。 +- [x] 5.5 线下提交体按契约组装:`{ amount, payment_method: offline, shop_id, offline_payment_method_id, external_transaction_no, payment_voucher_key, other_voucher_key, remark }`,未填写的可选字段不提交。 + +## 6. Voucher OCR Prefill + +- [x] 6.1 支付凭证区新增「识别凭证」按钮,取第一个已上传凭证 Key;未上传凭证时禁用。 +- [x] 6.2 识别期间展示 loading(按钮与交易流水号字段)并防重复点击,超时不低于 30 秒。 +- [x] 6.3 识别成功写入 `external_transaction_no`,字段保持可编辑;失败只提示,不清空已填内容、不阻断提交。 +- [x] 6.4 不预填金额、付款人、付款时间与备注。 + +## 7. List and Detail Display + +- [x] 7.1 列表新增线下交易流水号与线下收款方式(`offline_payment_method_name` 快照)展示,仅线下单展示。 +- [x] 7.2 详情新增交易流水号、收款方式(`offline_payment_method_code` 与 `offline_payment_method_name` 快照)与其他凭证预览;在线单只展示 `payment_transaction_id`。 +- [x] 7.3 不使用 `offline_payment_method_id` 反查字典当前值,也不做交易流水号重复校验。 +- [x] 7.4 列表不新增交易流水号筛选,保持后端已有查询参数集合不变。 + +## 8. Verification + +- [ ] 8.1 校验 `methods` 为空、超管调用新接口 403、允许范围变更不影响存量待支付单三种情况。 +- [ ] 8.2 校验 OCR 成功、失败、超时三条路径均不阻断人工填写与提交。 +- [ ] 8.3 校验线下单与在线单的两类交易号、收款方式快照与其他凭证展示正确。 +- [x] 8.4 运行 `npm run build`(含 `vue-tsc --noEmit`)与 `npm run check:encoding`。 +- [x] 8.5 运行 `openspec validate update-agent-self-recharge-and-offline-approval --strict`。 diff --git a/openspec/changes/update-payment-merchant-pool-optimization/design.md b/openspec/changes/update-payment-merchant-pool-optimization/design.md new file mode 100644 index 0000000..a2c4933 --- /dev/null +++ b/openspec/changes/update-payment-merchant-pool-optimization/design.md @@ -0,0 +1,54 @@ +# 商户池详情与商户凭证契约设计 + +## Context + +支付商户与商户池管理页已经上线,运营在使用时遇到三个问题:商户池详情入口和支付商户不一致且信息不完整、新增商户池直接报错、商户凭证缺少字段枚举与类型校验。本次优化只新增详情页、收敛成员顺序数据源,并在既有凭证写入表单上叠加契约校验,不改变接口路径与请求结构。 + +## Goals + +- 让商户池详情的入口和展示完整度与支付商户详情对齐。 +- 让新增/编辑商户池恢复可用,并且拖拽排序结果仍按顺序提交。 +- 让前端提交的商户凭证满足后端的字段枚举、必填键、值类型与商户标识约束。 + +## Non-Goals + +- 不改变“更换凭证”表单的交互方式与凭证值不回显约束。 +- 不新增后端接口。 + +## Decisions + +### 商户池详情复用支付商户详情的入口模式 + +支付商户详情已采用“列表点击名称 → 独立详情页”的模式,因此商户池改为同样方式:列表名称列渲染为可点击文本,点击后跳转 `/settings/payment-merchant-pools/pool-detail/:id`,并移除行操作里的“详情”抽屉。 + +详情页复用 `src/components/common/DetailPage.vue`,按“基本信息”“轮询配置”“运行状态”三组展示字段。创建时间、更新时间只在接口返回时渲染,避免出现空白字段行。 + +替代方案是保留抽屉并补齐字段;但两个详情入口不一致会让运营难以形成稳定预期,因此不采用。 + +### 成员商户名称由前端解析 + +详情接口只返回 `member_ids`。详情页在加载详情后按该商户池的 `payment_method` 拉取支付商户列表,把成员 ID 映射为商户名称展示,同时保留成员数量。解析不到名称时展示“未知商户”,不展示 `member_ids` 原始 ID,避免把内部标识暴露给运营。 + +### 成员顺序使用单一数据源 + +原实现同时监听 `form.member_ids` 和 `orderedMemberIds`,并在两个回调中互相赋新数组,形成“赋值 → 触发 → 再赋值”的无限循环,从而在新增商户池时触发 `Maximum recursive updates exceeded`。 + +修复方式是把成员顺序收敛为单一数据源:`orderedMemberIds` 改为基于 `form.member_ids` 的 `computed`(get 返回成员数组,set 写回成员数组)。`VueDraggable` 通过 `v-model` 触发 setter 写回 `form.member_ids`,不再存在互相触发的 watch。 + +### 凭证字段枚举固化在类型模块并在提交前校验 + +凭证必填键由后端契约按 `payment_method` 与 `provider_type` 组合给出,前端与该契约保持一致,因此把枚举定义在 `src/types/api/paymentMerchantPools.ts`:`PAYMENT_CREDENTIAL_FIELD_SPECS` 描述每个 `provider_type` 的必填键与可选键,`PAYMENT_CREDENTIAL_BOOLEAN_KEYS`、`PAYMENT_CREDENTIAL_INTEGER_KEYS` 描述 `ali_production`、`ali_pay_expire_minutes` 的值类型,`PAYMENT_MERCHANT_IDENTITY_KEYS` 描述商户标识必须一致的凭证字段。枚举之外的字段名一律拦截,避免提交必然被后端拒绝的请求。 + +`buildPaymentCredentials` 负责提交前校验并转换:拒绝不属于当前服务商的字段名、补齐必填键检查、把布尔字段与整数字段从输入框字符串转换为布尔值/数字、校验 `merchant_identity` 与对应凭证字段一致。表单继续使用“字段名 + 字段值”的通用编辑方式,只在分隔线下方展示当前服务商的必填/可选字段提示。 + +替代方案是把凭证表单改成按服务商渲染固定中文标签字段;该方案会改变既有交互与凭证只写约定,本次不采用。 + +### 凭证仅平台账号可读写 + +凭证读取与写入入口复用既有平台账号限制:`/settings/payment-merchant-pools` 及其详情路由都带 `allowedUserTypes: [1, 2]`,页面内 `canManage` 再按 `isPlatformAccount` 过滤操作。凭证值只在当前编辑会话内存中存在,提交、取消或关闭后清空,不进入 Pinia 持久化、浏览器存储、日志或错误上报。 + +## Risks and Trade-offs + +- 详情页与支付商户详情一样受 `allowedUserTypes: [1, 2]` 限制,代理与企业账号无法访问。 +- 商户池成员名称依赖支付商户列表接口,成员数量超过单页上限时可能解析不到名称,此时展示“未知商户”而不是原始 ID。 +- 凭证字段枚举与后端校验规则必须保持一致;后端新增字段时前端需要同步更新枚举,否则提交会被前端拦截。 diff --git a/openspec/changes/update-payment-merchant-pool-optimization/proposal.md b/openspec/changes/update-payment-merchant-pool-optimization/proposal.md new file mode 100644 index 0000000..5d04528 --- /dev/null +++ b/openspec/changes/update-payment-merchant-pool-optimization/proposal.md @@ -0,0 +1,48 @@ +# Change: 商户池详情与商户凭证契约 + +## Why + +`docs/产品迭代8月份/支付商户优化.md` 记录了支付商户与商户池管理页的三个问题,加上运营补充的凭证契约与权限要求,本次处理三件事: + +- 商户池详情与支付商户详情的入口不一致:支付商户是点击名称进入独立详情页,商户池却是行操作里的详情抽屉,且详情没有展示接口已返回的全部字段(例如 `time_period_started_at`),成员只给出数量。 +- 点击“新增商户池”时页面抛出 `Maximum recursive updates exceeded in component `,新增和编辑商户池流程完全不可用。 +- 商户凭证的字段契约此前只存在于接口文档:必填键由 `payment_method` 与 `provider_type` 组合决定,前端提交前没有字段枚举与类型校验,容易出现漏填必填键、`ali_production` / `ali_pay_expire_minutes` 以字符串提交、`merchant_identity` 与凭证字段不一致等会被后端拒绝的请求。 + +“更换凭证”表单的交互(手工逐行填写字段名与字段值、值不回显)按反馈保持原有行为,本次只在其上叠加字段契约校验。 + +## What Changes + +- 新增商户池详情页,并改为点击列表中商户池名称进入,与支付商户详情保持一致;移除列表行操作中的“详情”入口。 +- 商户池详情页展示详情接口返回的业务字段:商户池名称、支付方式、启停状态、成员数量、成员商户、轮询策略、统计周期、金额/笔数/时间阈值、时间起点、路由世代、备注,以及接口返回时的创建时间和更新时间。 +- 成员商户按支付商户名称展示,不展示 `member_ids` 原始 ID;名称无法解析时展示“未知商户”占位文案。 +- 合并商户池表单中 `form.member_ids` 与 `orderedMemberIds` 的双向 `watch` 为单一数据源,消除递归更新错误,同时保持拖拽排序结果按顺序提交。 +- 将商户凭证字段枚举(各 `payment_method` + `provider_type` 组合的必填键与可选键)固化到前端类型模块,并在提交前校验字段枚举、必填键、值类型(布尔/整数/字符串)与 `merchant_identity` 一致性。 +- 凭证写入表单展示当前服务商组合的必填/可选字段提示,降低漏填必填键的概率。 +- 明确凭证访问控制:仅超级管理员(`user_type=1`)与平台用户(`user_type=2`)可读写商户凭证,凭证内容不进入日志、审计与支付快照。 + +## Impact + +- Affected specs: `payment-merchant-pool-management` +- Affected code: + - `src/views/settings/payment-merchant-pools/pool-detail.vue`(新增) + - `src/views/settings/payment-merchant-pools/components/PoolManagement.vue` + - `src/views/settings/payment-merchant-pools/components/MerchantManagement.vue` + - `src/types/api/paymentMerchantPools.ts` + - `src/router/routes/asyncRoutes.ts` + - `src/router/routesAlias.ts` + - `src/locales/langs/zh.json`、`src/locales/langs/en.json` +- Dependencies: + - 后端 `GET /api/admin/payment-merchant-pools/{id}` 返回 `member_ids`、`routing_epoch`、`time_period_started_at` 等字段。 + - 后端按 `payment_method` 与 `provider_type` 组合校验凭证必填键与值类型。 + - 成员商户名称由前端使用同 `payment_method` 的支付商户列表解析,不要求后端在商户池详情返回名称。 +- Compatibility: + - 不改变“更换凭证”表单的交互方式与凭证值不回显约束。 + - 不影响支付商户列表与支付商户详情页的信息结构。 + - 不改变商户池创建、更新、启用、停用的请求结构。 + +## Non-Goals + +- 不按服务商渲染带中文标签的固定凭证表单,仍保留“字段名 + 字段值”的通用编辑方式。 +- 不新增商户池详情、凭证校验之外的后端接口。 +- 不在商户池详情页展示 `id`、`member_ids` 等内部标识。 +- 不改动商户池轮询策略、成员排序规则和阈值换算规则。 diff --git a/openspec/changes/update-payment-merchant-pool-optimization/specs/payment-merchant-pool-management/spec.md b/openspec/changes/update-payment-merchant-pool-optimization/specs/payment-merchant-pool-management/spec.md new file mode 100644 index 0000000..af1dacd --- /dev/null +++ b/openspec/changes/update-payment-merchant-pool-optimization/specs/payment-merchant-pool-management/spec.md @@ -0,0 +1,155 @@ +## ADDED Requirements + +### Requirement: 商户池详情入口 + +系统 SHALL 与支付商户保持一致,以点击列表中商户池名称的方式进入独立详情页,并且不再在行操作中提供“详情”入口。 + +#### Scenario: 点击商户池名称进入详情页 + +- **GIVEN** 管理员位于支付商户与商户池管理页的“商户池”页签 +- **WHEN** 管理员点击列表中某个商户池名称 +- **THEN** 系统 MUST 跳转到该商户池的详情页 +- **AND** 详情页 MUST 通过 `GET /api/admin/payment-merchant-pools/{id}` 加载数据 +- **AND** 列表行操作 MUST NOT 再提供“详情”操作 + +#### Scenario: 商户池名称以可点击样式展示 + +- **GIVEN** 商户池列表加载成功 +- **WHEN** 页面渲染商户池名称列 +- **THEN** 商户池名称 MUST 以可点击样式展示 +- **AND** 点击名称 MUST NOT 触发表格行选中或其他行操作 + +#### Scenario: 非平台账号不能查看商户池详情 + +- **GIVEN** 当前登录账号的 `user_type` 不是 `1` 或 `2` +- **WHEN** 用户点击商户池名称或直接访问商户池详情页地址 +- **THEN** 系统 MUST NOT 展示商户池详情数据 +- **AND** 路由 MUST 按平台账号限制拦截该访问 + +### Requirement: 商户池详情信息完整 + +系统 SHALL 在商户池详情页展示详情接口返回的全部业务字段,并 SHALL 以支付商户名称展示成员商户,不得展示 `id` 或 `member_ids` 原始标识。 + +#### Scenario: 展示轮询策略与阈值字段 + +- **GIVEN** 管理员进入某个商户池详情页 +- **WHEN** 详情数据加载成功 +- **THEN** 详情 MUST 展示商户池名称、支付方式、启停状态、轮询策略、路由世代和备注 +- **AND** `strategy` 为 `amount` 或 `count` 时 MUST 展示统计周期和对应阈值 +- **AND** `strategy` 为 `time` 时 MUST 展示时间单位、时间长度和时间起点 + +#### Scenario: 成员商户按名称展示 + +- **GIVEN** 商户池详情返回 `member_ids` +- **WHEN** 页面展示成员信息 +- **THEN** 页面 MUST 展示成员商户名称和成员数量 +- **AND** 页面 MUST NOT 展示 `member_ids` 原始 ID +- **AND** 成员名称无法解析时 MUST 展示“未知商户”而不是 ID + +#### Scenario: 未返回的字段不渲染 + +- **GIVEN** 商户池详情接口未返回创建时间或更新时间 +- **WHEN** 页面展示商户池详情 +- **THEN** 页面 MUST NOT 渲染对应字段的空白行 + +### Requirement: 商户池成员顺序单一数据源 + +系统 SHALL 使用单一数据源维护商户池成员顺序,保证新增、编辑和拖拽排序成员时不会出现递归更新错误,并且提交顺序与页面展示顺序一致。 + +#### Scenario: 新增商户池不再递归更新 + +- **WHEN** 管理员点击“新增商户池” +- **THEN** 系统 MUST NOT 抛出 `Maximum recursive updates exceeded` +- **AND** 表单 MUST 正常打开并允许选择成员 + +#### Scenario: 拖拽后按展示顺序提交 + +- **GIVEN** 商户池表单中存在多个成员商户 +- **WHEN** 管理员拖拽调整成员顺序并提交 +- **THEN** `member_ids` MUST 按拖拽后的顺序提交 +- **AND** 再次编辑该商户池时 MUST 按提交顺序展示成员 + +### Requirement: 商户凭证字段枚举契约 + +系统 SHALL 按 `payment_method` 与 `provider_type` 组合枚举商户凭证的必填键与可选键,并在提交前校验字段枚举、必填键、值类型与商户标识一致性。 + +字段枚举以后端契约为准(键名与渠道配置字段一致),前端必须与后端保持一致,后端新增或调整字段时需同步更新前端枚举。`credentials` MUST 为扁平 JSON 对象,必填键为: + +- `payment_method=wechat`、`provider_type=wechat`:`wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url` +- `payment_method=wechat`、`provider_type=wechat_v2`:`wx_mch_id`、`wx_api_v2_key`、`wx_notify_url` +- `payment_method=wechat`、`provider_type=fuiou`:`fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url` +- `payment_method=alipay`、`provider_type=alipay`:`ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url` + +可选键为:`wechat` 可附 `wx_api_v2_key`;`alipay` 可附 `ali_production`(布尔,是否生产环境)与 `ali_pay_expire_minutes`(整数,支付过期分钟数)。除 `ali_production` 与 `ali_pay_expire_minutes` 外,凭证值 MUST 为字符串。`merchant_identity` MUST 分别等于 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id`。 + +#### Scenario: 提交微信直连凭证 + +- **GIVEN** 管理员提交 `payment_method=wechat`、`provider_type=wechat` 的商户凭证 +- **THEN** 请求 MUST 包含 `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url` +- **AND** 请求 MAY 附带 `wx_api_v2_key` +- **AND** `merchant_identity` MUST 等于 `wx_mch_id` + +#### Scenario: 提交微信直连 V2 凭证 + +- **GIVEN** 管理员提交 `payment_method=wechat`、`provider_type=wechat_v2` 的商户凭证 +- **THEN** 请求 MUST 包含 `wx_mch_id`、`wx_api_v2_key`、`wx_notify_url` +- **AND** `merchant_identity` MUST 等于 `wx_mch_id` + +#### Scenario: 提交富友凭证 + +- **GIVEN** 管理员提交 `payment_method=wechat`、`provider_type=fuiou` 的商户凭证 +- **THEN** 请求 MUST 包含 `fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url` +- **AND** `merchant_identity` MUST 等于 `fy_mchnt_cd` + +#### Scenario: 提交支付宝凭证 + +- **GIVEN** 管理员提交 `payment_method=alipay`、`provider_type=alipay` 的商户凭证 +- **THEN** 请求 MUST 包含 `ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url` +- **AND** 请求 MAY 附带 `ali_production` 与 `ali_pay_expire_minutes` +- **AND** `merchant_identity` MUST 等于 `ali_app_id` + +#### Scenario: 缺少必填字段时拒绝提交 + +- **WHEN** 提交的凭证缺少当前服务商组合的必填键 +- **THEN** 前端 MUST 阻止提交并提示缺失的凭证字段名 + +#### Scenario: 拒绝不属于当前服务商的凭证字段 + +- **WHEN** 提交的凭证包含必填键与可选键之外的字段名 +- **THEN** 前端 MUST 阻止提交并提示该字段不属于当前服务商支持的字段 + +#### Scenario: 校验凭证值类型 + +- **GIVEN** 凭证包含 `ali_production` 或 `ali_pay_expire_minutes` +- **WHEN** 管理员提交凭证 +- **THEN** `ali_production` MUST 以布尔值提交 +- **AND** `ali_pay_expire_minutes` MUST 以正整数提交 +- **AND** 其余凭证字段 MUST 以字符串提交 + +#### Scenario: 校验商户标识一致性 + +- **WHEN** `merchant_identity` 与 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id` 的值不一致 +- **THEN** 前端 MUST 阻止提交并提示商户标识必须与对应凭证字段一致 + +### Requirement: 商户凭证访问控制 + +系统 SHALL 仅允许超级管理员(`user_type=1`)与平台用户(`user_type=2`)读取和写入商户凭证,且凭证内容 MUST NOT 进入日志、审计记录或支付快照。 + +#### Scenario: 平台账号可读写凭证 + +- **GIVEN** 当前登录账号的 `user_type` 为 `1` 或 `2` +- **WHEN** 账号打开支付商户管理页、支付商户详情页或提交商户凭证 +- **THEN** 系统 MUST 允许读取凭证配置状态与写入凭证 + +#### Scenario: 非平台账号不可读写凭证 + +- **GIVEN** 当前登录账号的 `user_type` 为 `3` 或 `4` +- **WHEN** 账号访问支付商户管理页或支付商户详情页 +- **THEN** 系统 MUST 拒绝访问并 MUST NOT 返回凭证字段或凭证状态 +- **AND** 页面 MUST NOT 渲染凭证写入入口 + +#### Scenario: 凭证不写入日志与快照 + +- **WHEN** 创建、更新或删除支付商户成功或失败 +- **THEN** 凭证内容 MUST NOT 出现在浏览器存储、页面日志、错误上报或支付快照中 +- **AND** 页面 MUST 只展示“已配置/未配置”状态与 `credential_version` diff --git a/openspec/changes/update-payment-merchant-pool-optimization/tasks.md b/openspec/changes/update-payment-merchant-pool-optimization/tasks.md new file mode 100644 index 0000000..a4b8c33 --- /dev/null +++ b/openspec/changes/update-payment-merchant-pool-optimization/tasks.md @@ -0,0 +1,33 @@ +# Implementation Tasks + +## 1. 商户池详情入口 + +- [x] 1.1 新增商户池详情页 `pool-detail.vue`,复用 `DetailPage` 展示基本信息、轮询配置和运行状态。 +- [x] 1.2 新增 `/settings/payment-merchant-pools/pool-detail/:id` 路由与路由别名,并按平台账号(`allowedUserTypes: [1, 2]`)限制访问。 +- [x] 1.3 商户池列表名称列改为可点击文本,点击跳转对应详情页;移除行操作中的“详情”入口。 +- [x] 1.4 补充中英文路由标题文案 `menus.settings.detailsOfPaymentMerchantPool`。 + +## 2. 商户池详情内容 + +- [x] 2.1 详情页展示详情接口返回的全部业务字段:名称、支付方式、启停状态、成员数量、成员商户、轮询策略、统计周期、金额/笔数/时间阈值、时间起点、路由世代和备注。 +- [x] 2.2 成员按支付商户名称展示,不展示 `member_ids` 原始 ID,无法解析时展示“未知商户”。 +- [x] 2.3 创建时间、更新时间等字段仅在接口返回时渲染,避免空白字段行。 + +## 3. 商户池表单稳定性 + +- [x] 3.1 将 `orderedMemberIds` 改为基于 `form.member_ids` 的单一数据源,移除互相赋值的双向 watch。 +- [x] 3.2 验证新增、编辑、切换支付方式、添加成员、移除成员和拖拽排序后提交的成员顺序正确。 + - 新增/编辑/切换支付方式直接重置 `form.member_ids`;添加与移除成员在 `form.member_ids` 上增删;拖拽经 `VueDraggable` 的 `v-model` 写回同一数组,`buildPayload` 按数组顺序提交。 + +## 4. 商户凭证字段枚举与校验 + +- [x] 4.1 在 `src/types/api/paymentMerchantPools.ts` 固化凭证字段枚举:`PAYMENT_CREDENTIAL_FIELD_SPECS`(各 `provider_type` 的必填键与可选键)、`PAYMENT_CREDENTIAL_BOOLEAN_KEYS`、`PAYMENT_CREDENTIAL_INTEGER_KEYS`、`PAYMENT_MERCHANT_IDENTITY_KEYS`。 +- [x] 4.2 实现 `buildPaymentCredentials`:校验字段枚举与必填键,把 `ali_production` 转为布尔值、`ali_pay_expire_minutes` 转为正整数,其余字段保持字符串,并校验 `merchant_identity` 与对应凭证字段一致。 +- [x] 4.3 支付商户凭证写入表单接入该校验,并在“写入支付凭证”分隔线下方展示当前服务商组合的必填/可选字段提示。 +- [x] 4.4 凭证读写仍仅限超级管理员(`user_type=1`)与平台用户(`user_type=2`):路由 `allowedUserTypes` 与页面 `canManage` 双层限制,凭证值不进入持久化、日志与错误上报。 + +## 5. 验证 + +- [x] 5.1 运行 `eslint`、`vue-tsc --noEmit` 与 `vite build`,确认改动通过类型检查和构建。 + - `eslint`(改动文件)、`vue-tsc --noEmit` 均通过;`vite build --mode development` 成功产出 `MerchantManagement`、`PoolManagement` 与 `pool-detail` chunk。 +- [x] 5.2 执行 `openspec validate update-payment-merchant-pool-optimization --strict`。 diff --git a/src/api/modules/agentRecharge.ts b/src/api/modules/agentRecharge.ts index 6b07651..57e9fa6 100644 --- a/src/api/modules/agentRecharge.ts +++ b/src/api/modules/agentRecharge.ts @@ -9,6 +9,8 @@ import type { AgentRechargeListResponse, CreateAgentRechargeRequest, AgentRechargePaymentMethods, + AgentRechargePaymentVoucherOcrRequest, + AgentRechargePaymentVoucherOcrResponse, AgentRechargePaymentStatusResponse, ConfirmOfflinePaymentRequest, RejectAgentRechargeRequest, @@ -25,6 +27,30 @@ export class AgentRechargeService extends BaseService { ) } + /** + * 获取代理自充可用支付方式和金额限制 + * 仅代理与平台账号可用,超级管理员调用返回 403 + */ + static getSelfRechargePaymentMethods(): Promise> { + return this.get>( + '/api/admin/agent-self-recharge-payment-methods' + ) + } + + /** + * 识别付款凭证中的交易流水号 + * 只返回预填值,识别较慢且失败不阻断人工填写 + */ + static recognizePaymentVoucher( + data: AgentRechargePaymentVoucherOcrRequest + ): Promise> { + return this.post>( + '/api/admin/agent-recharges/payment-voucher-ocr', + data, + { timeout: 30000 } + ) + } + /** * 获取代理充值订单列表 * @param params 查询参数 diff --git a/src/api/modules/employeeCollection.ts b/src/api/modules/employeeCollection.ts new file mode 100644 index 0000000..01b53e6 --- /dev/null +++ b/src/api/modules/employeeCollection.ts @@ -0,0 +1,149 @@ +/** + * 员工代收款相关 API + */ + +import { BaseService } from '../BaseService' +import type { + CloseEmployeeCollectionBillRequest, + CloseEmployeeCollectionBillResponse, + CreateEmployeeCollectionPaymentMethodRequest, + DeleteEmployeeCollectionPaymentMethodResponse, + EmployeeCollectionApplicationListResponse, + EmployeeCollectionApplicationQueryParams, + EmployeeCollectionApplicationRequest, + EmployeeCollectionApplicationResponse, + EmployeeCollectionApplicationSubmitResponse, + EmployeeCollectionBillListResponse, + EmployeeCollectionBillQueryParams, + EmployeeCollectionBillResponse, + EmployeeCollectionBillStatisticsResponse, + EmployeeCollectionPaymentMethodListResponse, + EmployeeCollectionPaymentMethodQueryParams, + EmployeeCollectionPaymentMethodResponse, + UpdateEmployeeCollectionPaymentMethodRequest +} from '@/types/api' + +const PAYMENT_METHODS_BASE_URL = '/api/admin/employee-collection-payment-methods' +const BILLS_BASE_URL = '/api/admin/employee-collection-bills' +const APPLICATIONS_BASE_URL = '/api/admin/employee-collection-applications' + +export class EmployeeCollectionService extends BaseService { + /** + * 获取收款方式列表 + * 超级管理员返回全部,普通员工仅返回启用项 + */ + static getPaymentMethods( + params?: EmployeeCollectionPaymentMethodQueryParams + ): Promise { + return this.get(PAYMENT_METHODS_BASE_URL, params) + } + + /** + * 新增收款方式 + */ + static createPaymentMethod( + data: CreateEmployeeCollectionPaymentMethodRequest + ): Promise { + return this.post(PAYMENT_METHODS_BASE_URL, data) + } + + /** + * 修改收款方式(已被引用时不可修改 code) + */ + static updatePaymentMethod( + id: number, + data: UpdateEmployeeCollectionPaymentMethodRequest + ): Promise { + return this.put( + `${PAYMENT_METHODS_BASE_URL}/${id}`, + data + ) + } + + /** + * 删除收款方式(已被引用时不可删除,只能停用) + */ + static deletePaymentMethod(id: number): Promise { + return this.delete( + `${PAYMENT_METHODS_BASE_URL}/${id}` + ) + } + + /** + * 获取员工代收款账单列表 + */ + static getBills( + params?: EmployeeCollectionBillQueryParams + ): Promise { + return this.get(BILLS_BASE_URL, params) + } + + /** + * 获取账单统计(应收/已核销/未核销/待处理数量) + */ + static getBillStatistics( + params?: EmployeeCollectionBillQueryParams + ): Promise { + return this.get( + `${BILLS_BASE_URL}/statistics`, + params + ) + } + + /** + * 获取账单详情(含退款冲销、核销分摊、关联申请、企微审批历史) + */ + static getBillById(id: number): Promise { + return this.getOne(`${BILLS_BASE_URL}/${id}`) + } + + /** + * 关闭账单(仅超级管理员,必须填写原因) + */ + static closeBill( + id: number, + data: CloseEmployeeCollectionBillRequest + ): Promise { + return this.post(`${BILLS_BASE_URL}/${id}/close`, data) + } + + /** + * 创建核销申请(提交后自动发起企微审批) + */ + static createApplication( + data: EmployeeCollectionApplicationRequest + ): Promise { + return this.post(APPLICATIONS_BASE_URL, data) + } + + /** + * 获取核销申请列表 + */ + static getApplications( + params?: EmployeeCollectionApplicationQueryParams + ): Promise { + return this.get(APPLICATIONS_BASE_URL, params) + } + + /** + * 获取核销申请详情(含分摊账单、付款凭证、审批意见与提交历史) + */ + static getApplicationById(id: number): Promise { + return this.getOne( + `${APPLICATIONS_BASE_URL}/${id}` + ) + } + + /** + * 驳回后修改并重新提交(生成新的企微审批实例) + */ + static updateApplication( + id: number, + data: EmployeeCollectionApplicationRequest + ): Promise { + return this.put( + `${APPLICATIONS_BASE_URL}/${id}`, + data + ) + } +} diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 0a8ca56..2de229d 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -40,7 +40,7 @@ export { BulkPurchaseService } from './bulkPurchase' export { NotificationService } from './notification' export { AuditService } from './audit' export { WecomService } from './wecom' +export { EmployeeCollectionService } from './employeeCollection' // TODO: 按需添加其他业务模块 // export { SettingService } from './setting' - diff --git a/src/api/modules/paymentMerchantPools.ts b/src/api/modules/paymentMerchantPools.ts index a856f70..ccc3e39 100644 --- a/src/api/modules/paymentMerchantPools.ts +++ b/src/api/modules/paymentMerchantPools.ts @@ -7,8 +7,13 @@ import type { BaseResponse } from '@/types/api' import type { CreatePaymentMerchantRequest, PaymentMerchant, + PaymentCredentialValue, + PaymentCredentials, + PaymentMerchantDetail, + PaymentMerchantDetailResponse, PaymentMerchantPageResponse, PaymentMerchantPageResult, + PaymentMerchantProviderType, PaymentMerchantPool, PaymentMerchantPoolPageResponse, PaymentMerchantPoolPayload, @@ -27,6 +32,28 @@ const WECHAT_AUTHORIZATIONS_BASE_URL = '/api/admin/wechat-authorizations' type RawPaymentMerchant = PaymentMerchant & { credentials?: unknown } +type RawPaymentMerchantDetail = PaymentMerchant & { credentials?: Record } + +const MERCHANT_CREDENTIAL_DISPLAY_KEYS: Record = { + wechat: ['wx_mch_id', 'wx_serial_no', 'wx_notify_url'], + wechat_v2: ['wx_mch_id', 'wx_serial_no', 'wx_notify_url'], + fuiou: ['fy_api_url', 'fy_ins_cd', 'fy_mchnt_cd', 'fy_term_id', 'fy_notify_url'], + alipay: [ + 'ali_app_id', + 'ali_notify_url', + 'ali_return_url', + 'ali_pay_expire_minutes', + 'ali_production' + ] +} + +const MERCHANT_CREDENTIAL_STATUS_KEYS: Record = { + wechat: ['wx_api_v2_key', 'wx_api_v3_key', 'wx_cert_content', 'wx_key_content'], + wechat_v2: ['wx_api_v2_key', 'wx_api_v3_key', 'wx_cert_content', 'wx_key_content'], + fuiou: ['fy_private_key', 'fy_public_key'], + alipay: ['ali_private_key', 'ali_public_key'] +} + const sanitizeMerchant = (merchant: RawPaymentMerchant): PaymentMerchant => { // 仅通过解构丢弃 credentials 字段,避免在前端页面/状态中保留支付凭证明文 const { credentials: _ignoredCredentials, ...safeMerchant } = merchant @@ -34,6 +61,30 @@ const sanitizeMerchant = (merchant: RawPaymentMerchant): PaymentMerchant => { return safeMerchant } +const sanitizeMerchantDetail = (merchant: RawPaymentMerchantDetail): PaymentMerchantDetail => { + const { credentials: rawCredentials, ...baseMerchant } = merchant + const credentials: PaymentCredentials = {} + const provider = merchant.provider_type + const source = rawCredentials || {} + + for (const key of MERCHANT_CREDENTIAL_DISPLAY_KEYS[provider] || []) { + const value = source[key] + if (value !== undefined && value !== null) { + credentials[key] = value as PaymentCredentialValue + } + } + + for (const key of MERCHANT_CREDENTIAL_STATUS_KEYS[provider] || []) { + const value = source[key] + credentials[key] = value ? '已配置' : '未配置' + } + + return { + ...baseMerchant, + credentials + } +} + const sanitizeMerchantPage = (page: PaymentMerchantPageResult): PaymentMerchantPageResult => ({ ...page, items: (page.items || []).map((item) => sanitizeMerchant(item as RawPaymentMerchant)) @@ -77,6 +128,15 @@ export class PaymentMerchantPoolsService extends BaseService { ) } + static getPaymentMerchantDetailById(id: number): Promise { + return this.get(`${PAYMENT_MERCHANTS_BASE_URL}/${id}`).then( + (response) => ({ + ...response, + data: sanitizeMerchantDetail(response.data as unknown as RawPaymentMerchantDetail) + }) + ) + } + static createPaymentMerchant( data: CreatePaymentMerchantRequest ): Promise { diff --git a/src/components/business/VoucherUpload.vue b/src/components/business/VoucherUpload.vue index f08d34c..a629055 100644 --- a/src/components/business/VoucherUpload.vue +++ b/src/components/business/VoucherUpload.vue @@ -68,6 +68,7 @@ tip?: string purpose?: FilePurpose maxSizeMb?: number + contentType?: string singleColumnCsv?: boolean maxCsvRows?: number } @@ -79,6 +80,7 @@ tip: '', purpose: 'attachment', maxSizeMb: 0, + contentType: '', singleColumnCsv: false, maxCsvRows: 0 }) @@ -190,6 +192,35 @@ uploadRef.value?.handleRemove(uploadFile) } + // accept 支持扩展名(.csv)、精确 MIME(image/jpeg)与通配 MIME(image/*) + const isAcceptMatched = (file: File) => { + if (!props.accept) return true + + const fileName = file.name.toLowerCase() + const fileType = (file.type || '').toLowerCase() + + return props.accept.split(',').some((type) => { + const value = type.trim().toLowerCase() + if (!value) return false + if (value.startsWith('.')) return fileName.endsWith(value) + if (value.endsWith('/*')) return fileType.startsWith(value.slice(0, -1)) + return fileType === value + }) + } + + const getAcceptWarning = () => { + const values = props.accept + .split(',') + .map((type) => type.trim().toLowerCase()) + .filter(Boolean) + + if (values.length > 0 && values.every((value) => value.startsWith('image/'))) { + return '只能上传图片格式的文件' + } + + return `只能上传 ${props.accept} 格式的文件` + } + const handleFileChange = async (uploadFile: UploadFile) => { const file = uploadFile.raw if (!file) return @@ -219,14 +250,8 @@ } if (props.accept) { - const accepted = props.accept.split(',').some((type) => { - const value = type.trim().toLowerCase() - return value.startsWith('.') - ? file.name.toLowerCase().endsWith(value) - : file.type.toLowerCase() === value - }) - if (!accepted) { - ElMessage.warning(`只能上传 ${props.accept} 格式的文件`) + if (!isAcceptMatched(file)) { + ElMessage.warning(getAcceptWarning()) removeUploadFile(uploadFile) return } @@ -249,10 +274,10 @@ try { ElMessage.info(`正在上传${props.voucherName}...`) - const contentType = file.type || 'application/octet-stream' + const uploadContentType = props.contentType || file.type || 'application/octet-stream' const uploadUrlRes = await StorageService.getUploadUrl({ file_name: file.name, - content_type: contentType, + content_type: uploadContentType, purpose: props.purpose }) @@ -263,7 +288,7 @@ } const { upload_url, file_key } = uploadUrlRes.data - await StorageService.uploadFile(upload_url, file, contentType) + await StorageService.uploadFile(upload_url, file, uploadContentType) if (uploadBatch !== activeUploadBatch || removedUploadUids.has(uploadFile.uid)) { return diff --git a/src/config/constants/augustIteration.ts b/src/config/constants/augustIteration.ts new file mode 100644 index 0000000..3f1f843 --- /dev/null +++ b/src/config/constants/augustIteration.ts @@ -0,0 +1,21 @@ +/** + * 八月迭代新增后台权限编码。 + * 页面和按钮统一引用这里的常量,后端菜单权限可直接复用同名编码。 + */ +export const AUGUST_PERMISSIONS = { + employeeCollection: { + billPage: 'employee_collection:bill_view', + billDetail: 'employee_collection:bill_detail', + billClose: 'employee_collection:bill_close', + applicationPage: 'employee_collection:application_view', + applicationCreate: 'employee_collection:application_create', + applicationDetail: 'employee_collection:application_detail', + applicationUpdate: 'employee_collection:application_update', + paymentMethodPage: 'employee_collection:payment_method_view', + paymentMethodCreate: 'employee_collection:payment_method_create', + paymentMethodEdit: 'employee_collection:payment_method_edit', + paymentMethodDelete: 'employee_collection:payment_method_delete' + } +} as const + +export type AugustPermission = string diff --git a/src/locales/langs/en.json b/src/locales/langs/en.json index b7021f1..1fc5b78 100644 --- a/src/locales/langs/en.json +++ b/src/locales/langs/en.json @@ -433,7 +433,12 @@ "agentRechargeDetail": "Agent Recharge Details", "refundManagement": "Refund Management", "refundDetail": "Refund Details", - "agentFundOverview": "Agent Fund Overview" + "agentFundOverview": "Agent Fund Overview", + "employeeCollectionBills": "Employee Collection Bills", + "employeeCollectionBillDetail": "Bill Details", + "employeeCollectionApplications": "Reconciliation Applications", + "employeeCollectionApplicationDetail": "Application Details", + "employeeCollectionPaymentMethods": "Payment Methods" }, "deviceManagement": { "title": "Device Management", @@ -496,6 +501,7 @@ "settings": { "title": "Settings Management", "paymentSettings": "Payment Settings", + "agentSelfRecharge": "Agent Self-Recharge Settings", "detailsOfPaymentConfiguration": "Payment Configuration Details", "paymentMerchant": "Payment Merchant", "developerApi": "Developer API", diff --git a/src/locales/langs/zh.json b/src/locales/langs/zh.json index 3a931d6..9311188 100644 --- a/src/locales/langs/zh.json +++ b/src/locales/langs/zh.json @@ -438,7 +438,12 @@ "agentRechargeDetail": "代理充值详情", "refundManagement": "退款管理", "refundDetail": "退款详情", - "agentFundOverview": "代理商资金概况" + "agentFundOverview": "代理商资金概况", + "employeeCollectionBills": "员工代收款账单", + "employeeCollectionBillDetail": "账单详情", + "employeeCollectionApplications": "核销申请", + "employeeCollectionApplicationDetail": "核销申请详情", + "employeeCollectionPaymentMethods": "收款方式管理" }, "commission": { "title": "佣金管理", @@ -448,6 +453,7 @@ "settings": { "title": "设置管理", "paymentSettings": "支付设置", + "agentSelfRecharge": "代理自充设置", "detailsOfPaymentConfiguration": "支付配置详情", "withdrawalSettings": "提现配置", "passwordSettings": "密码设置", diff --git a/src/router/routes/asyncRoutes.ts b/src/router/routes/asyncRoutes.ts index a6e65d0..56e95cf 100644 --- a/src/router/routes/asyncRoutes.ts +++ b/src/router/routes/asyncRoutes.ts @@ -3,6 +3,7 @@ import { AppRouteRecord } from '@/types/router' import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration' import { AUDIT_PERMISSIONS } from '@/config/constants/audit' +import { AUGUST_PERMISSIONS } from '@/config/constants/augustIteration' /** * 菜单列表、异步路由 @@ -690,6 +691,64 @@ export const asyncRoutes: AppRouteRecord[] = [ keepAlive: false } }, + // 员工代收款账单 + { + path: 'employee-collection/bills', + name: 'EmployeeCollectionBills', + component: RoutesAlias.EmployeeCollectionBills, + meta: { + title: 'menus.financialManagement.employeeCollectionBills', + keepAlive: true, + permissions: [AUGUST_PERMISSIONS.employeeCollection.billPage] + } + }, + // 员工代收款账单详情 + { + path: 'employee-collection/bills/detail/:id', + name: 'EmployeeCollectionBillDetailRoute', + component: RoutesAlias.EmployeeCollectionBillDetail, + meta: { + title: 'menus.financialManagement.employeeCollectionBillDetail', + isHide: true, + keepAlive: false, + permissions: [AUGUST_PERMISSIONS.employeeCollection.billDetail] + } + }, + // 核销申请 + { + path: 'employee-collection/applications', + name: 'EmployeeCollectionApplications', + component: RoutesAlias.EmployeeCollectionApplications, + meta: { + title: 'menus.financialManagement.employeeCollectionApplications', + keepAlive: true, + permissions: [AUGUST_PERMISSIONS.employeeCollection.applicationPage] + } + }, + // 核销申请详情 + { + path: 'employee-collection/applications/detail/:id', + name: 'EmployeeCollectionApplicationDetailRoute', + component: RoutesAlias.EmployeeCollectionApplicationDetail, + meta: { + title: 'menus.financialManagement.employeeCollectionApplicationDetail', + isHide: true, + keepAlive: false, + permissions: [AUGUST_PERMISSIONS.employeeCollection.applicationDetail] + } + }, + // 收款方式管理(仅超级管理员) + { + path: 'employee-collection/payment-methods', + name: 'EmployeeCollectionPaymentMethods', + component: RoutesAlias.EmployeeCollectionPaymentMethods, + meta: { + title: 'menus.financialManagement.employeeCollectionPaymentMethods', + keepAlive: true, + roles: ['R_SUPER'], + permissions: [AUGUST_PERMISSIONS.employeeCollection.paymentMethodPage] + } + }, // 代理商资金概况 { path: 'agent-fund-overview', @@ -847,6 +906,30 @@ export const asyncRoutes: AppRouteRecord[] = [ allowedUserTypes: [1, 2] } }, + // 支付商户详情 + { + path: 'payment-merchant-pools/detail/:id', + name: 'PaymentMerchantPoolsDetailRoute', + component: RoutesAlias.PaymentMerchantPoolsDetail, + meta: { + title: 'menus.settings.detailsOfPaymentMerchant', + isHide: true, + keepAlive: false, + allowedUserTypes: [1, 2] + } + }, + // 商户池详情 + { + path: 'payment-merchant-pools/pool-detail/:id', + name: 'PaymentMerchantPoolDetailRoute', + component: RoutesAlias.PaymentMerchantPoolDetail, + meta: { + title: 'menus.settings.detailsOfPaymentMerchantPool', + isHide: true, + keepAlive: false, + allowedUserTypes: [1, 2] + } + }, // 支付设置详情 { path: 'payment-settings/detail/:id', @@ -880,6 +963,17 @@ export const asyncRoutes: AppRouteRecord[] = [ roles: ['R_SUPER', 'R_ADMIN'] } }, + // 代理自充设置(跳转系统配置并带上模块筛选) + { + path: 'agent-self-recharge', + name: 'AgentSelfRechargeSettings', + redirect: { path: RoutesAlias.SystemConfigs, query: { module: 'c2b.payment' } }, + meta: { + title: 'menus.settings.agentSelfRecharge', + keepAlive: false, + roles: ['R_SUPER'] + } + }, { path: 'wecom', name: 'WecomSettings', @@ -1107,5 +1201,3 @@ export const asyncRoutes: AppRouteRecord[] = [ // ] // }, ] - - diff --git a/src/router/routesAlias.ts b/src/router/routesAlias.ts index 6c0be44..302101a 100644 --- a/src/router/routesAlias.ts +++ b/src/router/routesAlias.ts @@ -90,6 +90,11 @@ export enum RoutesAlias { RefundManagement = '/finance/refund', // 退款管理 RefundDetail = '/finance/refund/detail', // 退款详情 AgentFundOverview = '/finance/agent-fund-overview', // 代理商资金概况 + EmployeeCollectionBills = '/finance/employee-collection/bills', // 员工代收款账单 + EmployeeCollectionBillDetail = '/finance/employee-collection/bills/detail', // 员工代收款账单详情 + EmployeeCollectionApplications = '/finance/employee-collection/applications', // 核销申请 + EmployeeCollectionApplicationDetail = '/finance/employee-collection/applications/detail', // 核销申请详情 + EmployeeCollectionPaymentMethods = '/finance/employee-collection/payment-methods', // 收款方式管理 ExpiringAssets = '/asset-management/expiring-assets', // 临期资产 @@ -103,8 +108,11 @@ export enum RoutesAlias { PaymentSettings = '/settings/payment-settings', // 支付设置 PaymentSettingsDetail = '/settings/payment-settings/detail', // 支付设置详情 PaymentMerchantPools = '/settings/payment-merchant-pools', // 支付商户与商户池管理 + PaymentMerchantPoolsDetail = '/settings/payment-merchant-pools/detail', // 支付商户详情 + PaymentMerchantPoolDetail = '/settings/payment-merchant-pools/pool-detail', // 商户池详情 OperationPasswordSettings = '/settings/operation-password', // 操作密码设置 SystemConfigs = '/settings/system-configs', // 系统配置 + AgentSelfRechargeSettings = '/settings/agent-self-recharge', // 代理自充设置 WecomSettings = '/settings/wecom', // 企业微信配置兼容入口 WecomApplications = '/settings/wecom/applications', // 企业微信应用 WecomMembers = '/settings/wecom/members', // 企业微信成员 @@ -134,4 +142,3 @@ export enum RoutesAlias { // 主页路由 - 修改为资产信息页面 export const HOME_PAGE = RoutesAlias.AssetInformation - diff --git a/src/types/api/agentRecharge.ts b/src/types/api/agentRecharge.ts index e8b45e1..a9c0a9f 100644 --- a/src/types/api/agentRecharge.ts +++ b/src/types/api/agentRecharge.ts @@ -47,6 +47,11 @@ export interface AgentRecharge { recharge_source?: AgentRechargeSource | null recharge_source_name?: string | null payment_voucher_key?: string[] | string // 凭证附件列表;历史数据可能为单字符串或逗号字符串 + external_transaction_no?: string | null // 线下人工申报的交易流水号,与在线 payment_transaction_id 互不覆盖 + offline_payment_method_id?: number | null // 线下收款方式字典ID,仅线下充值有值 + offline_payment_method_code?: string | null // 线下收款方式稳定编码快照 + offline_payment_method_name?: string | null // 线下收款方式名称快照 + other_voucher_key?: string[] | null // 其他凭证对象存储Key列表,仅线下充值有值 rejection_reason?: string | null // 拒绝原因 remark?: string // 运营备注 submitter_name?: string | null // 提交人名称 @@ -98,6 +103,9 @@ export interface CreateAgentRechargeOfflineRequest { payment_method: 'offline' shop_id: number payment_voucher_key: string[] // 线下支付凭证附件列表(payment_method=offline 时必填) + offline_payment_method_id: number // 线下收款方式字典ID,取自收款方式字典启用项 + external_transaction_no: string // 线下交易流水号,OCR 预填后人工确认 + other_voucher_key?: string[] // 其他凭证对象存储Key列表,最多 5 个 remark?: string // 运营备注,最多 1000 字 } @@ -112,6 +120,19 @@ export interface AgentRechargePaymentMethods { max_amount: number } +// 代理自充可用支付方式和金额范围(GET /api/admin/agent-self-recharge-payment-methods) +export type AgentSelfRechargePaymentMethods = AgentRechargePaymentMethods + +// 识别付款凭证中的交易流水号请求 +export interface AgentRechargePaymentVoucherOcrRequest { + payment_voucher_key: string // 付款凭证对象存储Key,必须指向已上传的图片类型附件 +} + +// 识别付款凭证中的交易流水号响应(只返回交易流水号预填值) +export interface AgentRechargePaymentVoucherOcrResponse { + external_transaction_no: string +} + // 在线充值支付及钱包到账状态 export interface AgentRechargePaymentStatusResponse { recharge_id: number diff --git a/src/types/api/employeeCollection.ts b/src/types/api/employeeCollection.ts new file mode 100644 index 0000000..0ca296d --- /dev/null +++ b/src/types/api/employeeCollection.ts @@ -0,0 +1,302 @@ +/** + * 员工代收款相关类型定义 + * + * 依据后端 OpenAPI(员工代收款分组): + * - 列表响应为 { items, page, size, total } + * - 账单状态与申请状态均为数字枚举 + * - 金额单位为「分」,附件字段为对象存储 Key 数组 + */ + +import type { BaseResponse, PaginationParams } from './common' + +// ========== 收款方式字典 ========== + +export interface EmployeeCollectionPaymentMethod { + id: number + code: string + name: string + sort: number + enabled: boolean + remark?: string | null + created_at?: string | null + updated_at?: string | null +} + +/** 收款方式列表查询参数,后端返回 { items, page, size, total } */ +export interface EmployeeCollectionPaymentMethodQueryParams { + page?: number + page_size?: number + enabled?: boolean + keyword?: string +} + +export interface CreateEmployeeCollectionPaymentMethodRequest { + code: string + name: string + sort?: number + enabled?: boolean + remark?: string +} + +export interface UpdateEmployeeCollectionPaymentMethodRequest { + code?: string + name?: string + sort?: number + enabled?: boolean + remark?: string +} + +// ========== 通用列表结构 ========== + +/** 后端列表统一返回 { items, page, size, total } */ +export interface EmployeeCollectionListData { + items?: T[] | null + list?: T[] | null + total?: number + page?: number + size?: number + page_size?: number +} + +// ========== 员工代收款账单 ========== + +/** 账单状态:0 待核销 / 1 部分核销 / 2 已核销 / 3 已关闭 */ +export type EmployeeCollectionBillStatus = number + +/** 账单责任人(员工)快照 */ +export interface EmployeeCollectionDebtorSnapshot { + account_id?: number | null + account_name?: string | null + account_type?: string | null + [key: string]: unknown +} + +/** 账单客户 / 店铺快照 */ +export interface EmployeeCollectionCustomerSnapshot { + shop_id?: number | null + seller_shop_id?: number | null + buyer_id?: number | null + buyer_type?: string | null + buyer_nickname?: string | null + asset_identifier?: string | null + [key: string]: unknown +} + +export interface EmployeeCollectionBill { + id: number + source_type?: string | null + source_type_name?: string | null + source_id?: number | null + source_no?: string | null + debtor_account_id?: number | null + debtor_snapshot?: EmployeeCollectionDebtorSnapshot | null + customer_snapshot?: EmployeeCollectionCustomerSnapshot | null + receivable_amount?: number | null // 应收金额(分) + received_amount?: number | null // 已核销金额(分) + reserved_amount?: number | null // 审批中预占金额(分) + remaining_amount?: number | null // 未核销金额(分) + status: EmployeeCollectionBillStatus + status_name?: string | null + approval_pending?: boolean // 是否存在审批中的核销申请 + closed_reason?: string | null + closed_at?: string | null + created_at?: string | null + updated_at?: string | null +} + +/** 核销申请详情中的账单分摊 */ +export interface EmployeeCollectionAllocation { + id?: number + bill_id: number + amount: number // 本次分摊金额(分) + status?: number | null // 0 审批中预占 / 1 已通过 / 2 已驳回或已释放 + status_name?: string | null + bill_status?: number | null + bill_status_name?: string | null + bill_source_type?: string | null + bill_source_no?: string | null + bill_receivable_amount?: number | null + bill_received_amount?: number | null + bill_reserved_amount?: number | null + created_at?: string | null + released_at?: string | null +} + +/** 账单详情中的核销分摊(含所属核销申请信息) */ +export interface EmployeeCollectionBillAllocation { + id?: number + amount: number + application_id?: number | null + application_status?: number | null + application_status_name?: string | null + attempt_id?: number | null + status?: number | null + status_name?: string | null + created_at?: string | null + released_at?: string | null +} + +/** 来源订单退款冲销关联(账单详情 refunds) */ +export interface EmployeeCollectionBillRefund { + id?: number + refund_id?: number + source_order_id?: number + refund_amount?: number | null // 本次退款成功金额(分) + reduced_amount?: number | null // 实际冲减应收金额(分) + bill_receivable_amount?: number | null // 冲销前账单应收金额快照(分) + outcome?: string | null // closed_full / reduced / hint_only + outcome_name?: string | null + created_at?: string | null +} + +/** 账单详情(接口返回 { bill, refunds, allocations, applications }) */ +export interface EmployeeCollectionBillDetailData { + bill: EmployeeCollectionBill + refunds?: EmployeeCollectionBillRefund[] | null + allocations?: EmployeeCollectionBillAllocation[] | null + applications?: EmployeeCollectionBillApplication[] | null +} + +/** 账单统计 */ +export interface EmployeeCollectionBillStatistics { + receivable_total: number // 应收总金额(分) + received_total: number // 已收款(已核销)总金额(分) + unsettled_total: number // 未结(未核销)总金额(分) + pending_bill_count: number // 待处理账单数量 +} + +export interface EmployeeCollectionBillQueryParams extends PaginationParams { + source_type?: string + source_no?: string + status?: EmployeeCollectionBillStatus + debtor_account_id?: number + customer_id?: number + created_from?: string + created_to?: string +} + +export interface CloseEmployeeCollectionBillRequest { + reason: string +} + +export type CloseEmployeeCollectionBillResult = EmployeeCollectionBill + +// ========== 核销申请 ========== + +/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已撤销或已关闭 */ +export type EmployeeCollectionApplicationStatus = number + +/** 审批尝试记录(历史材料不被覆盖) */ +export interface EmployeeCollectionApplicationAttempt { + id?: number + attempt_no?: number + approval_instance_id?: number | null + approval_status?: number | null // 通用审批实例状态 + approval_status_name?: string | null + approval_opinion?: string | null + acting_reason?: string | null + external_transaction_no?: string | null + paid_amount?: number | null + paid_at?: string | null + payer_name?: string | null + payment_method_id?: number | null + payment_method_code?: string | null + payment_method_name?: string | null + payment_voucher_keys?: string[] | null + remark?: string | null + submitted_by_account_id?: number | null + allocation_snapshot?: Array> | null + created_at?: string | null +} + +export interface EmployeeCollectionApplication { + id: number + applicant_account_id?: number | null + acting_operator_id?: number | null // 0 表示本人办理 + acting_reason?: string | null + payment_method_id?: number | null + payment_method_code?: string | null + payment_method_name?: string | null + paid_amount?: number | null // 人工确认的付款金额(分) + paid_at?: string | null + payer_name?: string | null + external_transaction_no?: string | null + payment_voucher_keys?: string[] | null + remark?: string | null + status: EmployeeCollectionApplicationStatus + status_name?: string | null + terminal_reason?: string | null + decided_at?: string | null + latest_approval_instance_id?: number | null + latest_attempt_id?: number | null + created_at?: string | null + updated_at?: string | null +} + +/** 账单详情中的核销申请(含该申请的审批尝试记录) */ +export interface EmployeeCollectionBillApplication extends EmployeeCollectionApplication { + attempts?: EmployeeCollectionApplicationAttempt[] | null +} + +/** 申请详情(接口返回 { application, allocations, attempts }) */ +export interface EmployeeCollectionApplicationDetailData { + application: EmployeeCollectionApplication + allocations?: EmployeeCollectionAllocation[] | null + attempts?: EmployeeCollectionApplicationAttempt[] | null +} + +export interface EmployeeCollectionApplicationQueryParams extends PaginationParams { + status?: EmployeeCollectionApplicationStatus + applicant_account_id?: number + payment_method_id?: number + created_from?: string + created_to?: string +} + +export interface EmployeeCollectionAllocationRequest { + bill_id: number + amount: number +} + +export interface EmployeeCollectionApplicationRequest { + payment_method_id: number + paid_amount: number + paid_at: string + payer_name: string + external_transaction_no: string + payment_voucher_keys: string[] + remark?: string + allocations: EmployeeCollectionAllocationRequest[] + acting_reason?: string +} + +export interface EmployeeCollectionApplicationSubmitResult { + allocations?: EmployeeCollectionAllocation[] | null + application?: EmployeeCollectionApplication | null + approval_instance_id?: number | null + approval_status?: number | null + approval_status_name?: string | null + attempt?: EmployeeCollectionApplicationAttempt | null +} + +// ========== 响应类型 ========== + +export type EmployeeCollectionPaymentMethodListResponse = BaseResponse< + EmployeeCollectionPaymentMethod[] | EmployeeCollectionListData +> +export type EmployeeCollectionPaymentMethodResponse = BaseResponse +export type DeleteEmployeeCollectionPaymentMethodResponse = BaseResponse +export type EmployeeCollectionBillListResponse = BaseResponse< + EmployeeCollectionListData +> +export type EmployeeCollectionBillResponse = BaseResponse +export type EmployeeCollectionBillStatisticsResponse = + BaseResponse +export type CloseEmployeeCollectionBillResponse = BaseResponse +export type EmployeeCollectionApplicationListResponse = BaseResponse< + EmployeeCollectionListData +> +export type EmployeeCollectionApplicationResponse = + BaseResponse +export type EmployeeCollectionApplicationSubmitResponse = + BaseResponse diff --git a/src/types/api/index.ts b/src/types/api/index.ts index 425b699..7b01f1d 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -135,4 +135,5 @@ export * from './audit' // 企业微信审批配置相关 export * from './wecom' - +// 员工代收款相关 +export * from './employeeCollection' diff --git a/src/types/api/paymentMerchantPools.ts b/src/types/api/paymentMerchantPools.ts index af92e24..f08befe 100644 --- a/src/types/api/paymentMerchantPools.ts +++ b/src/types/api/paymentMerchantPools.ts @@ -45,6 +45,14 @@ export interface PaymentMerchant { updated_at: string } +/** + * 支付商户详情:仅在详情页短暂使用,凭证字段经过脱敏/白名单处理, + * 不用于列表、编辑表单或持久化状态。 + */ +export interface PaymentMerchantDetail extends PaymentMerchant { + credentials: PaymentCredentials +} + export interface PaymentMerchantQueryParams extends PaginationParams { /** 可选:按名称模糊筛选 */ name?: string @@ -132,6 +140,7 @@ export interface UpdateWechatAuthorizationRequest { export type PaymentMerchantResponse = BaseResponse export type PaymentMerchantPageResponse = BaseResponse +export type PaymentMerchantDetailResponse = BaseResponse export type PaymentMerchantPoolResponse = BaseResponse export type PaymentMerchantPoolPageResponse = BaseResponse export type WechatAuthorizationResponse = BaseResponse @@ -205,3 +214,146 @@ export interface PaymentMerchantPoolMemberOption { payment_method: PaymentMerchantMethod enabled: boolean } + +/** + * 商户凭证字段枚举:必填键由 `payment_method` 与 `provider_type` 组合决定, + * 可选键为允许附带但非必须的字段,键名与渠道配置字段一致。 + */ +export interface PaymentCredentialFieldSpec { + required: string[] + optional: string[] +} + +export const PAYMENT_CREDENTIAL_FIELD_SPECS: Record< + PaymentMerchantProviderType, + PaymentCredentialFieldSpec +> = { + wechat: { + required: [ + 'wx_mch_id', + 'wx_api_v3_key', + 'wx_cert_content', + 'wx_key_content', + 'wx_serial_no', + 'wx_notify_url' + ], + optional: ['wx_api_v2_key'] + }, + wechat_v2: { + required: ['wx_mch_id', 'wx_api_v2_key', 'wx_notify_url'], + optional: [] + }, + fuiou: { + required: [ + 'fy_mchnt_cd', + 'fy_ins_cd', + 'fy_term_id', + 'fy_private_key', + 'fy_public_key', + 'fy_api_url', + 'fy_notify_url' + ], + optional: [] + }, + alipay: { + required: [ + 'ali_app_id', + 'ali_private_key', + 'ali_public_key', + 'ali_notify_url', + 'ali_return_url' + ], + optional: ['ali_production', 'ali_pay_expire_minutes'] + } +} + +export function getPaymentCredentialFieldSpec( + providerType: PaymentMerchantProviderType +): PaymentCredentialFieldSpec { + return PAYMENT_CREDENTIAL_FIELD_SPECS[providerType] || { required: [], optional: [] } +} + +/** 凭证字段中的布尔字段:值必须为布尔值 */ +export const PAYMENT_CREDENTIAL_BOOLEAN_KEYS: string[] = ['ali_production'] + +/** 凭证字段中的整数字段:值必须为正整数 */ +export const PAYMENT_CREDENTIAL_INTEGER_KEYS: string[] = ['ali_pay_expire_minutes'] + +/** `merchant_identity` 必须与其值保持一致的凭证字段 */ +export const PAYMENT_MERCHANT_IDENTITY_KEYS: Record = { + wechat: 'wx_mch_id', + wechat_v2: 'wx_mch_id', + fuiou: 'fy_mchnt_cd', + alipay: 'ali_app_id' +} + +export interface PaymentCredentialEntryInput { + key: string + value: string +} + +export interface PaymentCredentialValidationResult { + credentials: PaymentCredentials | null + error: string +} + +/** + * 校验并转换商户凭证输入:字段枚举、必填键、值类型与商户标识一致性。 + */ +export function buildPaymentCredentials( + providerType: PaymentMerchantProviderType, + merchantIdentity: string, + entries: PaymentCredentialEntryInput[] +): PaymentCredentialValidationResult { + const spec = getPaymentCredentialFieldSpec(providerType) + const allowedKeys = [...spec.required, ...spec.optional] + const credentials: Record = {} + + for (const entry of entries) { + const key = entry.key.trim() + if (!key) { + return { credentials: null, error: '请填写凭证字段名' } + } + if (!allowedKeys.includes(key)) { + return { credentials: null, error: `凭证字段 ${key} 不属于当前服务商支持的字段` } + } + if (!entry.value) { + return { credentials: null, error: `请填写凭证字段 ${key} 的值` } + } + if (Object.prototype.hasOwnProperty.call(credentials, key)) { + return { credentials: null, error: `凭证字段 ${key} 重复` } + } + + if (PAYMENT_CREDENTIAL_BOOLEAN_KEYS.includes(key)) { + if (entry.value !== 'true' && entry.value !== 'false') { + return { credentials: null, error: `凭证字段 ${key} 必须为布尔值 true 或 false` } + } + credentials[key] = entry.value === 'true' + continue + } + + if (PAYMENT_CREDENTIAL_INTEGER_KEYS.includes(key)) { + const numberValue = Number(entry.value) + if (!Number.isInteger(numberValue) || numberValue <= 0) { + return { credentials: null, error: `凭证字段 ${key} 必须为正整数` } + } + credentials[key] = numberValue + continue + } + + credentials[key] = entry.value + } + + for (const key of spec.required) { + if (!Object.prototype.hasOwnProperty.call(credentials, key)) { + return { credentials: null, error: `缺少必填凭证字段 ${key}` } + } + } + + const identityKey = PAYMENT_MERCHANT_IDENTITY_KEYS[providerType] + if (identityKey && credentials[identityKey] !== merchantIdentity.trim()) { + return { credentials: null, error: `商户标识必须与凭证字段 ${identityKey} 的值一致` } + } + + return { credentials, error: '' } +} diff --git a/src/types/api/wecom.ts b/src/types/api/wecom.ts index 578be64..6637151 100644 --- a/src/types/api/wecom.ts +++ b/src/types/api/wecom.ts @@ -1,6 +1,9 @@ import type { BaseResponse, PaginationData, PaginationParams } from './common' -export type WecomBusinessType = 'refund_approval' | 'offline_recharge_approval' +export type WecomBusinessType = + | 'refund_approval' + | 'offline_recharge_approval' + | 'employee_collection_approval' export interface WecomApplication { id: number diff --git a/src/views/finance/agent-recharge/detail.vue b/src/views/finance/agent-recharge/detail.vue index 3830fcb..0d2c8ad 100644 --- a/src/views/finance/agent-recharge/detail.vue +++ b/src/views/finance/agent-recharge/detail.vue @@ -193,12 +193,28 @@ prop: 'payment_channel', formatter: (value) => value || '-' }, - { - label: '第三方支付流水号', - prop: 'payment_transaction_id', - formatter: (value) => value || '-', - fullWidth: true - }, + ...(isOfflineRecharge + ? [ + { + label: '交易流水号', + prop: 'external_transaction_no', + formatter: (value: string | null | undefined) => value || '-', + fullWidth: true + }, + { + label: '收款方式', + formatter: (_: unknown, data: AgentRecharge) => + data.offline_payment_method_name || data.offline_payment_method_code || '-' + } + ] + : [ + { + label: '第三方支付流水号', + prop: 'payment_transaction_id', + formatter: (value: string | null | undefined) => value || '-', + fullWidth: true + } + ]), { label: '支付单号', prop: 'payment_no', @@ -226,6 +242,26 @@ () => '查看支付凭证' ) : h('span', '-') + }, + { + label: '其他凭证', + fullWidth: true, + render: (data: AgentRecharge) => + hasVoucherKeys(data.other_voucher_key ?? undefined) + ? h( + ElButton, + { + type: 'primary', + link: true, + onClick: () => { + paymentVoucherFileKeys.value = toVoucherKeyList( + data.other_voucher_key ?? undefined + ) + } + }, + () => '查看其他凭证' + ) + : h('span', '-') } ] : []), diff --git a/src/views/finance/agent-recharge/index.vue b/src/views/finance/agent-recharge/index.vue index 3d91918..4002b82 100644 --- a/src/views/finance/agent-recharge/index.vue +++ b/src/views/finance/agent-recharge/index.vue @@ -121,10 +121,66 @@ ref="uploadRef" v-model="createForm.payment_voucher_key" voucher-name="支付凭证" + accept="image/*" + content-type="image/jpeg" @uploading-change="voucherUploading = $event" @change="createFormRef?.validateField('payment_voucher_key')" /> + + + + + + +
+ + + 识别凭证 + +
+
+ 识别结果仅供参考,请对照凭证核对后再提交。 +
+
+ + + ([]) + const offlinePaymentMethodOptions = ref([]) + const paymentMethodOptionsLoading = ref(false) + const otherVoucherUploading = ref(false) + const ocrLoading = ref(false) // 搜索表单初始值 const initialSearchState: AgentRechargeQueryParams = { @@ -542,6 +609,8 @@ { label: '业务处理状态', prop: 'processing_status_name' }, { label: '支付方式', prop: 'payment_method' }, { label: '支付通道', prop: 'payment_channel' }, + { label: '交易流水号', prop: 'external_transaction_no' }, + { label: '收款方式', prop: 'offline_payment_method_name' }, { label: '运营备注', prop: 'remark' }, { label: '驳回原因', prop: 'rejection_reason' }, { label: '创建时间', prop: 'created_at' }, @@ -554,6 +623,7 @@ const confirmPayFormRef = ref() const rejectFormRef = ref() const uploadRef = ref>() + const otherUploadRef = ref>() const OFFLINE_MIN_RECHARGE_AMOUNT = 0.01 const OFFLINE_MAX_RECHARGE_AMOUNT = 1_000_000 @@ -579,6 +649,8 @@ () => createLoading.value || voucherUploading.value || + otherVoucherUploading.value || + ocrLoading.value || paymentMethodsLoading.value || (createMode.value === 'online' && onlinePaymentMethods.value.length === 0) ) @@ -631,6 +703,12 @@ } if (createMode.value === 'offline') { rules.payment_voucher_key = [{ required: true, message: '请上传支付凭证', trigger: 'change' }] + rules.offline_payment_method_id = [ + { required: true, message: '请选择收款方式', trigger: 'change' } + ] + rules.external_transaction_no = [ + { required: true, message: '请输入交易流水号', trigger: 'blur' } + ] } if (createMode.value === 'online') delete rules.shop_id return rules @@ -651,12 +729,18 @@ payment_method: AgentRechargePaymentMethod | '' shop_id: number | null payment_voucher_key: string[] + offline_payment_method_id: number | null + external_transaction_no: string + other_voucher_key: string[] remark: string }>({ amount: OFFLINE_MIN_RECHARGE_AMOUNT, payment_method: '', shop_id: null, payment_voucher_key: [], + offline_payment_method_id: null, + external_transaction_no: '', + other_voucher_key: [], remark: '' }) @@ -803,6 +887,21 @@ width: 120, formatter: (row: AgentRecharge) => row.payment_channel || '-' }, + { + prop: 'external_transaction_no', + label: '交易流水号', + minWidth: 180, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => row.external_transaction_no || '-' + }, + { + prop: 'offline_payment_method_name', + label: '收款方式', + width: 140, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => + row.payment_method === 'offline' ? row.offline_payment_method_name || '-' : '-' + }, { prop: 'remark', label: '运营备注', @@ -1000,8 +1099,9 @@ if (createMode.value === 'online') { await loadPaymentMethods() } else { - // 重新加载店铺列表,确保获取最新数据 + // 重新加载店铺列表与收款方式字典,确保获取最新数据 await loadShops() + await loadPaymentMethodOptions() } createDialogVisible.value = true @@ -1012,10 +1112,16 @@ createForm.payment_method = '' createForm.shop_id = null createForm.payment_voucher_key = [] + createForm.offline_payment_method_id = null + createForm.external_transaction_no = '' + createForm.other_voucher_key = [] createForm.remark = '' onlineRequestId.value = null voucherUploading.value = false + otherVoucherUploading.value = false + ocrLoading.value = false uploadRef.value?.clearFiles(false) + otherUploadRef.value?.clearFiles(false) } // 对话框关闭后的清理 @@ -1024,12 +1130,12 @@ resetCreateForm() } - // 加载代理在线充值可用支付方式 + // 加载代理在线充值可用支付方式(超管调用该接口返回 403,仅通过系统配置查看允许范围) const loadPaymentMethods = async () => { paymentMethodsLoading.value = true onlinePaymentMethods.value = [] try { - const res = await AgentRechargeService.getPaymentMethods() + const res = await AgentRechargeService.getSelfRechargePaymentMethods() if (res.code === 0) { onlinePaymentMethods.value = Array.isArray(res.data?.methods) ? res.data.methods : [] paymentMethodsBounds.min_amount = ONLINE_MIN_RECHARGE_AMOUNT_FEN @@ -1037,15 +1143,75 @@ Number(res.data?.max_amount) || ONLINE_MAX_RECHARGE_AMOUNT_FEN createForm.amount = minimumAmountYuan.value } else { - ElMessage.warning(res.msg || '当前暂无可用在线支付方式') + ElMessage.warning(res.msg || '当前暂无可用的在线支付方式') } } catch (error) { console.error('加载在线支付方式失败:', error) + ElMessage.warning('当前暂无可用的在线支付方式') } finally { paymentMethodsLoading.value = false } } + // 加载线下收款方式字典启用项 + const loadPaymentMethodOptions = async () => { + paymentMethodOptionsLoading.value = true + offlinePaymentMethodOptions.value = [] + try { + const res = await EmployeeCollectionService.getPaymentMethods({ + page: 1, + page_size: 100, + enabled: true + }) + if (res.code === 0) { + offlinePaymentMethodOptions.value = + normalizeCollectionList(res.data) + } + } catch (error) { + console.error('加载线下收款方式失败:', error) + } finally { + paymentMethodOptionsLoading.value = false + } + } + + // 已上传的支付凭证对象键(OCR 识别取第一个) + const offlineVoucherKeys = computed(() => toVoucherKeyList(createForm.payment_voucher_key)) + + // 识别付款凭证中的交易流水号:只做预填,失败不阻断人工填写 + const handleRecognizeVoucher = async () => { + if (ocrLoading.value) return + + const voucherKeys = offlineVoucherKeys.value + if (!voucherKeys.length) { + ElMessage.warning('请先上传支付凭证') + return + } + + ocrLoading.value = true + try { + const res = await AgentRechargeService.recognizePaymentVoucher({ + payment_voucher_key: voucherKeys[0] + }) + if (res.code !== 0) { + ElMessage.warning(res.msg || '识别失败,请手动填写交易流水号') + return + } + const transactionNo = res.data?.external_transaction_no + if (!transactionNo) { + ElMessage.warning('未识别到交易流水号,请手动填写') + return + } + createForm.external_transaction_no = transactionNo + createFormRef.value?.validateField('external_transaction_no') + ElMessage.success('已识别交易流水号,请对照凭证核对') + } catch (error) { + console.error('识别付款凭证失败:', error) + ElMessage.warning('识别失败,请手动填写交易流水号') + } finally { + ocrLoading.value = false + } + } + // 创建充值订单 const handleCreateRecharge = async () => { if (createLoading.value) return @@ -1056,6 +1222,10 @@ ElMessage.warning('支付凭证上传中,请稍候') return } + if (otherVoucherUploading.value) { + ElMessage.warning('其他凭证上传中,请稍候') + return + } createLoading.value = true try { @@ -1071,6 +1241,10 @@ ElMessage.warning('请选择目标店铺') return } + if (!createForm.offline_payment_method_id) { + ElMessage.warning('请选择收款方式') + return + } const voucherKeys = toVoucherKeyList(createForm.payment_voucher_key) if (!hasVoucherKeys(voucherKeys)) { @@ -1078,11 +1252,15 @@ return } + const otherVoucherKeys = toVoucherKeyList(createForm.other_voucher_key) const data: CreateAgentRechargeRequest = { amount: amountYuanToFen(createForm.amount), payment_method: 'offline', shop_id: createForm.shop_id, + offline_payment_method_id: createForm.offline_payment_method_id, + external_transaction_no: createForm.external_transaction_no.trim(), payment_voucher_key: voucherKeys, + other_voucher_key: otherVoucherKeys.length ? otherVoucherKeys : undefined, remark: createForm.remark || undefined } @@ -1430,6 +1608,18 @@ color: var(--el-color-danger); } + .external-transaction-row { + display: flex; + gap: 8px; + width: 100%; + } + + .external-transaction-tip { + margin-top: 4px; + font-size: 12px; + color: var(--el-text-color-secondary); + } + .online-recharge-qr-dialog { display: flex; flex-direction: column; diff --git a/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue b/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue new file mode 100644 index 0000000..cdffad2 --- /dev/null +++ b/src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue @@ -0,0 +1,574 @@ + + + + + diff --git a/src/views/finance/employee-collection/applications/detail.vue b/src/views/finance/employee-collection/applications/detail.vue new file mode 100644 index 0000000..ce43c28 --- /dev/null +++ b/src/views/finance/employee-collection/applications/detail.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/src/views/finance/employee-collection/applications/index.vue b/src/views/finance/employee-collection/applications/index.vue new file mode 100644 index 0000000..8865da6 --- /dev/null +++ b/src/views/finance/employee-collection/applications/index.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/src/views/finance/employee-collection/bills/detail.vue b/src/views/finance/employee-collection/bills/detail.vue new file mode 100644 index 0000000..25f7ea2 --- /dev/null +++ b/src/views/finance/employee-collection/bills/detail.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/src/views/finance/employee-collection/bills/index.vue b/src/views/finance/employee-collection/bills/index.vue new file mode 100644 index 0000000..4d7f333 --- /dev/null +++ b/src/views/finance/employee-collection/bills/index.vue @@ -0,0 +1,539 @@ + + + + + diff --git a/src/views/finance/employee-collection/employeeCollectionDisplay.ts b/src/views/finance/employee-collection/employeeCollectionDisplay.ts new file mode 100644 index 0000000..02439a6 --- /dev/null +++ b/src/views/finance/employee-collection/employeeCollectionDisplay.ts @@ -0,0 +1,215 @@ +/** + * 员工代收款展示辅助函数 + */ + +import type { + EmployeeCollectionApplication, + EmployeeCollectionApplicationStatus, + EmployeeCollectionBill, + EmployeeCollectionBillStatus +} from '@/types/api' + +type TagType = 'success' | 'warning' | 'danger' | 'info' + +/** 账单状态:0 待核销 / 1 部分核销 / 2 已核销 / 3 已关闭 */ +export const BILL_STATUS = { + PENDING: 0, + PARTIAL: 1, + VERIFIED: 2, + CLOSED: 3 +} as const + +/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已撤销或已关闭 */ +export const APPLICATION_STATUS = { + PENDING: 0, + APPROVED: 1, + REJECTED: 2, + REVOKED: 3 +} as const + +/** + * 兼容后端列表返回结构: + * - 直接返回数组 + * - { list: [...] } / { items: [...] } / { records: [...] } + */ +export const normalizeCollectionPage = (data: unknown): { list: T[]; total: number } => { + if (Array.isArray(data)) { + return { list: data as T[], total: data.length } + } + if (data && typeof data === 'object') { + const record = data as Record + for (const key of ['items', 'list', 'records']) { + const value = record[key] + if (Array.isArray(value)) { + const total = Number(record.total) + return { list: value as T[], total: Number.isNaN(total) ? value.length : total } + } + } + } + return { list: [], total: 0 } +} + +export const normalizeCollectionList = (data: unknown): T[] => + normalizeCollectionPage(data).list + +/** + * 金额格式化(分 -> 元) + */ +export const formatCollectionCurrency = (amount?: number | null): string => { + if (amount === undefined || amount === null || Number.isNaN(amount)) return '-' + return `¥${(amount / 100).toFixed(2)}` +} + +/** + * 账单状态选项 + */ +export const BILL_STATUS_OPTIONS = [ + { label: '待核销', value: BILL_STATUS.PENDING }, + { label: '部分核销', value: BILL_STATUS.PARTIAL }, + { label: '已核销', value: BILL_STATUS.VERIFIED }, + { label: '已关闭', value: BILL_STATUS.CLOSED } +] + +/** + * 核销申请状态选项 + */ +export const APPLICATION_STATUS_OPTIONS = [ + { label: '审批中', value: APPLICATION_STATUS.PENDING }, + { label: '已通过', value: APPLICATION_STATUS.APPROVED }, + { label: '已驳回', value: APPLICATION_STATUS.REJECTED }, + { label: '已撤销或已关闭', value: APPLICATION_STATUS.REVOKED } +] + +/** + * 账单来源选项 + */ +export const BILL_SOURCE_OPTIONS = [ + { label: '后台线下套餐订单', value: 'order' }, + { label: '代理线下充值', value: 'recharge' } +] + +const BILL_STATUS_LABEL_MAP: Record = BILL_STATUS_OPTIONS.reduce( + (map, item) => ({ ...map, [item.value]: item.label }), + {} +) + +const APPLICATION_STATUS_LABEL_MAP: Record = APPLICATION_STATUS_OPTIONS.reduce( + (map, item) => ({ ...map, [item.value]: item.label }), + {} +) + +const BILL_SOURCE_LABEL_MAP: Record = BILL_SOURCE_OPTIONS.reduce( + (map, item) => ({ ...map, [item.value]: item.label }), + {} +) + +/** + * 账单状态文案(优先使用后端 status_name) + */ +export const getBillStatusLabel = (status?: EmployeeCollectionBillStatus | null): string => { + if (status === undefined || status === null) return '-' + return BILL_STATUS_LABEL_MAP[status] ?? String(status) +} + +/** + * 核销申请状态文案 + */ +export const getApplicationStatusLabel = ( + status?: EmployeeCollectionApplicationStatus | null +): string => { + if (status === undefined || status === null) return '-' + return APPLICATION_STATUS_LABEL_MAP[status] ?? String(status) +} + +/** + * 账单来源文案(优先使用后端 source_type_name) + */ +export const getBillSourceLabel = (source?: string | null): string => { + if (!source) return '-' + return BILL_SOURCE_LABEL_MAP[source] || source +} + +/** + * 账单状态标签类型 + */ +export const getBillStatusTagType = (status?: EmployeeCollectionBillStatus | null): TagType => { + switch (status) { + case BILL_STATUS.PENDING: + return 'warning' + case BILL_STATUS.PARTIAL: + return 'info' + case BILL_STATUS.VERIFIED: + return 'success' + case BILL_STATUS.CLOSED: + return 'info' + default: + return 'info' + } +} + +/** + * 核销申请状态标签类型 + */ +export const getApplicationStatusTagType = ( + status?: EmployeeCollectionApplicationStatus | null +): TagType => { + switch (status) { + case APPLICATION_STATUS.PENDING: + return 'warning' + case APPLICATION_STATUS.APPROVED: + return 'success' + case APPLICATION_STATUS.REJECTED: + return 'danger' + case APPLICATION_STATUS.REVOKED: + return 'info' + default: + return 'info' + } +} + +/** + * 账单责任人(员工)名称 + */ +export const getBillDebtorName = (bill?: EmployeeCollectionBill | null): string => + bill?.debtor_snapshot?.account_name || '-' + +/** + * 账单关联店铺 ID + */ +export const getBillShopId = (bill?: EmployeeCollectionBill | null): number | undefined => + bill?.customer_snapshot?.shop_id ?? bill?.customer_snapshot?.seller_shop_id ?? undefined + +/** + * 账单客户展示:优先买家昵称,其次店铺名称,最后店铺 ID + */ +export const getBillCustomerLabel = ( + bill?: EmployeeCollectionBill | null, + shopNameMap?: Record +): string => { + const nickname = bill?.customer_snapshot?.buyer_nickname + if (nickname) return nickname + const shopId = getBillShopId(bill) + if (!shopId) return '-' + return shopNameMap?.[shopId] || `店铺 #${shopId}` +} + +/** + * 账单是否可关闭:仅待核销或部分核销可关闭(存在审批中预占时由调用方拦截或后端拒绝) + */ +export const canCloseBill = (bill: EmployeeCollectionBill): boolean => + bill.status === BILL_STATUS.PENDING || bill.status === BILL_STATUS.PARTIAL + +/** + * 账单是否可发起核销申请 + */ +export const canCreateApplication = (bill: EmployeeCollectionBill): boolean => { + if (bill.status === BILL_STATUS.CLOSED) return false + return (bill.remaining_amount ?? 0) > 0 +} + +/** + * 申请是否可修改并重新提交 + */ +export const canResubmitApplication = (application: EmployeeCollectionApplication): boolean => { + return application.status === APPLICATION_STATUS.REJECTED +} diff --git a/src/views/finance/employee-collection/payment-methods/index.vue b/src/views/finance/employee-collection/payment-methods/index.vue new file mode 100644 index 0000000..4756658 --- /dev/null +++ b/src/views/finance/employee-collection/payment-methods/index.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/src/views/order-management/order-list/index.vue b/src/views/order-management/order-list/index.vue index 70b2a23..9c97422 100644 --- a/src/views/order-management/order-list/index.vue +++ b/src/views/order-management/order-list/index.vue @@ -197,7 +197,11 @@ 提示: 使用钱包支付时,订单将直接完成 @@ -213,6 +217,12 @@ @uploading-change="voucherUploading = $event" @change="createFormRef?.validateField('payment_voucher_key')" /> +
+ 付款凭证为选填,也可在员工代收款核销申请中提交 +
@@ -708,6 +692,14 @@ color: var(--el-text-color-secondary); } + .credential-tip { + margin-bottom: 8px; + font-size: 12px; + line-height: 20px; + color: var(--el-text-color-secondary); + word-break: break-all; + } + .field-error { margin-top: 8px; font-size: 12px; diff --git a/src/views/settings/payment-merchant-pools/components/PoolManagement.vue b/src/views/settings/payment-merchant-pools/components/PoolManagement.vue index fac2bd3..c72b11a 100644 --- a/src/views/settings/payment-merchant-pools/components/PoolManagement.vue +++ b/src/views/settings/payment-merchant-pools/components/PoolManagement.vue @@ -202,48 +202,12 @@ - - - - {{ detail.name }} - {{ - getPaymentMethodLabel(detail.payment_method) - }} - {{ detail.member_ids.length }} - {{ - getPaymentPoolStrategyLabel(detail.strategy) - }} - - {{ getPaymentStatisticCycleLabel(detail.statistic_cycle) }} - - - {{ (Number(detail.threshold_amount) / 100).toFixed(2) }} 元 - - - {{ detail.threshold_count }} - - - {{ detail.time_period_value }} - {{ getPaymentTimePeriodUnitLabel(detail.time_period_unit) }} - - - {{ detail.enabled ? '启用' : '停用' }} - - v{{ detail.routing_epoch }} - {{ detail.remark || '-' }} - {{ - formatDateTime(detail.updated_at) - }} - - diff --git a/src/views/settings/payment-merchant-pools/detail.vue b/src/views/settings/payment-merchant-pools/detail.vue new file mode 100644 index 0000000..9ee15a3 --- /dev/null +++ b/src/views/settings/payment-merchant-pools/detail.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/src/views/settings/payment-merchant-pools/pool-detail.vue b/src/views/settings/payment-merchant-pools/pool-detail.vue new file mode 100644 index 0000000..cef793a --- /dev/null +++ b/src/views/settings/payment-merchant-pools/pool-detail.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/src/views/settings/system-configs/index.vue b/src/views/settings/system-configs/index.vue index dead324..d2ffc9f 100644 --- a/src/views/settings/system-configs/index.vue +++ b/src/views/settings/system-configs/index.vue @@ -68,7 +68,7 @@ v-for="item in currentConfig?.enum_values" :key="item" :value="item" - :label="item" + :label="formatEnumLabel(item)" /> - - diff --git a/src/views/settings/wecom/scenes/index.vue b/src/views/settings/wecom/scenes/index.vue index bccf48b..c522fb2 100644 --- a/src/views/settings/wecom/scenes/index.vue +++ b/src/views/settings/wecom/scenes/index.vue @@ -64,6 +64,7 @@ > +