Compare commits
205 Commits
main
...
codex/xuxi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd7e9610e | ||
|
|
bd3c00e523 | ||
|
|
2816c21fae | ||
|
|
cb8044996f | ||
|
|
4055d22eec | ||
|
|
fba732982d | ||
|
|
0791e94768 | ||
|
|
a712e7d65f | ||
|
|
9c4adf46d9 | ||
|
|
ac72b4e5c1 | ||
|
|
531c813d9a | ||
|
|
daabe4b309 | ||
|
|
fd24c71a47 | ||
|
|
bfaab0ce4e | ||
|
|
d54494dcc7 | ||
|
|
b18e4d079e | ||
|
|
5338f8dcb3 | ||
|
|
2f509c133f | ||
|
|
d86f7085cf | ||
|
|
bb40c6b6bc | ||
|
|
d81cc85936 | ||
|
|
8ab5307d49 | ||
|
|
9a9c63aadb | ||
|
|
016e7ea408 | ||
|
|
9b6a379404 | ||
|
|
f592e314f1 | ||
|
|
cbfbf6f93b | ||
|
|
06cbcf5498 | ||
|
|
514c373a5f | ||
|
|
ad3d572813 | ||
|
|
a273f77315 | ||
|
|
065102e35e | ||
|
|
df2389efff | ||
|
|
96e92719d2 | ||
|
|
165ba6a6c8 | ||
|
|
678ebc1238 | ||
|
|
d77b9e1328 | ||
|
|
86c2b6d88a | ||
|
|
b8e0f445f0 | ||
|
|
2aef8010db | ||
|
|
9f3fb23265 | ||
|
|
ef34c8cfbe | ||
|
|
575b1a84ff | ||
|
|
dc22d53e20 | ||
|
|
92d6e3ea4c | ||
|
|
e0a4a316e6 | ||
|
|
c4b73f20fc | ||
|
|
78022874ed | ||
|
|
c417ea2f3e | ||
|
|
52351ffd32 | ||
|
|
4a2071103b | ||
|
|
33f172b2eb | ||
|
|
bd2fa7eda6 | ||
|
|
e5523cc8f1 | ||
|
|
db2439da12 | ||
|
|
4b27f821a5 | ||
|
|
5fdcbbc735 | ||
|
|
3e5b3ca8dd | ||
|
|
ee78258482 | ||
|
|
581112554a | ||
|
|
654d076745 | ||
|
|
621bc817bf | ||
|
|
ed2100f95e | ||
|
|
cb2166d3d3 | ||
|
|
828883e742 | ||
|
|
62191d50ab | ||
|
|
05e4509f0d | ||
|
|
75b6d6b08d | ||
|
|
9e064c4b42 | ||
|
|
72fb742a3d | ||
|
|
4c1cd5527b | ||
|
|
976fc50be6 | ||
|
|
a7118a19d6 | ||
|
|
2fa5de6faa | ||
|
|
cf4533644b | ||
|
|
d102807f1f | ||
|
|
92d5751e29 | ||
|
|
0273991735 | ||
|
|
15b92b21d7 | ||
|
|
fb5b0b4b0b | ||
|
|
f79dfd05a9 | ||
|
|
6a61f37518 | ||
|
|
ada5547dab | ||
|
|
bb36a281f0 | ||
|
|
2f86d68ad1 | ||
|
|
fc6234c3fa | ||
|
|
5020cd2cb6 | ||
|
|
020685724d | ||
|
|
708d6e74fb | ||
|
|
ffacc0f8cf | ||
|
|
562a0965c1 | ||
|
|
caf0d01081 | ||
|
|
51bbaa856d | ||
|
|
e517587be5 | ||
|
|
4d78ef3bc8 | ||
|
|
fc28557c3d | ||
|
|
ff6406d1e0 | ||
|
|
089772817d | ||
|
|
d7a8f98e71 | ||
|
|
46ed372fbc | ||
|
|
ef542d4477 | ||
|
|
f081897391 | ||
|
|
1188bbb5d3 | ||
|
|
efcba62d3b | ||
|
|
4f7ad254c9 | ||
|
|
4d691dc0c0 | ||
|
|
24eb88e178 | ||
|
|
286a707faf | ||
|
|
fb33e4f782 | ||
|
|
24e2c726f5 | ||
|
|
b11d2efbe1 | ||
|
|
671ba259a3 | ||
|
|
b8d1177e0a | ||
|
|
960c9d454f | ||
|
|
cd15a4d2d8 | ||
|
|
4ed8082fba | ||
|
|
64182df114 | ||
|
|
760a613950 | ||
|
|
e50184f36d | ||
|
|
4fd0b7be1e | ||
|
|
3645802d7d | ||
|
|
c230981a46 | ||
|
|
824603764b | ||
|
|
7f7d234139 | ||
|
|
617703a5b4 | ||
|
|
0e5f4c1596 | ||
|
|
f6d1c4ce42 | ||
|
|
b437c7d9a9 | ||
|
|
18d77046bf | ||
|
|
4c51935284 | ||
|
|
407dff873b | ||
|
|
c5e6c19b0e | ||
|
|
b1f94fd3a4 | ||
|
|
eab5da8263 | ||
|
|
01e1f61f91 | ||
|
|
f3a123def5 | ||
|
|
4cd214272e | ||
|
|
24ac1d5af9 | ||
|
|
73cab271a5 | ||
|
|
dfe8723cb3 | ||
|
|
831cb083ab | ||
|
|
106cd93af8 | ||
|
|
0150b5000d | ||
|
|
de59c180ce | ||
|
|
b07f34b558 | ||
|
|
b5243326a7 | ||
|
|
ca9096e98b | ||
|
|
5bd2599c64 | ||
|
|
e6a49f0e0c | ||
|
|
e422504daa | ||
|
|
b87d742886 | ||
|
|
680a471a7b | ||
|
|
0953ac8600 | ||
|
|
43d3de6d7e | ||
|
|
7e82310cc8 | ||
|
|
56e24b0b5f | ||
|
|
1c11a3e2e3 | ||
|
|
2225341a67 | ||
|
|
f7784d8faf | ||
|
|
b0b1512510 | ||
|
|
07af4cf90b | ||
|
|
01c118891f | ||
|
|
5d4b964669 | ||
|
|
ad8fee485c | ||
|
|
fae629d61f | ||
|
|
e2e1b61b0a | ||
|
|
cd9e227ba6 | ||
|
|
4d6d84e104 | ||
|
|
6568bcb349 | ||
|
|
c64bb73e6d | ||
|
|
594939ac74 | ||
|
|
e96b8c5b9a | ||
|
|
9386f795aa | ||
|
|
5e26d0edb7 | ||
|
|
dec39d864c | ||
|
|
05d6b9dda8 | ||
|
|
2e7a7a1c1c | ||
|
|
a80326f308 | ||
|
|
6f1966ff36 | ||
|
|
f5c3aabd26 | ||
|
|
cd094bc90c | ||
|
|
649489b16c | ||
|
|
afcede79a0 | ||
|
|
ea08b9cd88 | ||
|
|
47c66348fd | ||
|
|
be4bb61f45 | ||
|
|
cd81a9230c | ||
|
|
37dd0c7230 | ||
|
|
caf2a20151 | ||
|
|
dbf3544d85 | ||
|
|
423d8bc7e6 | ||
|
|
13372fdeb1 | ||
|
|
8d5a2d2836 | ||
|
|
5ab4fe6115 | ||
|
|
fecc583766 | ||
|
|
0d1fd73f1c | ||
|
|
f7c9a0ba71 | ||
|
|
5b0505bfbf | ||
|
|
70a581e8d4 | ||
|
|
832b639f03 | ||
|
|
7523bd3ec0 | ||
|
|
cbfe7f257c | ||
|
|
75adc3f7f2 | ||
|
|
2af0a45e6a | ||
|
|
70aa055c83 |
23
furniture-3d-system/data/clearance/2026-11/README.md
Normal file
23
furniture-3d-system/data/clearance/2026-11/README.md
Normal file
@ -0,0 +1,23 @@
|
||||
# 2026-11 清货任务人工基线
|
||||
|
||||
本目录保存 2026 年 11 月清货任务的人工权威数据。两份文件合并后共 1,833 条任务。
|
||||
|
||||
| 文件 | 数据行数 | 优先级 |
|
||||
| --- | ---: | --- |
|
||||
| `bulk-tasks.xlsx` | 1,571 | 沿用表内优先级/空值 |
|
||||
| `guangdong-tasks.xlsx` | 262 | 统一标记为“广东佛山” |
|
||||
|
||||
匹配键为“统计月 + 条码 + 利润中心”。当前数据库本地基线已逐字段核对,
|
||||
不存在缺失记录、多余记录或任务量、清货价、负责人、部门负责人及优先级差异。
|
||||
|
||||
SHA-256:
|
||||
|
||||
```text
|
||||
d7fe0d851d9641c9e6c13208c8d43e66c0e367f9d0e7682697dfd2b2edc2a9d1 bulk-tasks.xlsx
|
||||
4d58bf18a9b664a73732d93093159f805bca1ba5296e818831061a97028fb00f guangdong-tasks.xlsx
|
||||
```
|
||||
|
||||
原材料:
|
||||
|
||||
- `清货任务 · 大货_导入模板(2).xlsx`
|
||||
- `清货任务 · 广东_导入模板(1).xlsx`
|
||||
BIN
furniture-3d-system/data/clearance/2026-11/bulk-tasks.xlsx
Normal file
BIN
furniture-3d-system/data/clearance/2026-11/bulk-tasks.xlsx
Normal file
Binary file not shown.
BIN
furniture-3d-system/data/clearance/2026-11/guangdong-tasks.xlsx
Normal file
BIN
furniture-3d-system/data/clearance/2026-11/guangdong-tasks.xlsx
Normal file
Binary file not shown.
@ -16,24 +16,22 @@
|
||||
|
||||
只读取得费率、物流规则和商品字段快照,在本机离线运行,不向生产网页接口重放旧重计算。上游费率视图有 75,630 行;复现的 200 行限价页中有 159 行能进行运费计算,共需 4,465 行费率。
|
||||
|
||||
同一快照、同一计算时间、相同输入与输出顺序,本机 Node.js 24.22.1 的一次带规范化调用计数的对比(计数会影响耗时):
|
||||
同一快照、同一计算时间、相同输入与输出顺序,本机 Node.js 24.22.1 的一次对比:
|
||||
|
||||
| 项目 | 修复前 | 修复后 |
|
||||
| --- | ---: | ---: |
|
||||
| 计算耗时 | 16,056 ms | 124 ms |
|
||||
| 计算耗时 | 11,394 ms | 133 ms |
|
||||
| 字符串规范化调用 | 64,631,978 | 16,030 |
|
||||
| 完整结果 SHA-256 | `a34b18ccfd50ae6053b26d7ca2513fa101368cadfdbee3befaf7f752c360efc6` | 相同 |
|
||||
| 完整结果 SHA-256 | `fe480d89ec75f3235e891fec47bb43a5d7dbfcf71e777ef366a7e89d8e6576cb` | 相同 |
|
||||
|
||||
这是离线计算对比,不代表生产端到端请求耗时,也不包含数据库和网络时间。原始商业数据与 CPU 采样只保存在私有排错目录,不进入 Git。
|
||||
|
||||
另用本机独立 HTTP 子进程调用实际批量服务,数据访问以同一只读快照代替,不插入字符串计数。旧版计算用时 5,012 ms,期间并发健康请求最长等待 5,019 ms;修复版计算用时 143 ms,8 次健康请求均为 200,最长等待 29 ms。两次完整服务返回值摘要均为 `ef8a73cec474260611eb74c073d22b32c721b1881644db7ceff22cc83e41d489`。
|
||||
|
||||
## 修复边界
|
||||
|
||||
- 纯计算层按规范化后的快递和省份建立费率索引,并按原来的生效时间和规则 ID 排序。保留有效期、重量/体积/最长边、计泡、取整、失效提示和财务计算规则。
|
||||
- `createFreightCalculator` 在同一批次复用费率索引与发货占比;新的批次重新使用自己的费率快照和计算时间,不增加跨请求的陈旧缓存。
|
||||
- 批量服务超过 8 ms 计算时间片后用 `setImmediate` 让出线程,让健康检查和其他请求有执行机会。
|
||||
- 新增复杂度、报价一致性、有效期和批量让出线程的回归测试。本次生产修复保留现有运费计算公式及基线期望,不带入开发线上尚未发布的运费功能和公式变化。
|
||||
- 新增复杂度、报价一致性、有效期和批量让出线程的回归测试。原区域测试的省份占比与区域占比不一致,且未修改的基线也失败;修正测试数据,使区域内部省份占比之和等于区域占比。未修改业务计算公式。
|
||||
|
||||
不修改数据库结构或业务数据、页面视觉、SSH 密钥、密码、共享账号、全局 Node.js、穿透配置或其他项目服务。
|
||||
|
||||
|
||||
@ -243,6 +243,8 @@ pnpm exec tsx scripts/generate-supply-chain-inventory-alert-snapshot.ts
|
||||
只有经管业务身份且拥有操作权限的人员可审核通过、驳回或打回。
|
||||
- 申诉原因固定为“商品编码有误”“活动中,无法下架”“其他”三项,接口和页面使用同一份枚举。
|
||||
- 申诉和审核均支持按链接批量处理:同一平台店铺商品编码下符合条件的订单一次覆盖。
|
||||
- 未下架监控支持勾选多条明细后在任一明细中批量通过、驳回、打回或取消驳回;
|
||||
后端仅更新勾选 ID 中符合当前状态和人员数据权限的记录。
|
||||
- 审核通过后原乐捐记录保留,但页面展示与月度汇总按 0 元计算,实现自动减免。
|
||||
- 负责人离职或无法匹配到员工目录在职记录时,状态显示“人员已离职”,乐捐金额按 0 元计算且不能申诉。
|
||||
- “人员已离职”可作为申诉状态筛选;筛选后可批量向部门负责人的钉钉个人工作通知推送处理提醒。
|
||||
@ -272,6 +274,7 @@ pnpm exec tsx scripts/generate-supply-chain-inventory-alert-snapshot.ts
|
||||
- “操作”权限控制申诉、审核、取消驳回和离职负责人批量推送;申诉校验运营业务身份及本人
|
||||
负责人匹配,审核类操作校验经管业务身份。旧 `discontinuationPenalties` 节点继续兼容审核操作,
|
||||
已有授权不会失效。
|
||||
- “人员已离职”明细必须勾选后才能批量推送部门负责人,只推送勾选且仍为离职状态的记录。
|
||||
|
||||
## 4. 业务口径
|
||||
|
||||
|
||||
@ -5,27 +5,28 @@
|
||||
|
||||
## 一、限价与判定口径
|
||||
|
||||
1. **判定基准**:以「商家实收」为准;`商家实收 < 限价 × 数量` 即判定破价。
|
||||
2. **实付款与平台承担**:实付款 + 平台承担(红包 / 消费券 / 平台券)= 商家实收;凭证必须同时包含「实付款」和「数量」。
|
||||
3. **出资方字典**
|
||||
1. **判定基准**:以订单明细中的「买家实付」为准;`买家实付 < 适用限价 × 数量` 即判定破价。
|
||||
2. **负责人限价规则**:订单商品负责人与链接维度负责人一致时,比较负责人限价;不一致时,比较非负责人限价。无法同时取得两边负责人时,按负责人限价处理。
|
||||
3. **实付款与平台承担**:实付款 + 平台承担(红包 / 消费券 / 平台券)= 商家实收;平台承担只用于展示金额构成,不作为破价判定金额。凭证必须同时包含「买家实付」和「数量」。
|
||||
4. **出资方字典**
|
||||
- 商家承担:店铺优惠(官方立减、单品直降、店铺券)、政府补贴
|
||||
- 平台承担:红包、消费券、平台券、平台优惠、新品礼金
|
||||
- 买家承担:淘金币
|
||||
- 待定:首单礼金(等与运营确认后补录)
|
||||
4. **限价维度**:按**货号**限价——同一货号下所有规格/条码共用同一套限价。
|
||||
5. **限价时间版本**:按订单付款时点生效的限价判定(限价调整前后分别取当时版本),不做"用最新限价回算历史单"。
|
||||
6. **成本变动规则(限价维护侧)**:成本价上涨 5% 以内不动限价;成本价下调不变限价。
|
||||
7. **非负责人限价**:2026-10-07 00:00 前的 ID 价格不判定破价(此后按规则判定)。
|
||||
8. **老链接分界**:上架时间早于 2026-10-07 00:00 的链接(老链接)破价申诉通过;之后的按限价流程结束日期判定(上架时间需早于流程结束日期),并核对店铺款式编码 / 商品编码。
|
||||
9. **负责人触发规则**:非运营发起的流程,前两个月全公司运营同价(不归属个人);两个月后按销量触发负责人(销量高者为负责人)。
|
||||
10. **数据快照**:破价单在生成时保存金额快照;退款/售后状态单独记录,退款不等于免责(仍需判断是否已调价)。
|
||||
11. **离职人员**:订单负责人已离职 → 自动标记「人员已离职」,不判定、不计乐捐。
|
||||
12. **是否有检测报告**:取素材平台「检测报告」接口(`/api/external/certificate-reports`,按 **SPU** 精确匹配,只认有报告文件的记录),进程内缓存 10 分钟;
|
||||
5. **限价维度**:按**货号**限价——同一货号下所有规格/条码共用同一套限价。
|
||||
6. **限价时间版本**:按订单付款时点生效的限价判定(限价调整前后分别取当时版本),不做"用最新限价回算历史单"。
|
||||
7. **成本变动规则(限价维护侧)**:成本价上涨 5% 以内不动限价;成本价下调不变限价。
|
||||
8. **非负责人限价**:负责人限价 × 1.05,且至少增加 10 元;2026-10-07 00:00 前的 ID 价格不判定破价(此后按规则判定)。
|
||||
9. **老链接分界**:上架时间早于 2026-10-07 00:00 的链接(老链接)破价申诉通过;之后的按限价流程结束日期判定(上架时间需早于流程结束日期),并核对店铺款式编码 / 商品编码。
|
||||
10. **负责人触发规则**:非运营发起的流程,前两个月全公司运营同价(不归属个人);两个月后按销量触发负责人(销量高者为负责人)。
|
||||
11. **数据快照**:破价单在生成时保存金额快照;退款/售后状态单独记录,退款不等于免责(仍需判断是否已调价)。
|
||||
12. **离职人员**:订单负责人已离职 → 自动标记「人员已离职」,不判定、不计乐捐。
|
||||
13. **是否有检测报告**:取素材平台「检测报告」接口(`/api/external/certificate-reports`,按 **SPU** 精确匹配,只认有报告文件的记录),进程内缓存 10 分钟;
|
||||
接口未配置或读取失败时该列显示为空、筛选候选值为空。配置项:`MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY`,
|
||||
不填则复用素材平台 `MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*`。
|
||||
13. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
|
||||
14. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
|
||||
- 判定按**换出行原编码**的限价执行(客户在平台上购买的那个编码);
|
||||
- 成交数量与商家实收取**换入行**(换货不改变订单成交金额);换入行金额抓不到时回退订单主表商家实收 → 订单主表买家实付,都取不到才按 0(由运营申诉说明);
|
||||
- 成交数量、买家实付与商家实收取**换入行**;换入行的商家实收抓不到时可回退订单主表商家实收 → 订单主表买家实付,但破价判定金额始终优先使用换入明细的 `buyer_paid_amount`;
|
||||
- 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单;
|
||||
- 配对优先按 `sku_uuid` 精确匹配,剩余行仅在一对一时顺序配对;复杂结构(多对多)暂不强行配对,相关行按各自编码现状处理。
|
||||
|
||||
@ -61,7 +62,7 @@
|
||||
## 四、生成与运行
|
||||
|
||||
1. **按天增量**:限价逻辑上线后,破价订单**按天增量生成**(每天只生成最新一天的破价单并入库),**历史数据不再重复回算**;破价订单审核页每天展示最新破价单。
|
||||
2. **数据来源**:聚水潭报表库(`jst_Orders` + `jst_OrderItem` / 天猫奇门 `jst_qmcust_orderlist` + `_item`),取 `seller_income_amount`、`buyer_paid_amount`、`qty`、`shop_i_id`、`shop_sku_id`、`pay_date`、退款状态,以及换货识别字段 `item_status`、`item_ext_data`(`item_source`、`sku_uuid`)。报表库查询较慢,逐日增量任务建议按天分片拉取。
|
||||
2. **数据来源**:聚水潭报表库(`jst_Orders` + `jst_OrderItem` / 天猫奇门 `jst_qmcust_orderlist` + `_item`),取 `seller_income_amount`、`buyer_paid_amount`、`qty`、`shop_i_id`、`shop_sku_id`、`pay_date`、退款状态,以及换货识别字段 `item_status`、`item_ext_data`(`item_source`、`sku_uuid`)。订单商品负责人取产品全量档案 `opsHead`;链接负责人按 `shop_i_id` 读取 `op_TargetManagement.ider` 最新记录。报表库查询较慢,逐日增量任务建议按天分片拉取。
|
||||
3. **限价来源**:钉钉流程(仅取**已完结**),共 4 个模板,同步脚本 `scripts/sync-limit-workflows.mjs`:
|
||||
- 「新品限价及限价调整」→ 货号取「货号(非商品编码)」,限价取「期望日常限价」;
|
||||
其中的「调整限价明细表」子表(申请类型=调整原货号限价)只填「调整后限价」,该列同样作为限价取值
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
-- 2026-11 shipping progress was built from outbound-order tables.
|
||||
-- Clear that month's daily cache so the next progress refresh rebuilds it
|
||||
-- from the unified sales table using barcode + profit center + stat_date.
|
||||
DELETE FROM "SupplyChainClearanceTaskShipmentDaily"
|
||||
WHERE "taskId" IN (
|
||||
SELECT "id"
|
||||
FROM "SupplyChainClearanceTask"
|
||||
WHERE "periodKey" = '2026-11'
|
||||
);
|
||||
@ -0,0 +1,9 @@
|
||||
-- Clear November caches produced with the previous per-task-profit-center rule.
|
||||
-- The next progress refresh rebuilds each task from all enabled store centers:
|
||||
-- 运营中心A, 运营中心C, 运营中心G and 分销部.
|
||||
DELETE FROM "SupplyChainClearanceTaskShipmentDaily"
|
||||
WHERE "taskId" IN (
|
||||
SELECT "id"
|
||||
FROM "SupplyChainClearanceTask"
|
||||
WHERE "periodKey" = '2026-11'
|
||||
);
|
||||
@ -17,11 +17,13 @@ const actionSchema = z.discriminatedUnion('action', [
|
||||
action: z.literal('review'),
|
||||
decision: z.enum(['PASS', 'REJECT', 'RETURN']),
|
||||
note: z.string().max(200).default(''),
|
||||
scope: z.enum(['ORDER', 'LINK']).default('ORDER'),
|
||||
scope: z.enum(['ORDER', 'LINK', 'SELECTED']).default('ORDER'),
|
||||
ids: z.array(z.string().min(1)).max(500).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('cancel-reject'),
|
||||
scope: z.enum(['ORDER', 'LINK']).default('ORDER'),
|
||||
scope: z.enum(['ORDER', 'LINK', 'SELECTED']).default('ORDER'),
|
||||
ids: z.array(z.string().min(1)).max(500).optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
@ -87,11 +89,13 @@ export async function POST(
|
||||
decision: parsed.data.decision,
|
||||
note: parsed.data.note,
|
||||
scope: parsed.data.scope,
|
||||
ids: parsed.data.ids,
|
||||
})
|
||||
: await supplyChainDiscontinuationPenaltyService.cancelReject({
|
||||
id,
|
||||
viewer,
|
||||
scope: parsed.data.scope,
|
||||
ids: parsed.data.ids,
|
||||
})
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
|
||||
@ -9,6 +9,9 @@ import {
|
||||
parseProductArchiveFiltersPayload,
|
||||
} from '@/server/services/supply-chain-product-archive-query'
|
||||
import { freightConfigService } from '@/server/services/freight-config.service'
|
||||
import {
|
||||
resolveOwnerAdjustedLimitPriceAfterFreight,
|
||||
} from '@/lib/supply-chain-limit-price-fields'
|
||||
|
||||
// 限价管理 - 商品列表:复用产品全量档案的分页/筛选能力,
|
||||
// 但权限走经营管理(businessManagement),与该页面的入口权限一致。
|
||||
@ -25,10 +28,20 @@ async function withRegionFreight(
|
||||
const freightByBarcode = await freightConfigService.calculateRegionFreightsForArchiveRows(page.rows)
|
||||
return {
|
||||
...page,
|
||||
rows: page.rows.map((row) => ({
|
||||
...row,
|
||||
...freightByBarcode.get(row.barcode),
|
||||
})),
|
||||
rows: page.rows.map((row) => {
|
||||
const freight = freightByBarcode.get(row.barcode)
|
||||
return {
|
||||
...row,
|
||||
...freight,
|
||||
ownerAdjustedLimitPrice: resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: row.barcode,
|
||||
productStatus: row.productStatus,
|
||||
ownerLimitPrice: row.opsPriceLimit,
|
||||
costPrice: row.costPrice,
|
||||
freightNationwide: freight?.freightNationwide,
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[limit-products] 区域和全国运费计算失败,保留其他限价数据', error)
|
||||
|
||||
@ -453,6 +453,11 @@ function rowClearanceTotal(row: { totalStock: number; extra?: Record<string, unk
|
||||
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : Math.max(0, row.totalStock)
|
||||
}
|
||||
|
||||
function isFixedTaskRow(row: { extra?: Record<string, unknown> }) {
|
||||
const fixedTaskQty = Number(row.extra?.fixedTaskQty)
|
||||
return Number.isFinite(fixedTaskQty) && fixedTaskQty > 0
|
||||
}
|
||||
|
||||
function rowComboStatus(row: { extra?: Record<string, unknown> }) {
|
||||
return String(row.extra?.comboStatus ?? '').trim()
|
||||
}
|
||||
@ -1190,8 +1195,14 @@ export function ClearanceView({
|
||||
return
|
||||
}
|
||||
const clearanceTotal = rowClearanceTotal(row)
|
||||
const fixedTask = isFixedTaskRow(row)
|
||||
const salesNoDiscount = isSalesStatus(row.salesStatus, row.productStatus)
|
||||
const discount = salesNoDiscount || row.discountRestricted || !edit.discount.trim()
|
||||
const discountDisabled = salesNoDiscount || (!fixedTask && (
|
||||
row.discountRestricted
|
||||
|| row.discountChannelClosed
|
||||
|| row.dropshipOnlyStock
|
||||
))
|
||||
const discount = discountDisabled || !edit.discount.trim()
|
||||
? 1
|
||||
: parseDiscountInput(edit.discount)
|
||||
const reason = salesNoDiscount ? '' : edit.reason.trim()
|
||||
@ -1511,11 +1522,12 @@ export function ClearanceView({
|
||||
|| (row.currentTask && row.currentTask.status !== 'REJECTED'),
|
||||
)
|
||||
const clearanceTotal = rowClearanceTotal(row)
|
||||
const fixedTask = isFixedTaskRow(row)
|
||||
const comboStatus = rowComboStatus(row)
|
||||
const rowEditable = draftEditable
|
||||
&& clearanceTotal > 0
|
||||
&& comboStatus !== '多包异常'
|
||||
&& !row.taskChannelClosed
|
||||
&& (!row.taskChannelClosed || fixedTask)
|
||||
if (column.kind === 'image') return <PoolImageCell row={row} />
|
||||
if (column.key === 'priority' && canReview) {
|
||||
return (
|
||||
@ -1564,7 +1576,7 @@ export function ClearanceView({
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
row.taskChannelClosed
|
||||
row.taskChannelClosed && !fixedTask
|
||||
? '正常销售无需定任务'
|
||||
: comboStatus === '多包异常'
|
||||
? comboStatus
|
||||
@ -1572,7 +1584,7 @@ export function ClearanceView({
|
||||
}
|
||||
aria-label={`${row.productName || row.barcode} 任务量`}
|
||||
title={
|
||||
row.taskChannelClosed
|
||||
row.taskChannelClosed && !fixedTask
|
||||
? '销售状态且可用天数小于30天,无需定任务和申请折扣'
|
||||
: comboStatus === '多包异常'
|
||||
? `该商品${comboStatus},不允许填写任务或申请折扣`
|
||||
@ -1583,11 +1595,33 @@ export function ClearanceView({
|
||||
}
|
||||
if (column.kind === 'discount') {
|
||||
const salesNoDiscount = isSalesStatus(row.salesStatus, row.productStatus)
|
||||
const discountDisabled =
|
||||
salesNoDiscount
|
||||
|| row.discountRestricted
|
||||
const discountDisabled = salesNoDiscount || (!fixedTask && (
|
||||
row.discountRestricted
|
||||
|| row.discountChannelClosed
|
||||
|| row.dropshipOnlyStock
|
||||
))
|
||||
const discountPlaceholder = salesNoDiscount
|
||||
? '销售状态无需折扣'
|
||||
: fixedTask
|
||||
? '如 5折,可留空'
|
||||
: row.dropshipOnlyStock
|
||||
? '仅代发仓有库存,不可申请折扣'
|
||||
: row.discountChannelClosed
|
||||
? '正常销售无需折扣'
|
||||
: row.discountRestricted
|
||||
? '已配置禁止折扣'
|
||||
: '如 5折,可留空'
|
||||
const discountTitle = salesNoDiscount
|
||||
? '销售状态商品不允许申请折扣'
|
||||
: fixedTask
|
||||
? undefined
|
||||
: row.dropshipOnlyStock
|
||||
? '简尚总库存为0且仅代发仓有库存,不允许申请折扣'
|
||||
: row.discountChannelClosed
|
||||
? '正常销售状态无需定任务和折扣'
|
||||
: row.discountRestricted
|
||||
? '该商品编码或 SPU 已配置禁止申请折扣'
|
||||
: undefined
|
||||
return (
|
||||
<input
|
||||
key={`${row.barcode}-${pool?.periodKey ?? ''}-discount`}
|
||||
@ -1603,28 +1637,8 @@ export function ClearanceView({
|
||||
saveDraftNow(row.barcode)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
salesNoDiscount
|
||||
? '销售状态无需折扣'
|
||||
: row.dropshipOnlyStock
|
||||
? '仅代发仓有库存,不可申请折扣'
|
||||
: row.discountChannelClosed
|
||||
? '正常销售无需折扣'
|
||||
: row.discountRestricted
|
||||
? '已配置禁止折扣'
|
||||
: '如 5折,可留空'
|
||||
}
|
||||
title={
|
||||
salesNoDiscount
|
||||
? '销售状态商品不允许申请折扣'
|
||||
: row.dropshipOnlyStock
|
||||
? '简尚总库存为0且仅代发仓有库存,不允许申请折扣'
|
||||
: row.discountChannelClosed
|
||||
? '正常销售状态无需定任务和折扣'
|
||||
: row.discountRestricted
|
||||
? '该商品编码或 SPU 已配置禁止申请折扣'
|
||||
: undefined
|
||||
}
|
||||
placeholder={discountPlaceholder}
|
||||
title={discountTitle}
|
||||
aria-label={`${row.productName || row.barcode} 折扣`}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -74,6 +74,12 @@ const STATUS_TABS: Array<{ key: string; label: string }> = [
|
||||
|
||||
const money = (value: number | null | undefined) => (value === null || value === undefined ? '—' : `¥${Number(value).toFixed(2)}`)
|
||||
const day = (value: string | null) => (value ? new Date(value).toISOString().slice(0, 10) : '—')
|
||||
/** 历史破价单的 gapAmount 可能按旧规则(商家实收)保存,展示时统一按买家实付重算。 */
|
||||
const buyerPaidGap = (row: Pick<BreachOrder, 'buyerPaidAmount' | 'limitTotal' | 'gapAmount'>) => (
|
||||
row.limitTotal === null
|
||||
? row.gapAmount
|
||||
: Number(((row.buyerPaidAmount ?? 0) - row.limitTotal).toFixed(2))
|
||||
)
|
||||
|
||||
/** 可批量、可下拉、输入即查询的筛选框(值以标签展示,支持粘贴多个值)。 */
|
||||
function MultiFilterChip({
|
||||
@ -216,7 +222,7 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
|
||||
return () => controller.abort()
|
||||
}, [openHeaderFilter, headerOptions])
|
||||
// 列宽可人工拖拽调整(默认按初始像素宽)
|
||||
const DEFAULT_COL_WIDTHS = [92, 180, 150, 140, 150, 180, 110, 118, 150, 88]
|
||||
const DEFAULT_COL_WIDTHS = [92, 180, 150, 140, 150, 180, 110, 118, 170, 110, 88]
|
||||
const [colWidths, setColWidths] = useState<number[]>(DEFAULT_COL_WIDTHS)
|
||||
const startColumnResize = (index: number, event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
@ -449,13 +455,13 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
|
||||
const body = await response.json()
|
||||
const rows: BreachOrder[] = body?.data?.rows ?? []
|
||||
const header = kind === 'fine'
|
||||
? ['线上订单号', '内部订单号', '平台', '店铺', '商品编码', '运营负责人', '商家实收', '限价', '价差', '驳回原因', '审核人', '乐捐金额']
|
||||
: ['付款日期', '平台', '店铺', '线上订单号', '内部订单号', '商品编码', '店铺款式编码', '店铺商品编码', '数量', '商家实收', '限价×数量', '价差', '运营负责人', '状态', '申诉类型', '流程号']
|
||||
? ['线上订单号', '内部订单号', '平台', '店铺', '商品编码', '运营负责人', '买家实付', '平台承担', '商家实收', '限价', '价差', '驳回原因', '审核人', '乐捐金额']
|
||||
: ['付款日期', '平台', '店铺', '线上订单号', '内部订单号', '商品编码', '店铺款式编码', '店铺商品编码', '数量', '买家实付', '平台承担', '商家实收', '限价×数量', '价差', '运营负责人', '状态', '申诉类型', '流程号']
|
||||
const lines = [header.join(',')]
|
||||
for (const row of rows) {
|
||||
const cells = kind === 'fine'
|
||||
? [row.soId, row.oId, row.platform, row.shopName, row.itemCode, row.ownerName, row.sellerIncomeAmount, row.limitTotal, row.gapAmount, row.reviewReason, row.reviewedBy, row.fineAmount]
|
||||
: [day(row.payDate), row.platform, row.shopName, row.soId, row.oId, row.itemCode, row.shopStyleCode, row.shopItemCode, row.qty, row.sellerIncomeAmount, row.limitTotal, row.gapAmount, row.ownerName, row.statusLabel, row.appealType, row.processNo]
|
||||
? [row.soId, row.oId, row.platform, row.shopName, row.itemCode, row.ownerName, row.buyerPaidAmount, row.platformBearAmount, row.sellerIncomeAmount, row.limitTotal, buyerPaidGap(row), row.reviewReason, row.reviewedBy, row.fineAmount]
|
||||
: [day(row.payDate), row.platform, row.shopName, row.soId, row.oId, row.itemCode, row.shopStyleCode, row.shopItemCode, row.qty, row.buyerPaidAmount, row.platformBearAmount, row.sellerIncomeAmount, row.limitTotal, buyerPaidGap(row), row.ownerName, row.statusLabel, row.appealType, row.processNo]
|
||||
lines.push(cells.map((value) => `"${String(value ?? '').replaceAll('"', '""')}"`).join(','))
|
||||
}
|
||||
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
||||
@ -615,14 +621,15 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
|
||||
{sortTh('soId', '订单号', 5, 'soId')}
|
||||
{sortTh('listedAt', '上架时间', 6)}
|
||||
{sortTh('flowEndDate', '流程结束日期', 7)}
|
||||
{sortTh('sellerIncomeAmount', '商家实收 / 限价', 8)}
|
||||
{sortTh('status', '状态', 9, 'status')}
|
||||
{sortTh('buyerPaidAmount', '买家实付 / 限价', 8)}
|
||||
{sortTh('sellerIncomeAmount', '商家实收', 9)}
|
||||
{sortTh('status', '状态', 10, 'status')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan={10} className={styles.state}>加载中…</td></tr>}
|
||||
{!loading && error && <tr><td colSpan={10} className={`${styles.state} ${styles.error}`}>{error}</td></tr>}
|
||||
{!loading && !error && orders.length === 0 && <tr><td colSpan={10} className={styles.state}>暂无破价订单</td></tr>}
|
||||
{loading && <tr><td colSpan={11} className={styles.state}>加载中…</td></tr>}
|
||||
{!loading && error && <tr><td colSpan={11} className={`${styles.state} ${styles.error}`}>{error}</td></tr>}
|
||||
{!loading && !error && orders.length === 0 && <tr><td colSpan={11} className={styles.state}>暂无破价订单</td></tr>}
|
||||
{orders.map((row) => (
|
||||
<tr key={row.id} className={activeId === row.id ? styles.on : undefined} onClick={() => setActiveId(row.id)}>
|
||||
<td className={styles.num}>{day(row.payDate)}</td>
|
||||
@ -634,10 +641,12 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
|
||||
<td className={styles.num}>{day(row.listedAt)}</td>
|
||||
<td className={styles.num}>{day(row.flowEndDate)}</td>
|
||||
<td className={styles.num}>
|
||||
{money(row.sellerIncomeAmount)}
|
||||
<b className={styles.gap}> {row.gapAmount !== null ? row.gapAmount.toFixed(2) : ''}</b>
|
||||
{money(row.buyerPaidAmount)}
|
||||
<b className={styles.gap}> {buyerPaidGap(row) !== null ? buyerPaidGap(row)?.toFixed(2) : ''}</b>
|
||||
<span className={styles.sub}>限价 {money(row.limitTotal)}({money(row.limitUnitPrice)}×{row.qty})</span>
|
||||
<span className={styles.sub}>平台承担 {money(row.platformBearAmount)}</span>
|
||||
</td>
|
||||
<td className={styles.num}>{money(row.sellerIncomeAmount)}</td>
|
||||
<td><span className={`${styles.tag} ${STATUS_CLASS[row.status] ?? ''}`}>{row.statusLabel}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
@ -669,9 +678,12 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
|
||||
<h3>订单与破价数据</h3>
|
||||
<div className={styles.in}>
|
||||
<div className={styles.price}>
|
||||
<div><span>商家实收</span><b className={(order.gapAmount ?? 0) < 0 ? styles.bad : undefined}>{money(order.sellerIncomeAmount)}</b></div>
|
||||
<div><span>买家实付</span><b className={(buyerPaidGap(order) ?? 0) < 0 ? styles.bad : undefined}>{money(order.buyerPaidAmount)}</b></div>
|
||||
<div><span>平台承担</span><b>{money(order.platformBearAmount)}</b></div>
|
||||
<div><span>商家实收</span><b>{money(order.sellerIncomeAmount)}</b></div>
|
||||
<div><span>数量</span><b>{order.qty}</b></div>
|
||||
<div><span>限价×数量</span><b>{money(order.limitTotal)}</b></div>
|
||||
<div><span>价差</span><b className={(order.gapAmount ?? 0) < 0 ? styles.bad : undefined}>{order.gapAmount !== null ? order.gapAmount.toFixed(2) : '—'}</b></div>
|
||||
<div><span>价差</span><b className={(buyerPaidGap(order) ?? 0) < 0 ? styles.bad : undefined}>{buyerPaidGap(order) !== null ? buyerPaidGap(order)?.toFixed(2) : '—'}</b></div>
|
||||
</div>
|
||||
<div className={styles.facts}>
|
||||
<div><span>付款日期</span><b>{day(order.payDate)}</b></div>
|
||||
@ -804,7 +816,8 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
|
||||
<div className={styles.note}>尚未识别凭证(运营提交申诉后,系统自动识别截图并给出建议)。</div>
|
||||
)}
|
||||
<ul className={styles.points}>
|
||||
<li>比较口径:实付款 + 平台承担 与「限价×数量」比较,低于限价即破价。</li>
|
||||
<li>比较口径:订单明细买家实付与「适用限价×数量」比较,低于限价即破价。</li>
|
||||
<li>商品负责人与链接负责人一致时用负责人限价;不一致时用非负责人限价。</li>
|
||||
<li>差额由商家承担(店铺优惠、政府补贴)→ 驳回;由平台/买家承担(红包、淘金币)→ 通过。</li>
|
||||
<li>老链接:上架时间早于 2026-10-07 00:00 → 通过;之后按限价流程结束日期判定。</li>
|
||||
<li>负责人已离职 → 不判定、不计乐捐。</li>
|
||||
|
||||
@ -756,11 +756,35 @@ describe('限价管理', () => {
|
||||
const row = await screen.findByText('BG006701N1063V01')
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(await screen.findByText('商家实收')).toBeInTheDocument()
|
||||
expect(screen.getByText(/内部订单号 9330278/)).toBeInTheDocument()
|
||||
expect(screen.getByText('审核要点(仅审核人可见)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '通过' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /一键打回/ })).toBeInTheDocument()
|
||||
const detail = await screen.findByRole('complementary')
|
||||
expect(within(detail).getByText('商家实收')).toBeInTheDocument()
|
||||
expect(within(detail).getByText(/内部订单号 9330278/)).toBeInTheDocument()
|
||||
expect(within(detail).getByText('审核要点(仅审核人可见)')).toBeInTheDocument()
|
||||
expect(within(detail).getByRole('button', { name: '通过' })).toBeInTheDocument()
|
||||
expect(within(detail).getByRole('button', { name: /一键打回/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('破价订单审核:列表和详情展示买家实付、平台承担与商家实收', async () => {
|
||||
render(<CustomTableView canEdit userId="user-a" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '破价订单审核' }))
|
||||
|
||||
expect(await screen.findByRole('columnheader', { name: /买家实付/ })).toBeInTheDocument()
|
||||
const row = await screen.findByText('BG006701N1063V01')
|
||||
const listRow = row.closest('tr')
|
||||
expect(listRow).not.toBeNull()
|
||||
expect(within(listRow as HTMLElement).getByText('¥337.20')).toBeInTheDocument()
|
||||
expect(within(listRow as HTMLElement).getByText('平台承担 ¥27.00')).toBeInTheDocument()
|
||||
expect(within(listRow as HTMLElement).getByText('-123.60')).toBeInTheDocument()
|
||||
fireEvent.click(row)
|
||||
|
||||
const detail = await screen.findByRole('complementary')
|
||||
expect(within(detail).getByText('买家实付')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('¥337.20')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('平台承担')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('¥27.00')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('商家实收')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('¥364.20')).toBeInTheDocument()
|
||||
expect(within(detail).getByText('-123.60')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('破价订单审核不展示上架时间、流程结束、实收区间和价差区间筛选', async () => {
|
||||
@ -772,7 +796,7 @@ describe('限价管理', () => {
|
||||
expect(document.querySelectorAll('input[type="number"]')).toHaveLength(0)
|
||||
expect(screen.queryByRole('button', { name: '筛选上架时间', exact: true })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '筛选流程结束日期', exact: true })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '筛选商家实收 / 限价', exact: true })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '筛选商家实收', exact: true })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('破价订单审核:无审核权限时只显示查询结果,不显示审核操作', async () => {
|
||||
@ -790,9 +814,10 @@ describe('限价管理', () => {
|
||||
const row = await screen.findByText('BG006701N1063V01')
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(await screen.findByText('商家实收')).toBeInTheDocument()
|
||||
expect(screen.queryByText('审核要点(仅审核人可见)')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '通过' })).not.toBeInTheDocument()
|
||||
const detail = await screen.findByRole('complementary')
|
||||
expect(within(detail).getByText('商家实收')).toBeInTheDocument()
|
||||
expect(within(detail).queryByText('审核要点(仅审核人可见)')).not.toBeInTheDocument()
|
||||
expect(within(detail).queryByRole('button', { name: '通过' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('快递测算已迁出限价管理页面', async () => {
|
||||
|
||||
@ -26,6 +26,53 @@ function emptyPayload() {
|
||||
}
|
||||
}
|
||||
|
||||
function penaltyPayload() {
|
||||
const row = {
|
||||
id: 'penalty-a',
|
||||
oId: 'O-A',
|
||||
oiId: '',
|
||||
soId: '',
|
||||
barcode: 'BARCODE-A',
|
||||
shopId: 'shop-1',
|
||||
shopSkuCode: 'SKU-A',
|
||||
itemId: 'item-1',
|
||||
shopName: '测试店铺',
|
||||
platform: 'Pinduoduo',
|
||||
ownerName: '运营甲',
|
||||
deptHead: '经管甲',
|
||||
profitCenter: '运营中心A',
|
||||
payAt: '2026-11-03T02:00:00.000Z',
|
||||
noticeAt: '2026-11-01T02:00:00.000Z',
|
||||
statTime: '2026-11-05T02:00:00.000Z',
|
||||
orderStatus: '已取消',
|
||||
quantity: 1,
|
||||
penaltyAmount: 10,
|
||||
status: 'PENDING_REVIEW',
|
||||
appealType: '商品编码有误',
|
||||
appealDesc: '测试凭证',
|
||||
appealImages: [],
|
||||
}
|
||||
return {
|
||||
...emptyPayload(),
|
||||
data: {
|
||||
...emptyPayload().data,
|
||||
penalties: [
|
||||
row,
|
||||
{ ...row, id: 'penalty-b', oId: 'O-B', shopSkuCode: 'SKU-B' },
|
||||
],
|
||||
canReviewPenalty: true,
|
||||
viewerName: '经管甲',
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
total: 2,
|
||||
totalPages: 1,
|
||||
},
|
||||
dataSourceStatus: 'READY',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
window.localStorage.clear()
|
||||
@ -94,6 +141,105 @@ describe('DiscontinuationView filters', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('submits a review for all checked penalty rows', async () => {
|
||||
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/filter-options?')) {
|
||||
return { ok: true, json: async () => ({ success: true, data: { values: [] } }) }
|
||||
}
|
||||
if (url.endsWith('/penalties/penalty-a') && !init?.method) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
appealType: '商品编码有误',
|
||||
appealDesc: '测试凭证',
|
||||
appealImages: [],
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (url.endsWith('/penalties/penalty-a') && init?.method === 'POST') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { status: 'APPROVED', covered: 2, scope: 'SELECTED' },
|
||||
}),
|
||||
}
|
||||
}
|
||||
return { ok: true, json: async () => penaltyPayload() }
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
render(<DiscontinuationView userId="manager-1" canReview />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '未下架监控与申诉' }))
|
||||
await screen.findByText('O-A')
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '勾选明细 O-A' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '勾选明细 O-B' }))
|
||||
fireEvent.click(screen.getByText('O-A'))
|
||||
|
||||
const passButton = await screen.findByRole('button', { name: '通过并减免(2)' })
|
||||
await waitFor(() => expect(passButton).not.toBeDisabled())
|
||||
fireEvent.click(passButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const reviewCall = (global.fetch as jest.Mock).mock.calls.find(([url, init]) =>
|
||||
String(url).endsWith('/penalties/penalty-a') && init?.method === 'POST')
|
||||
expect(JSON.parse(String(reviewCall?.[1]?.body))).toEqual({
|
||||
action: 'review',
|
||||
decision: 'PASS',
|
||||
note: '',
|
||||
scope: 'SELECTED',
|
||||
ids: ['penalty-a', 'penalty-b'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes only checked departed-owner rows to department heads', async () => {
|
||||
const payload = penaltyPayload()
|
||||
payload.data.penalties = payload.data.penalties.map((row) => ({
|
||||
...row,
|
||||
status: 'LEFT_EMPLOYEE',
|
||||
ownerName: '离职运营',
|
||||
}))
|
||||
payload.data.penaltyStatusCounts = { LEFT_EMPLOYEE: 2 }
|
||||
payload.data.penaltyAllTotal = 2
|
||||
|
||||
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/filter-options?')) {
|
||||
return { ok: true, json: async () => ({ success: true, data: { values: [] } }) }
|
||||
}
|
||||
if (url.endsWith('/penalties/push-department-heads') && init?.method === 'POST') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { sent: [{ deptHead: '经管甲', count: 2, taskId: 1 }], failed: [], pushedPenaltyCount: 2 },
|
||||
}),
|
||||
}
|
||||
}
|
||||
return { ok: true, json: async () => payload }
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
render(<DiscontinuationView userId="manager-1" canReview />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '未下架监控与申诉' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^人员已离职/ }))
|
||||
await screen.findByText('O-A')
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '勾选明细 O-A' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '勾选明细 O-B' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量推送部门负责人(2)' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const pushCall = (global.fetch as jest.Mock).mock.calls.find(([url, init]) =>
|
||||
String(url).endsWith('/penalties/push-department-heads') && init?.method === 'POST')
|
||||
expect(JSON.parse(String(pushCall?.[1]?.body))).toEqual({
|
||||
ids: ['penalty-a', 'penalty-b'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('starts all-snapshot regeneration from the page header', async () => {
|
||||
let job: Record<string, unknown> | null = null
|
||||
global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
|
||||
@ -397,6 +397,7 @@ export function DiscontinuationView({
|
||||
defaultPenaltySettings,
|
||||
)
|
||||
const [selectedPenaltyId, setSelectedPenaltyId] = useState<string | null>(null)
|
||||
const [selectedPenaltyIds, setSelectedPenaltyIds] = useState<string[]>([])
|
||||
const [appealType, setAppealType] = useState<string>(
|
||||
DEFAULT_DISCONTINUATION_APPEAL_REASON,
|
||||
)
|
||||
@ -549,6 +550,7 @@ export function DiscontinuationView({
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setPageNumber(1)
|
||||
setSelectedPenaltyIds([])
|
||||
setApplied({ ...filters })
|
||||
}
|
||||
|
||||
@ -557,6 +559,7 @@ export function DiscontinuationView({
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setPageNumber(1)
|
||||
setSelectedPenaltyIds([])
|
||||
setFilters(next)
|
||||
setApplied(next)
|
||||
}
|
||||
@ -587,6 +590,7 @@ export function DiscontinuationView({
|
||||
const changePage = (nextPage: number) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setSelectedPenaltyIds([])
|
||||
setPageNumber(nextPage)
|
||||
}
|
||||
|
||||
@ -602,6 +606,16 @@ export function DiscontinuationView({
|
||||
&& inDateRange(link.statDate),
|
||||
)
|
||||
const penaltyRows = payload?.penalties ?? []
|
||||
const selectedPenaltyIdSet = new Set(selectedPenaltyIds)
|
||||
const selectedPenaltyRows = penaltyRows.filter((row) =>
|
||||
selectedPenaltyIdSet.has(row.id))
|
||||
const selectedLeftEmployeeIds = selectedPenaltyRows
|
||||
.filter((row) => row.status === 'LEFT_EMPLOYEE')
|
||||
.map((row) => row.id)
|
||||
const pageHasLeftEmployeeRows = penaltyRows.some((row) =>
|
||||
row.status === 'LEFT_EMPLOYEE')
|
||||
const allPenaltyRowsSelected = penaltyRows.length > 0
|
||||
&& penaltyRows.every((row) => selectedPenaltyIdSet.has(row.id))
|
||||
const selectedPenalty =
|
||||
penaltyRows.find((row) => row.id === selectedPenaltyId) ?? null
|
||||
const canReviewPenalty = payload?.canReviewPenalty ?? initialCanReview
|
||||
@ -615,6 +629,25 @@ export function DiscontinuationView({
|
||||
return definition ? [definition] : []
|
||||
})
|
||||
|
||||
const togglePenaltySelection = (id: string) => {
|
||||
setSelectedPenaltyIds((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id],
|
||||
)
|
||||
}
|
||||
|
||||
const toggleAllPenaltyRows = () => {
|
||||
setSelectedPenaltyIds((current) => {
|
||||
const pageIds = penaltyRows.map((row) => row.id)
|
||||
const allSelected = pageIds.length > 0
|
||||
&& pageIds.every((id) => current.includes(id))
|
||||
return allSelected
|
||||
? current.filter((id) => !pageIds.includes(id))
|
||||
: Array.from(new Set([...current, ...pageIds]))
|
||||
})
|
||||
}
|
||||
|
||||
const penaltyDetail = usePenaltyAppealDetail(selectedPenaltyId, (detail) => {
|
||||
const images = Array.isArray(detail.appealImages) ? detail.appealImages : []
|
||||
setAppealType(normalizeDiscontinuationAppealReason(detail.appealType))
|
||||
@ -624,6 +657,10 @@ export function DiscontinuationView({
|
||||
})
|
||||
|
||||
const openPenalty = (row: PenaltyRow) => {
|
||||
setSelectedPenaltyIds((current) => {
|
||||
if (!current.length) return [row.id]
|
||||
return current.includes(row.id) ? current : [row.id]
|
||||
})
|
||||
setSelectedPenaltyId(row.id)
|
||||
setAppealType(normalizeDiscontinuationAppealReason(row.appealType))
|
||||
setAppealDesc(row.appealDesc || '')
|
||||
@ -755,6 +792,7 @@ export function DiscontinuationView({
|
||||
|
||||
const submitPenaltyReview = async (decision: 'PASS' | 'REJECT' | 'RETURN') => {
|
||||
if (!selectedPenalty || !penaltyDetail.ready || penaltyActionBusy) return
|
||||
const useSelectedBatch = selectedPenaltyRows.length > 1
|
||||
setPenaltyActionBusy(true)
|
||||
setPenaltyActionMessage('')
|
||||
try {
|
||||
@ -763,20 +801,29 @@ export function DiscontinuationView({
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'review', decision, note: reviewNote, scope: reviewScope }),
|
||||
body: JSON.stringify({
|
||||
action: 'review',
|
||||
decision,
|
||||
note: reviewNote,
|
||||
scope: useSelectedBatch ? 'SELECTED' : reviewScope,
|
||||
...(useSelectedBatch ? { ids: selectedPenaltyRows.map((row) => row.id) } : {}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
const body = await response.json()
|
||||
if (!response.ok || !body?.success) {
|
||||
throw new Error(body?.error?.message || '审核失败')
|
||||
}
|
||||
const covered = Number(body.data?.covered ?? 1)
|
||||
setPenaltyActionMessage(
|
||||
decision === 'PASS'
|
||||
(decision === 'PASS'
|
||||
? '审核通过,乐捐金额已自动减免'
|
||||
: decision === 'REJECT'
|
||||
? '已驳回,继续计入乐捐'
|
||||
: '已打回运营重新申诉',
|
||||
: '已打回运营重新申诉')
|
||||
+ (useSelectedBatch || covered > 1 ? `,共处理 ${covered} 条` : ''),
|
||||
)
|
||||
if (useSelectedBatch) setSelectedPenaltyIds([])
|
||||
setRefreshTick((value) => value + 1)
|
||||
} catch (error) {
|
||||
setPenaltyActionMessage(error instanceof Error ? error.message : '审核失败')
|
||||
@ -787,6 +834,7 @@ export function DiscontinuationView({
|
||||
|
||||
const cancelPenaltyReject = async () => {
|
||||
if (!selectedPenalty || !penaltyDetail.ready || penaltyActionBusy) return
|
||||
const useSelectedBatch = selectedPenaltyRows.length > 1
|
||||
setPenaltyActionBusy(true)
|
||||
setPenaltyActionMessage('')
|
||||
try {
|
||||
@ -795,7 +843,11 @@ export function DiscontinuationView({
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'cancel-reject', scope: reviewScope }),
|
||||
body: JSON.stringify({
|
||||
action: 'cancel-reject',
|
||||
scope: useSelectedBatch ? 'SELECTED' : reviewScope,
|
||||
...(useSelectedBatch ? { ids: selectedPenaltyRows.map((row) => row.id) } : {}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
const body = await response.json()
|
||||
@ -805,6 +857,7 @@ export function DiscontinuationView({
|
||||
setPenaltyActionMessage(
|
||||
`已取消驳回,${body.data?.covered ?? 1} 条明细恢复为待审核`,
|
||||
)
|
||||
if (useSelectedBatch) setSelectedPenaltyIds([])
|
||||
setRefreshTick((value) => value + 1)
|
||||
} catch (error) {
|
||||
setPenaltyActionMessage(error instanceof Error ? error.message : '取消驳回失败')
|
||||
@ -814,7 +867,10 @@ export function DiscontinuationView({
|
||||
}
|
||||
|
||||
const pushLeftEmployees = async () => {
|
||||
if (!penaltyRows.length) return
|
||||
if (!selectedLeftEmployeeIds.length) {
|
||||
setPushMessage('请先勾选需要推送的人员已离职明细')
|
||||
return
|
||||
}
|
||||
setPushingLeftEmployees(true)
|
||||
setPushMessage('')
|
||||
try {
|
||||
@ -823,7 +879,7 @@ export function DiscontinuationView({
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: penaltyRows.map((row) => row.id) }),
|
||||
body: JSON.stringify({ ids: selectedLeftEmployeeIds }),
|
||||
},
|
||||
)
|
||||
const body = await response.json()
|
||||
@ -836,6 +892,8 @@ export function DiscontinuationView({
|
||||
`已推送 ${sent.length} 个部门负责人、${body.data?.pushedPenaltyCount ?? 0} 条明细`
|
||||
+ (failed.length ? `;${failed.length} 个失败:${failed.map((item: { deptHead: string; reason: string }) => `${item.deptHead}(${item.reason})`).join('、')}` : ''),
|
||||
)
|
||||
setSelectedPenaltyIds((current) =>
|
||||
current.filter((id) => !selectedLeftEmployeeIds.includes(id)))
|
||||
} catch (error) {
|
||||
setPushMessage(error instanceof Error ? error.message : '批量推送失败')
|
||||
} finally {
|
||||
@ -953,6 +1011,7 @@ export function DiscontinuationView({
|
||||
setFilters(nextFilters)
|
||||
setApplied(nextFilters)
|
||||
setPageNumber(1)
|
||||
setSelectedPenaltyIds([])
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
@ -1150,6 +1209,7 @@ export function DiscontinuationView({
|
||||
setFilters(next)
|
||||
setApplied(next)
|
||||
setPageNumber(1)
|
||||
setSelectedPenaltyIds([])
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@ -1201,14 +1261,21 @@ export function DiscontinuationView({
|
||||
<b>¥{Number(payload?.monthlyPenaltyTotal ?? 0).toFixed(2)}</b>
|
||||
<span style={{ color: '#78716c' }}>(¥10/单)</span>
|
||||
</span>
|
||||
{filters.status.includes('LEFT_EMPLOYEE') && canReviewPenalty && penaltyRows.length > 0 && (
|
||||
{canReviewPenalty && pageHasLeftEmployeeRows && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pushingLeftEmployees}
|
||||
disabled={pushingLeftEmployees || selectedLeftEmployeeIds.length === 0}
|
||||
onClick={() => void pushLeftEmployees()}
|
||||
style={{ ...primaryButtonStyle, background: '#1d4ed8', borderColor: '#1d4ed8', opacity: pushingLeftEmployees ? 0.55 : 1 }}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
background: '#1d4ed8',
|
||||
borderColor: '#1d4ed8',
|
||||
opacity: pushingLeftEmployees || selectedLeftEmployeeIds.length === 0 ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{pushingLeftEmployees ? '推送中…' : `批量推送部门负责人(${penaltyRows.length})`}
|
||||
{pushingLeftEmployees
|
||||
? '推送中…'
|
||||
: `批量推送部门负责人(${selectedLeftEmployeeIds.length})`}
|
||||
</button>
|
||||
)}
|
||||
{pushMessage ? <span style={{ color: pushMessage.includes('失败') ? '#b91c1c' : '#1d4ed8', fontSize: 12 }}>{pushMessage}</span> : null}
|
||||
@ -1257,6 +1324,22 @@ export function DiscontinuationView({
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa', color: '#71717a', textAlign: 'left' }}>
|
||||
<th style={{ ...cell, width: 38 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="勾选本页全部申诉明细"
|
||||
checked={allPenaltyRowsSelected}
|
||||
onChange={toggleAllPenaltyRows}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'block',
|
||||
accentColor: '#2f6bdf',
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
{visiblePenaltyColumns.map((column) => (
|
||||
<th key={column.key} style={cell}>{column.label}</th>
|
||||
))}
|
||||
@ -1266,9 +1349,36 @@ export function DiscontinuationView({
|
||||
{penaltyRows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
style={{ borderTop: '1px solid #f0f0f1', cursor: 'pointer' }}
|
||||
style={{
|
||||
borderTop: '1px solid #f0f0f1',
|
||||
cursor: 'pointer',
|
||||
background: selectedPenaltyIdSet.has(row.id) ? '#f0f6ff' : undefined,
|
||||
}}
|
||||
onClick={() => openPenalty(row)}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
...cell,
|
||||
width: 38,
|
||||
padding: penaltySettings.compact ? '5px 8px' : cell.padding,
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`勾选明细 ${row.oId}`}
|
||||
checked={selectedPenaltyIdSet.has(row.id)}
|
||||
onChange={() => togglePenaltySelection(row.id)}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'block',
|
||||
accentColor: '#2f6bdf',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
{visiblePenaltyColumns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
@ -1299,6 +1409,7 @@ export function DiscontinuationView({
|
||||
row={selectedPenalty}
|
||||
canSubmitAppeal={canSubmitAppealPenalty}
|
||||
canReview={canReviewPenalty}
|
||||
batchSelectedCount={selectedPenaltyRows.length}
|
||||
viewerName={viewerName}
|
||||
busy={penaltyActionBusy}
|
||||
detailLoading={penaltyDetail.loading}
|
||||
@ -1423,6 +1534,7 @@ function PenaltyAppealDrawer({
|
||||
row,
|
||||
canSubmitAppeal,
|
||||
canReview,
|
||||
batchSelectedCount,
|
||||
viewerName,
|
||||
busy,
|
||||
detailLoading,
|
||||
@ -1453,6 +1565,7 @@ function PenaltyAppealDrawer({
|
||||
row: PenaltyRow | null
|
||||
canSubmitAppeal: boolean
|
||||
canReview: boolean
|
||||
batchSelectedCount: number
|
||||
viewerName: string
|
||||
busy: boolean
|
||||
detailLoading: boolean
|
||||
@ -1483,6 +1596,7 @@ function PenaltyAppealDrawer({
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [shortcutAction, setShortcutAction] = useState<'RETURN' | 'REJECT' | null>(null)
|
||||
if (!row) return null
|
||||
const useSelectedBatch = batchSelectedCount > 1
|
||||
const canAppeal =
|
||||
canSubmitAppeal
|
||||
&& Boolean(row.ownerName)
|
||||
@ -1705,10 +1819,15 @@ function PenaltyAppealDrawer({
|
||||
{canAudit && (
|
||||
<section style={{ marginTop: 18, borderTop: '1px solid #e4e4e7', paddingTop: 16 }}>
|
||||
<h3 style={{ margin: '0 0 10px', fontSize: 13 }}>商品计划部审核</h3>
|
||||
{useSelectedBatch ? (
|
||||
<p style={{ margin: '0 0 10px', color: '#1d4ed8', fontSize: 11, fontWeight: 600 }}>
|
||||
已勾选 {batchSelectedCount} 条明细,通过、打回或驳回将批量处理这些明细。
|
||||
</p>
|
||||
) : null}
|
||||
<p style={{ margin: '0 0 9px', color: '#71717a', fontSize: 11 }}>
|
||||
审核意见为空时才能直接通过;选择打回或驳回原因后,通过按钮会禁用。
|
||||
</p>
|
||||
<label
|
||||
{!useSelectedBatch ? <label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
@ -1734,7 +1853,7 @@ function PenaltyAppealDrawer({
|
||||
勾选后,同一平台店铺商品编码下的全部待审核订单会使用相同结果一次审核。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label> : null}
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<span style={{ display: 'block', marginBottom: 6, color: '#a16207', fontSize: 11, fontWeight: 600 }}>打回快捷原因</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
@ -1794,10 +1913,10 @@ function PenaltyAppealDrawer({
|
||||
cursor: canPass ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
通过并减免
|
||||
通过并减免{useSelectedBatch ? `(${batchSelectedCount})` : ''}
|
||||
</button>
|
||||
<button type="button" disabled={busy || !canReturn} onClick={() => onSubmitReview('RETURN')} style={{ ...secondaryButtonStyle, opacity: canReturn ? 1 : 0.42, cursor: canReturn ? 'pointer' : 'not-allowed' }}>打回补充</button>
|
||||
<button type="button" disabled={busy || !canReject} onClick={() => onSubmitReview('REJECT')} style={{ ...secondaryButtonStyle, color: '#b91c1c', borderColor: '#fecaca', opacity: canReject ? 1 : 0.42, cursor: canReject ? 'pointer' : 'not-allowed' }}>驳回</button>
|
||||
<button type="button" disabled={busy || !canReturn} onClick={() => onSubmitReview('RETURN')} style={{ ...secondaryButtonStyle, opacity: canReturn ? 1 : 0.42, cursor: canReturn ? 'pointer' : 'not-allowed' }}>打回补充{useSelectedBatch ? `(${batchSelectedCount})` : ''}</button>
|
||||
<button type="button" disabled={busy || !canReject} onClick={() => onSubmitReview('REJECT')} style={{ ...secondaryButtonStyle, color: '#b91c1c', borderColor: '#fecaca', opacity: canReject ? 1 : 0.42, cursor: canReject ? 'pointer' : 'not-allowed' }}>驳回{useSelectedBatch ? `(${batchSelectedCount})` : ''}</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@ -1805,10 +1924,15 @@ function PenaltyAppealDrawer({
|
||||
{canReview && row.status === 'REJECTED' && (
|
||||
<section style={{ marginTop: 18, borderTop: '1px solid #e4e4e7', paddingTop: 16 }}>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: 13 }}>驳回处理</h3>
|
||||
{useSelectedBatch ? (
|
||||
<p style={{ margin: '0 0 10px', color: '#1d4ed8', fontSize: 11, fontWeight: 600 }}>
|
||||
已勾选 {batchSelectedCount} 条明细,取消驳回将批量恢复其中所有已驳回明细。
|
||||
</p>
|
||||
) : null}
|
||||
<p style={{ margin: '0 0 10px', color: '#71717a', fontSize: 11 }}>
|
||||
取消驳回后,该条明细将恢复为“待审核”,可重新审核。
|
||||
</p>
|
||||
<label
|
||||
{!useSelectedBatch ? <label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
@ -1834,14 +1958,16 @@ function PenaltyAppealDrawer({
|
||||
勾选后,同一平台店铺商品编码下的全部已驳回明细会一起恢复为待审核。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label> : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onCancelReject}
|
||||
style={{ ...primaryButtonStyle, background: '#1d4ed8', borderColor: '#1d4ed8', opacity: busy ? 0.5 : 1 }}
|
||||
>
|
||||
{busy ? '处理中…' : '取消驳回'}
|
||||
{busy
|
||||
? '处理中…'
|
||||
: `取消驳回${useSelectedBatch ? `(${batchSelectedCount})` : ''}`}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DepartmentPermissionPanel } from './DepartmentPermissionPanel'
|
||||
|
||||
jest.mock('./UnifiedPermissionMatrix', () => ({
|
||||
UnifiedPermissionMatrix: ({ supplyRoleSubjectKey }: { supplyRoleSubjectKey?: string }) => (
|
||||
<div>权限主体:{supplyRoleSubjectKey}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DepartmentPermissionPanel', () => {
|
||||
it('uses the selected department as the permission principal', async () => {
|
||||
const actor = userEvent.setup()
|
||||
render(
|
||||
<DepartmentPermissionPanel
|
||||
loading={false}
|
||||
error={null}
|
||||
departments={[
|
||||
{
|
||||
id: 'dept-supply',
|
||||
name: '供应链中心',
|
||||
fullPath: '供应链中心',
|
||||
children: [{
|
||||
id: 'dept-operation',
|
||||
name: '运营部',
|
||||
fullPath: '供应链中心 / 运营部',
|
||||
}],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('权限主体:department@dept-supply')).toBeInTheDocument()
|
||||
await actor.click(screen.getByRole('button', { name: /运营部/ }))
|
||||
expect(screen.getByText('权限主体:department@dept-operation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Building2, Search } from 'lucide-react'
|
||||
import type { PermissionDepartment } from './usePermissionManagedUsers'
|
||||
import { UnifiedPermissionMatrix } from './UnifiedPermissionMatrix'
|
||||
|
||||
type DepartmentOption = {
|
||||
id: string
|
||||
label: string
|
||||
depth: number
|
||||
userCount: number
|
||||
}
|
||||
|
||||
function flattenDepartments(
|
||||
departments: readonly PermissionDepartment[],
|
||||
depth = 0,
|
||||
): DepartmentOption[] {
|
||||
return departments.flatMap((department) => [
|
||||
{
|
||||
id: department.id,
|
||||
label: department.name,
|
||||
depth,
|
||||
userCount: department.summary?.totalUserCount ?? department.users?.length ?? 0,
|
||||
},
|
||||
...flattenDepartments(department.children ?? [], depth + 1),
|
||||
])
|
||||
}
|
||||
|
||||
export function DepartmentPermissionPanel({
|
||||
departments,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
departments: readonly PermissionDepartment[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}) {
|
||||
const options = useMemo(() => flattenDepartments(departments), [departments])
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedId, setSelectedId] = useState(options[0]?.id ?? '')
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase('zh-CN')
|
||||
return normalized
|
||||
? options.filter((option) => option.label.toLocaleLowerCase('zh-CN').includes(normalized))
|
||||
: options
|
||||
}, [options, query])
|
||||
const selected = options.find((option) => option.id === selectedId)
|
||||
?? options[0]
|
||||
?? null
|
||||
|
||||
return (
|
||||
<section className="grid h-full min-h-0 grid-cols-[250px_minmax(0,1fr)] overflow-hidden bg-[#f5f7f6]">
|
||||
<aside className="flex min-h-0 flex-col border-r border-[#e2e7e6] bg-white">
|
||||
<div className="border-b border-[#edf0ef] px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-[#607a75]" />
|
||||
<p className="text-[14px] font-black text-[#263536]">部门权限</p>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-[#7e8d8d]">按部门配置供应链中心查看、导出和操作权限</p>
|
||||
</div>
|
||||
<div className="border-b border-[#edf0ef] p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa6a6]" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索部门"
|
||||
className="h-9 w-full rounded-[7px] border border-[#e4e8e7] bg-[#fafbfb] pl-9 pr-3 text-[12px] outline-none focus:border-[#9aabaa]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{loading ? (
|
||||
<p className="px-3 py-8 text-center text-[11px] text-[#8b9998]">正在读取部门…</p>
|
||||
) : error ? (
|
||||
<p className="px-3 py-4 text-[11px] text-[#a65f59]">{error}</p>
|
||||
) : filteredOptions.length ? filteredOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(option.id)}
|
||||
className={`mb-1 flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-[11px] font-bold ${
|
||||
selected?.id === option.id
|
||||
? 'bg-[#e8f0ed] text-[#38534b]'
|
||||
: 'text-[#667573] hover:bg-[#f3f6f5]'
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + option.depth * 14}px` }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<span className="text-[10px] font-normal text-[#9aa6a5]">{option.userCount}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<p className="px-3 py-8 text-center text-[11px] text-[#8b9998]">没有匹配的部门</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex min-h-0 min-w-0 flex-col p-3">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="shrink-0 rounded-t-[8px] border border-b-0 border-[#e2e7e6] bg-white px-5 py-4">
|
||||
<p className="text-[15px] font-black text-[#263536]">{selected.label} · 供应链权限</p>
|
||||
<p className="mt-1 text-[11px] text-[#7e8d8d]">
|
||||
该部门账号继承此配置;个人权限可在账号管理中单独覆盖。
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[8px] border border-[#e2e7e6] bg-white">
|
||||
<UnifiedPermissionMatrix
|
||||
mode="role"
|
||||
supplyRoleSubjectKey={`department@${selected.id}`}
|
||||
section="supply"
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-[8px] border border-[#e2e7e6] bg-white text-[12px] text-[#879493]">
|
||||
请选择一个部门
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -17,7 +17,16 @@ jest.mock('@/hooks/useAuth', () => ({
|
||||
}))
|
||||
|
||||
jest.mock('./usePermissionManagedUsers', () => ({
|
||||
usePermissionManagedUsers: () => ({ users: [], loading: false, error: null }),
|
||||
usePermissionManagedUsers: () => ({
|
||||
users: [],
|
||||
departments: [{
|
||||
id: 'dept-1',
|
||||
name: '供应链中心',
|
||||
fullPath: '供应链中心',
|
||||
}],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('./usePermissionRoles', () => ({
|
||||
@ -32,6 +41,10 @@ jest.mock('./RoleManagementPanel', () => ({
|
||||
RoleManagementPanel: () => <div>岗位列表内容</div>,
|
||||
}))
|
||||
|
||||
jest.mock('./DepartmentPermissionPanel', () => ({
|
||||
DepartmentPermissionPanel: () => <div>部门权限内容</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/users/CreateUserModal', () => ({ CreateUserModal: () => null }))
|
||||
jest.mock('@/components/users/EditUserModal', () => ({ EditUserModal: () => null }))
|
||||
|
||||
@ -49,6 +62,8 @@ describe('UserPermissionCenter', () => {
|
||||
await actor.click(screen.getByRole('tab', { name: '岗位管理' }))
|
||||
expect(screen.getByText('岗位列表内容')).toBeInTheDocument()
|
||||
expect(screen.queryByText('账号列表内容')).not.toBeInTheDocument()
|
||||
await actor.click(screen.getByRole('tab', { name: '部门权限' }))
|
||||
expect(screen.getByText('部门权限内容')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to the page that opened user permission management', async () => {
|
||||
|
||||
@ -12,6 +12,7 @@ import { getAssignableRolesForRole } from '@/lib/role-scope'
|
||||
import { resolveUserPermissionRoleId } from '@/lib/permission-roles'
|
||||
import { AccountManagementPanel } from './AccountManagementPanel'
|
||||
import { AccountPermissionDetail } from './AccountPermissionDetail'
|
||||
import { DepartmentPermissionPanel } from './DepartmentPermissionPanel'
|
||||
import { RoleManagementPanel } from './RoleManagementPanel'
|
||||
import { UserPermissionTabs, type UserPermissionView } from './UserPermissionTabs'
|
||||
import { usePermissionManagedUsers, type PermissionManagedUser } from './usePermissionManagedUsers'
|
||||
@ -92,7 +93,13 @@ export function UserPermissionCenter() {
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-hidden">
|
||||
{activeView === 'roles' ? (
|
||||
{activeView === 'departments' ? (
|
||||
<DepartmentPermissionPanel
|
||||
departments={departments}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
) : activeView === 'roles' ? (
|
||||
<RoleManagementPanel
|
||||
roles={permissionRoles}
|
||||
loading={permissionRolesLoading}
|
||||
|
||||
@ -3,11 +3,12 @@ import userEvent from '@testing-library/user-event'
|
||||
import { UserPermissionTabs } from './UserPermissionTabs'
|
||||
|
||||
describe('UserPermissionTabs', () => {
|
||||
it('shows account and role management without a separate directory tab', () => {
|
||||
it('shows account, role and department permission tabs', () => {
|
||||
render(<UserPermissionTabs activeView="accounts" onChange={jest.fn()} />)
|
||||
|
||||
expect(screen.getByRole('tab', { name: '账号管理' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '岗位管理' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '部门权限' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: '组织花名册' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('产品开发节点权限')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('供应链开发权限')).not.toBeInTheDocument()
|
||||
@ -21,4 +22,13 @@ describe('UserPermissionTabs', () => {
|
||||
await actor.click(screen.getByRole('tab', { name: '岗位管理' }))
|
||||
expect(onChange).toHaveBeenCalledWith('roles')
|
||||
})
|
||||
|
||||
it('switches to department permission management', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
render(<UserPermissionTabs activeView="accounts" onChange={onChange} />)
|
||||
|
||||
await actor.click(screen.getByRole('tab', { name: '部门权限' }))
|
||||
expect(onChange).toHaveBeenCalledWith('departments')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { BriefcaseBusiness, Users } from 'lucide-react'
|
||||
import { BriefcaseBusiness, Building2, Users } from 'lucide-react'
|
||||
|
||||
export type UserPermissionView = 'accounts' | 'roles'
|
||||
export type UserPermissionView = 'accounts' | 'roles' | 'departments'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'accounts', label: '账号管理', icon: Users },
|
||||
{ key: 'roles', label: '岗位管理', icon: BriefcaseBusiness },
|
||||
{ key: 'departments', label: '部门权限', icon: Building2 },
|
||||
] as const
|
||||
|
||||
export function UserPermissionTabs({
|
||||
|
||||
@ -268,7 +268,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
|
||||
</Link>
|
||||
<DownloadCenterButton />
|
||||
</>}
|
||||
<UserMenu user={user ?? null} />
|
||||
{!isSupplyChain ? <UserMenu user={user ?? null} /> : null}
|
||||
</div>
|
||||
{pendingNavItem ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[2px] overflow-hidden bg-black/[0.04]">
|
||||
|
||||
@ -194,6 +194,7 @@ describe('AppTopNav', () => {
|
||||
expect(screen.queryByRole('button', { name: '打开导航' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '切换系统,当前为供应链中心' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '更新日志' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '用户菜单' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('BOM 核算入口按权限显示(非产品开发路径)', () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { buildBreachLinkGroupKey } from '@/lib/supply-chain-breach-order'
|
||||
import { deriveNonOwnerLimitPrice } from '@/lib/supply-chain-limit-price-fields'
|
||||
|
||||
/**
|
||||
* 破价单生成:换货识别 + 限价判定(service 与预演脚本共用,保证口径一致)。
|
||||
@ -6,8 +7,8 @@ import { buildBreachLinkGroupKey } from '@/lib/supply-chain-breach-order'
|
||||
* 业务口径(已与业务确认):
|
||||
* - 手工换货单:聚水潭明细里表现为「被替换行(item_status=Replaced,数量归 0)」
|
||||
* + 「换入行(item_ext_data 含 item_source=手工换货/手工添加,金额与数量落在这一行)」。
|
||||
* - 判定按「换出行原编码」的限价执行;成交数量与商家实收取换入行(抓不到按 0 参与判定,
|
||||
* 即页面上显示 ¥0.00,由运营申诉说明)。
|
||||
* - 判定按「换出行原编码」的限价执行;成交数量与买家实付取换入行。
|
||||
* - 订单商品负责人与链接负责人一致时使用负责人限价;不一致时使用非负责人限价。
|
||||
* - 换货行配对成功后不再按换入编码单独生成,避免同一订单重复生成两条破价单。
|
||||
*/
|
||||
|
||||
@ -27,11 +28,13 @@ export type BreachGenerationLine = {
|
||||
refundStatus: string
|
||||
/** 订单主表商家实收(换货单兜底金额) */
|
||||
orderSellerIncomeAmount?: number | null
|
||||
/** 订单主表买家实付(换货单次兜底金额) */
|
||||
/** 订单主表买家实付(仅用于换货行商家实收缺失时兜底,不替代明细买家实付) */
|
||||
orderPaidAmount?: number | null
|
||||
itemStatus: string
|
||||
itemSource: string
|
||||
skuUuid: string | null
|
||||
/** 链接维度负责人(店铺款式编码 → 负责人),由生成服务按源数据补齐。 */
|
||||
linkOwnerName?: string | null
|
||||
/** 内部标记:该行为手工换货配对后按原编码生成的判定行 */
|
||||
fromManualSwap?: boolean
|
||||
}
|
||||
@ -61,6 +64,8 @@ export type BreachGenerationAuthContext = {
|
||||
barcodeToStyle: Map<string, string>
|
||||
/** 货号 → 负责人限价 */
|
||||
ownerLimitByStyle: Map<string, number>
|
||||
/** 商品编码(条码) → 订单商品负责人 */
|
||||
orderOwnerByBarcode: Map<string, string>
|
||||
/** 货号 → 价格授权列表 */
|
||||
authsByStyle: Map<string, BreachGenerationAuth[]>
|
||||
}
|
||||
@ -104,7 +109,7 @@ export type BreachGenerationStats = {
|
||||
swapOrderAmountFallback: number
|
||||
/** 进入限价匹配的判定行数(正常行 + 换货行) */
|
||||
scanned: number
|
||||
/** 命中破价(实收 < 限价×数量)的行数 */
|
||||
/** 命中破价(买家实付 < 限价×数量)的行数 */
|
||||
breached: number
|
||||
/** 因没有限价记录而被跳过的行数 */
|
||||
skippedNoLimit: number
|
||||
@ -119,6 +124,16 @@ type SwapPairResult = {
|
||||
orderAmountFallback: number
|
||||
}
|
||||
|
||||
function normalizeOwnerName(value: string | null | undefined) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[\s\u200B-\u200D\uFEFF]/g, '')
|
||||
}
|
||||
|
||||
function normalizeCode(value: string | null | undefined) {
|
||||
return String(value ?? '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
/** 手工换货配对:优先按 sku_uuid 精确配对,剩余行仅在「一对一」时按顺序配对。 */
|
||||
function pairManualSwapLines(lines: BreachGenerationLine[]): SwapPairResult {
|
||||
const consumed = new Set<BreachGenerationLine>()
|
||||
@ -136,16 +151,16 @@ function pairManualSwapLines(lines: BreachGenerationLine[]): SwapPairResult {
|
||||
const mergeSwapPair = (replaced: BreachGenerationLine, incoming: BreachGenerationLine[]): BreachGenerationLine => {
|
||||
const incomingIncome = incoming.reduce((sum, line) => sum + Number(line.sellerIncomeAmount || 0), 0)
|
||||
const incomingPaid = incoming.reduce((sum, line) => sum + Number(line.buyerPaidAmount || 0), 0)
|
||||
// 换货不改变订单成交金额:优先用换入行实收;换入行抓不到时,回退订单主表金额(商家实收→买家实付)。
|
||||
// 换货不改变订单成交金额:商家实收优先取换入行;抓不到时回退订单主表金额。
|
||||
// 买家实付始终保留换入明细值,避免把整单金额误当成商品行金额。
|
||||
let sellerIncomeAmount = incomingIncome
|
||||
let buyerPaidAmount = incomingPaid
|
||||
const buyerPaidAmount = incomingPaid
|
||||
if (!(sellerIncomeAmount > 0)) {
|
||||
const orderIncome = Number(replaced.orderSellerIncomeAmount || 0)
|
||||
const orderPaid = Number(replaced.orderPaidAmount || 0)
|
||||
if (orderIncome > 0) sellerIncomeAmount = orderIncome
|
||||
else if (orderPaid > 0) sellerIncomeAmount = orderPaid
|
||||
else sellerIncomeAmount = 0
|
||||
if (buyerPaidAmount <= 0) buyerPaidAmount = orderPaid
|
||||
orderAmountFallback += 1
|
||||
}
|
||||
return {
|
||||
@ -199,7 +214,7 @@ function pairManualSwapLines(lines: BreachGenerationLine[]): SwapPairResult {
|
||||
|
||||
/**
|
||||
* 根据订单明细与限价表生成破价单候选(不写库)。
|
||||
* 同一 (订单, 商品编码) 的多行先聚合,再按「商家实收 < 限价×数量」判定。
|
||||
* 同一 (订单, 商品编码) 的多行先聚合,再按「买家实付 < 限价×数量」判定。
|
||||
*/
|
||||
export function buildBreachOrderCandidates(
|
||||
lines: BreachGenerationLine[],
|
||||
@ -248,22 +263,28 @@ export function buildBreachOrderCandidates(
|
||||
stats.scanned += 1
|
||||
// —— 限价来源(业务口径:以流程为准)——
|
||||
// 优先使用限价流程同步的「货号负责人限价」;流程中没有该货号时,回退到店铺限价(台账)。
|
||||
const styleCode = authContext ? authContext.barcodeToStyle.get(String(row.itemCode ?? '').trim()) : undefined
|
||||
const normalizedItemCode = normalizeCode(row.itemCode)
|
||||
const styleCode = authContext ? authContext.barcodeToStyle.get(normalizedItemCode) : undefined
|
||||
const flowUnitPrice = styleCode ? authContext?.ownerLimitByStyle.get(styleCode) ?? null : null
|
||||
const linkLimit = limitMap.get(`${row.shopName}::${row.itemCode}`)
|
||||
const baseUnitPrice = flowUnitPrice ?? (linkLimit ? Number(linkLimit.limitUnitPrice) : null)
|
||||
if (baseUnitPrice === null) {
|
||||
const ownerUnitPrice = flowUnitPrice ?? (linkLimit ? Number(linkLimit.limitUnitPrice) : null)
|
||||
if (ownerUnitPrice === null) {
|
||||
stats.skippedNoLimit += 1
|
||||
if (row.fromManualSwap) stats.swapSkippedNoLimit += 1
|
||||
continue
|
||||
}
|
||||
const orderOwnerName = normalizeOwnerName(authContext?.orderOwnerByBarcode.get(normalizedItemCode))
|
||||
const linkOwnerName = normalizeOwnerName(row.linkOwnerName)
|
||||
const hasComparableOwners = Boolean(orderOwnerName && linkOwnerName)
|
||||
const ownerMatches = !hasComparableOwners || orderOwnerName === linkOwnerName
|
||||
const qty = Number(row.qty)
|
||||
const buyerPaid = Number(row.buyerPaidAmount || 0)
|
||||
const sellerIncome = Number(row.sellerIncomeAmount || 0)
|
||||
|
||||
// —— 流程授权豁免(同价申请 / 活动限价)——
|
||||
// 同价期内:按负责人限价判定,实收 ≥ 负责人限价 → 豁免;超期后恢复非负责人限价基准。
|
||||
// 活动期内:实收 ≥ 调整后限价 → 豁免;低于调整后限价则按调整后限价判定。
|
||||
let effectiveUnitPrice = baseUnitPrice
|
||||
// 同价期内:按负责人限价判定,买家实付 ≥ 负责人限价 → 豁免;超期后恢复非负责人限价基准。
|
||||
// 活动期内:买家实付 ≥ 调整后限价 → 豁免;低于调整后限价则按调整后限价判定。
|
||||
let effectiveUnitPrice = ownerMatches ? ownerUnitPrice : deriveNonOwnerLimitPrice(ownerUnitPrice)
|
||||
if (authContext && styleCode) {
|
||||
const auths = authContext.authsByStyle.get(styleCode)
|
||||
if (auths?.length) {
|
||||
@ -277,12 +298,12 @@ export function buildBreachOrderCandidates(
|
||||
const start = auth.approvedAt?.getTime() ?? Number.NaN
|
||||
const end = auth.endDate?.getTime() ?? Number.NaN
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && payTime >= start && payTime <= end) {
|
||||
if (sellerIncome >= ownerPrice) exempted = true
|
||||
if (buyerPaid >= ownerPrice) exempted = true
|
||||
else effectiveUnitPrice = ownerPrice
|
||||
break
|
||||
}
|
||||
if (Number.isFinite(end) && payTime > end) {
|
||||
effectiveUnitPrice = ownerPrice + Math.max(ownerPrice * 0.05, 10)
|
||||
effectiveUnitPrice = deriveNonOwnerLimitPrice(ownerPrice)
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -290,7 +311,7 @@ export function buildBreachOrderCandidates(
|
||||
const start = auth.startDate?.getTime() ?? Number.NaN
|
||||
const end = auth.endDate?.getTime() ?? Number.NaN
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && payTime >= start && payTime <= end) {
|
||||
if (sellerIncome >= auth.adjustedLimitPrice) exempted = true
|
||||
if (buyerPaid >= auth.adjustedLimitPrice) exempted = true
|
||||
else effectiveUnitPrice = auth.adjustedLimitPrice
|
||||
break
|
||||
}
|
||||
@ -305,9 +326,9 @@ export function buildBreachOrderCandidates(
|
||||
}
|
||||
|
||||
const limitTotal = effectiveUnitPrice * qty
|
||||
if (!(sellerIncome < limitTotal)) continue
|
||||
if (!(buyerPaid < limitTotal)) continue
|
||||
stats.breached += 1
|
||||
const platformBear = Math.max(0, Number((sellerIncome - Number(row.buyerPaidAmount || 0)).toFixed(4)))
|
||||
const platformBear = Math.max(0, Number((sellerIncome - buyerPaid).toFixed(4)))
|
||||
candidates.push({
|
||||
oId: row.oId,
|
||||
platform: row.platform || '',
|
||||
@ -324,13 +345,13 @@ export function buildBreachOrderCandidates(
|
||||
itemCode: row.itemCode,
|
||||
}),
|
||||
qty: Number(row.qty),
|
||||
buyerPaidAmount: Number(row.buyerPaidAmount || 0),
|
||||
buyerPaidAmount: buyerPaid,
|
||||
platformBearAmount: platformBear,
|
||||
sellerIncomeAmount: sellerIncome,
|
||||
limitUnitPrice: effectiveUnitPrice,
|
||||
limitTotal,
|
||||
gapAmount: Number((sellerIncome - limitTotal).toFixed(4)),
|
||||
ownerName: linkLimit?.ownerName || '',
|
||||
gapAmount: Number((buyerPaid - limitTotal).toFixed(4)),
|
||||
ownerName: orderOwnerName || linkLimit?.ownerName || linkOwnerName,
|
||||
orderStatus: row.orderStatus || '',
|
||||
refundStatus: row.refundStatus || '',
|
||||
listedAt: linkLimit?.listedAt ?? null,
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
* - 交付包路由使用命名占位符(:period / :cat),需开 namedPlaceholders。
|
||||
* - 远程慢库(单查 2~7s、聚合更久):连接数收紧为 3,单查询超时 60s。
|
||||
*/
|
||||
import { createPool, type Pool } from 'mysql2/promise';
|
||||
import { createPool, type Pool, type QueryOptions } from 'mysql2/promise';
|
||||
|
||||
type GlobalMysql = typeof globalThis & { __selectionMarketMysqlPoolV2?: Pool };
|
||||
const globalForMysql = globalThis as GlobalMysql;
|
||||
@ -70,7 +70,12 @@ export async function mysqlQuery<T = unknown>(
|
||||
timeoutMs = 60000,
|
||||
): Promise<T[]> {
|
||||
const pool = getPool();
|
||||
const [rows] = await pool.query({ sql: sqlText, timeout: timeoutMs }, params);
|
||||
const queryOptions: QueryOptions = {
|
||||
sql: sqlText,
|
||||
values: params as QueryOptions['values'],
|
||||
timeout: timeoutMs,
|
||||
};
|
||||
const [rows] = await pool.query(queryOptions);
|
||||
return rows as T[];
|
||||
}
|
||||
|
||||
|
||||
@ -82,7 +82,7 @@ export const BREACH_LINK_SCOPE_APPEAL_TYPES = new Set<string>([
|
||||
])
|
||||
|
||||
export const BREACH_REJECT_REASONS = [
|
||||
'商家实收低于限价',
|
||||
'买家实付低于适用限价',
|
||||
'凭证不完整',
|
||||
'凭证与订单不符',
|
||||
'活动前未算好价格',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
deriveOwnerAdjustedLimitPrice,
|
||||
resolveOwnerAdjustedLimitPriceAfterFreight,
|
||||
resolveLimitPriceFields,
|
||||
} from './supply-chain-limit-price-fields'
|
||||
|
||||
@ -92,7 +93,7 @@ describe('resolveLimitPriceFields', () => {
|
||||
})).toBe(166.88)
|
||||
})
|
||||
|
||||
it('负责人限价为空时解析字段仍生成调整限价', () => {
|
||||
it('负责人限价为空时基础字段留空,待全国运费计算后生成调整限价', () => {
|
||||
const fields = resolveLimitPriceFields({
|
||||
...ACTIVE_INPUT,
|
||||
ownerLimitPrice: null,
|
||||
@ -100,7 +101,77 @@ describe('resolveLimitPriceFields', () => {
|
||||
})
|
||||
|
||||
expect(fields.opsPriceLimit).toBe('')
|
||||
expect(fields.ownerAdjustedLimitPrice).toBe('166.88')
|
||||
expect(fields.ownerAdjustedLimitPrice).toBe('')
|
||||
})
|
||||
|
||||
it('DF-开头条码不生成负责人调整限价', () => {
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'DF-TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: 100,
|
||||
costPrice: '80',
|
||||
freightNationwide: '20.0000',
|
||||
})).toBe('')
|
||||
})
|
||||
|
||||
it('负责人限价有值时只按数字规则调整', () => {
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: 529,
|
||||
costPrice: '',
|
||||
freightNationwide: '20.0000',
|
||||
})).toBe('529.66')
|
||||
})
|
||||
|
||||
it('负责人限价为空时把成本和全国运费一起计入40%毛利', () => {
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: null,
|
||||
costPrice: '100',
|
||||
freightNationwide: '20.0000',
|
||||
})).toBe('206.66')
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: null,
|
||||
costPrice: '100',
|
||||
freightNationwide: '16.67',
|
||||
})).toBe('196.66')
|
||||
})
|
||||
|
||||
it('全国运费缺失时按成本价计算调整限价', () => {
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: null,
|
||||
costPrice: '100',
|
||||
freightNationwide: '',
|
||||
})).toBe('166.88')
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: null,
|
||||
costPrice: '100',
|
||||
})).toBe('166.88')
|
||||
})
|
||||
|
||||
it('成本价为空时不生成调整限价', () => {
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: null,
|
||||
costPrice: '',
|
||||
freightNationwide: '20.0000',
|
||||
})).toBe('')
|
||||
expect(resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: 'TEST001',
|
||||
productStatus: '销售',
|
||||
ownerLimitPrice: null,
|
||||
costPrice: '',
|
||||
freightNationwide: '',
|
||||
})).toBe('')
|
||||
})
|
||||
|
||||
it('负责人限价或成本价都无效时不生成调整限价', () => {
|
||||
|
||||
@ -84,6 +84,54 @@ export function deriveOwnerAdjustedLimitPrice(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
function numericText(value: string | number | null | undefined) {
|
||||
const text = String(value ?? '').replace(/[^\d.-]/g, '').trim()
|
||||
if (!text) return null
|
||||
const parsed = Number(text)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function isDfBarcode(barcode: string | null | undefined) {
|
||||
return /^DF-/i.test(String(barcode ?? '').trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责人调整限价的最终页面值。
|
||||
* 有负责人限价时只做数字规则调整;无负责人限价时使用:
|
||||
* (成本价 + 全国运费) / (1 - 40%)。
|
||||
* DF- 开头条码不展示负责人调整限价。
|
||||
*/
|
||||
export function resolveOwnerAdjustedLimitPriceAfterFreight(input: {
|
||||
barcode: string
|
||||
productStatus: string
|
||||
ownerLimitPrice: number | string | null
|
||||
costPrice: string
|
||||
freightNationwide?: string
|
||||
}) {
|
||||
if (
|
||||
INACTIVE_PRODUCT_STATUSES.has(input.productStatus.trim())
|
||||
|| isDfBarcode(input.barcode)
|
||||
) return ''
|
||||
|
||||
const ownerValue = numericText(input.ownerLimitPrice)
|
||||
const owner = ownerValue !== null && ownerValue > 0 ? ownerValue : null
|
||||
const cost = numericText(input.costPrice)
|
||||
const nationwideFreight = numericText(input.freightNationwide)
|
||||
if (owner === null && (cost === null || cost <= 0)) return ''
|
||||
|
||||
const adjusted = deriveOwnerAdjustedLimitPrice({
|
||||
ownerLimitPrice: owner,
|
||||
costPrice: owner === null
|
||||
? (cost as number) + (
|
||||
nationwideFreight !== null && nationwideFreight >= 0
|
||||
? nationwideFreight
|
||||
: 0
|
||||
)
|
||||
: cost,
|
||||
})
|
||||
return adjusted === null ? '' : adjusted.toFixed(2)
|
||||
}
|
||||
|
||||
function emptyLimitPriceFields() {
|
||||
return {
|
||||
opsPriceLimit: '',
|
||||
@ -100,6 +148,7 @@ function emptyLimitPriceFields() {
|
||||
}
|
||||
|
||||
export function resolveLimitPriceFields(input: {
|
||||
barcode?: string
|
||||
productStatus: string
|
||||
ownerLimitPrice: number | null
|
||||
costPrice: string
|
||||
@ -116,17 +165,18 @@ export function resolveLimitPriceFields(input: {
|
||||
? input.ownerLimitPrice
|
||||
: null
|
||||
const format = (value: number | null) => (value === null ? '' : value.toFixed(2))
|
||||
const ownerAdjusted = deriveOwnerAdjustedLimitPrice({
|
||||
ownerLimitPrice: owner,
|
||||
costPrice: cost,
|
||||
})
|
||||
if (owner === null) {
|
||||
return {
|
||||
...emptyLimitPriceFields(),
|
||||
ownerAdjustedLimitPrice: format(ownerAdjusted),
|
||||
limitInitiator: input.limitInitiator,
|
||||
}
|
||||
}
|
||||
const ownerAdjusted = isDfBarcode(input.barcode)
|
||||
? null
|
||||
: deriveOwnerAdjustedLimitPrice({
|
||||
ownerLimitPrice: owner,
|
||||
costPrice: cost,
|
||||
})
|
||||
const nonOwner = deriveNonOwnerLimitPrice(owner)
|
||||
const jx = Number.isFinite(cost) && cost > 0 ? cost / (1 - 0.26) : null
|
||||
const alibaba = nonOwner * 0.93
|
||||
|
||||
@ -24,6 +24,8 @@ export type JstBreachOrderSourceRow = {
|
||||
itemSource: string
|
||||
/** item_ext_data 中的 sku_uuid,用于把「换出行」与「换入行」精确配对 */
|
||||
skuUuid: string | null
|
||||
/** 店铺款式编码对应的链接维度负责人 */
|
||||
linkOwnerName?: string
|
||||
}
|
||||
|
||||
function requiredEnv(name: string) {
|
||||
@ -92,7 +94,7 @@ class JstBreachOrderSourceRepository {
|
||||
if (itemCodes?.length) {
|
||||
request.input('itemCodes', sql.NVarChar(sql.MAX), JSON.stringify(itemCodes.slice(0, 20000)))
|
||||
}
|
||||
const lineColumns = (refundExpr: string, orderSellerIncomeExpr: string) => `
|
||||
const lineColumns = (refundExpr: string, orderSellerIncomeExpr: string, orderPaidExpr: string) => `
|
||||
CAST(i.o_id AS varchar(40)) AS oId,
|
||||
CAST(o.so_id AS varchar(80)) AS soId,
|
||||
CAST(o.shop_site AS nvarchar(40)) AS platform,
|
||||
@ -107,7 +109,7 @@ class JstBreachOrderSourceRepository {
|
||||
CAST(ISNULL(o.status, '') AS varchar(40)) AS orderStatus,
|
||||
${refundExpr} AS refundStatus,
|
||||
${orderSellerIncomeExpr} AS orderSellerIncomeAmount,
|
||||
CAST(ISNULL(o.paid_amount, 0) AS decimal(18,4)) AS orderPaidAmount,
|
||||
${orderPaidExpr} AS orderPaidAmount,
|
||||
CAST(ISNULL(i.item_status, '') AS varchar(40)) AS itemStatus,
|
||||
CASE
|
||||
WHEN i.item_ext_data LIKE N'%"item_source":"手工换货"%' THEN N'手工换货'
|
||||
@ -122,13 +124,18 @@ class JstBreachOrderSourceRepository {
|
||||
SELECT ${lineColumns(
|
||||
`CAST(ISNULL(i.refund_status, '') AS varchar(40))`,
|
||||
`CAST(ISNULL(o.seller_income_amount, 0) AS decimal(18,4))`,
|
||||
`CAST(ISNULL(o.buyer_paid_amount, 0) AS decimal(18,4))`,
|
||||
)}
|
||||
FROM [${schema}].[jst_OrderItem] i
|
||||
JOIN [${schema}].[jst_Orders] o ON o.o_id = i.o_id
|
||||
WHERE o.pay_date >= @dateFrom AND o.pay_date < DATEADD(day, 1, @dateTo)
|
||||
${codeFilter}
|
||||
UNION ALL
|
||||
SELECT ${lineColumns(`CAST('' AS varchar(40))`, `CAST(NULL AS decimal(18,4))`)}
|
||||
SELECT ${lineColumns(
|
||||
`CAST('' AS varchar(40))`,
|
||||
`CAST(NULL AS decimal(18,4))`,
|
||||
`CAST(ISNULL(o.paid_amount, 0) AS decimal(18,4))`,
|
||||
)}
|
||||
FROM [${schema}].[jst_qmcust_orderlist_item] i
|
||||
JOIN [${schema}].[jst_qmcust_orderlist] o ON o.o_id = i.o_id
|
||||
WHERE o.pay_date >= @dateFrom AND o.pay_date < DATEADD(day, 1, @dateTo)
|
||||
@ -137,6 +144,50 @@ class JstBreachOrderSourceRepository {
|
||||
return result.recordset
|
||||
}
|
||||
|
||||
/** 按店铺款式编码读取链接维度负责人(op_TargetManagement 最新记录)。 */
|
||||
async listLinkOwners(itemIds: readonly string[]): Promise<Map<string, string>> {
|
||||
const ids = [...new Set(itemIds.map((value) => String(value ?? '').trim()).filter(Boolean))]
|
||||
const ownerByItemId = new Map<string, string>()
|
||||
if (!ids.length) return ownerByItemId
|
||||
|
||||
const pool = await this.getPool()
|
||||
const chunkSize = 100
|
||||
const chunkConcurrency = 4
|
||||
const chunks: string[][] = []
|
||||
for (let offset = 0; offset < ids.length; offset += chunkSize) {
|
||||
chunks.push(ids.slice(offset, offset + chunkSize))
|
||||
}
|
||||
for (let offset = 0; offset < chunks.length; offset += chunkConcurrency) {
|
||||
const results = await Promise.all(chunks.slice(offset, offset + chunkConcurrency).map(async (chunk) => {
|
||||
const literalList = chunk.map((itemId) => `N'${itemId.replaceAll("'", "''")}'`).join(',')
|
||||
const innerQuery = `
|
||||
SELECT shopproid AS itemId, ider AS ownerName
|
||||
FROM (
|
||||
SELECT
|
||||
shopproid,
|
||||
ider,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY shopproid
|
||||
ORDER BY realTime DESC, Id DESC
|
||||
) AS row_rank
|
||||
FROM dbo.op_TargetManagement
|
||||
WHERE shopproid IN (${literalList})
|
||||
) ranked
|
||||
WHERE row_rank = 1
|
||||
`
|
||||
return (await pool.request().query<{ itemId: string; ownerName: string }>(
|
||||
`SELECT * FROM OPENQUERY([JianSun_Old], N'${innerQuery.replaceAll("'", "''")}')`,
|
||||
)).recordset
|
||||
}))
|
||||
for (const rows of results) {
|
||||
for (const row of rows) {
|
||||
if (row.itemId) ownerByItemId.set(String(row.itemId).trim(), String(row.ownerName ?? '').trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ownerByItemId
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.pool) return
|
||||
await this.pool.close()
|
||||
|
||||
@ -1,166 +1,64 @@
|
||||
import {
|
||||
FREIGHT_CALCULATION_PROVINCES,
|
||||
type FreightCourierOption,
|
||||
type FreightProductArchiveSnapshot,
|
||||
import {
|
||||
type FreightCalculationResult,
|
||||
type FreightRateRecord,
|
||||
} from '@/lib/freight-calculation'
|
||||
import { freightConfigRepository } from '@/server/repositories/freight-config.repository'
|
||||
import { FreightConfigService } from '../freight-config.service'
|
||||
|
||||
jest.mock('@/server/repositories/freight-config.repository', () => ({
|
||||
freightConfigRepository: {},
|
||||
freightConfigRepository: {
|
||||
findBarcodeFixedRateMap: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function archive(barcode: string, productTag = '仓库-测试仓'): FreightProductArchiveSnapshot {
|
||||
const repository = jest.mocked(freightConfigRepository)
|
||||
|
||||
function result(barcode: string): FreightCalculationResult {
|
||||
return {
|
||||
barcode,
|
||||
productTag,
|
||||
inOutWarehouse: '测试仓',
|
||||
productTag: '仓库-测试仓',
|
||||
warehouseName: '测试仓',
|
||||
weight: 1,
|
||||
length: 10,
|
||||
width: 10,
|
||||
height: 10,
|
||||
volume: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function fixedRate(barcode: string, expressName = '固定快递'): FreightRateRecord {
|
||||
return {
|
||||
ruleId: `fix-${barcode}`,
|
||||
expressName,
|
||||
province: '安徽',
|
||||
effectiveStartTime: '2026-01-01 00:00:00',
|
||||
effectiveEndTime: '2099-12-31 23:59:59',
|
||||
startWeight: 0,
|
||||
endWeight: null,
|
||||
startVolume: 0,
|
||||
endVolume: null,
|
||||
startLength: null,
|
||||
endLength: null,
|
||||
waybillFee: 1,
|
||||
deductWeight: null,
|
||||
feeRate: 1,
|
||||
minimumFee: null,
|
||||
barcode,
|
||||
} as FreightRateRecord
|
||||
}
|
||||
|
||||
function buildRepositoryStub(overrides: {
|
||||
archives?: FreightProductArchiveSnapshot[]
|
||||
couriers?: FreightCourierOption[]
|
||||
fixedRateMap?: Map<string, FreightRateRecord[]>
|
||||
} = {}) {
|
||||
return {
|
||||
warmup: jest.fn(),
|
||||
listPage: jest.fn(),
|
||||
findProductArchiveSnapshot: jest.fn(),
|
||||
findProductArchiveSnapshots: jest.fn().mockResolvedValue(overrides.archives ?? []),
|
||||
findCouriersByWarehouse: jest.fn().mockResolvedValue(overrides.couriers ?? [
|
||||
{ expressName: '测试快递', rules: [] },
|
||||
]),
|
||||
findApplicableRates: jest.fn().mockResolvedValue([]),
|
||||
findApplicableVolumeRates: jest.fn().mockResolvedValue([]),
|
||||
findBarcodeFixedRates: jest.fn().mockResolvedValue([]),
|
||||
findBarcodeFixedRateMap: jest.fn().mockResolvedValue(overrides.fixedRateMap ?? new Map()),
|
||||
readProvinceShipmentShareSnapshot: jest.fn().mockResolvedValue({
|
||||
rows: FREIGHT_CALCULATION_PROVINCES.map(({ province, region }) => ({
|
||||
province,
|
||||
shipmentCount: 1,
|
||||
totalShipment: FREIGHT_CALCULATION_PROVINCES.length,
|
||||
shipmentShare: 1 / FREIGHT_CALCULATION_PROVINCES.length,
|
||||
region,
|
||||
regionShare: null,
|
||||
})),
|
||||
}),
|
||||
courierCount: 0,
|
||||
calculatedCourierCount: 0,
|
||||
regionAverages: [],
|
||||
nationalAverage: null,
|
||||
rateSource: 'WAREHOUSE',
|
||||
couriers: [],
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('FreightConfigService batch calculation', () => {
|
||||
it('去重后批量计算,缺失档案且无固定价的编码单独报错且不打断其他条目', async () => {
|
||||
const service = new FreightConfigService(buildRepositoryStub({
|
||||
archives: [archive('CODE-A'), archive('CODE-B')],
|
||||
}) as never)
|
||||
it('去重后批量计算,并单独返回失败编码', async () => {
|
||||
repository.findBarcodeFixedRateMap.mockResolvedValue(new Map())
|
||||
const service = new FreightConfigService()
|
||||
const calculate = jest.spyOn(
|
||||
service as unknown as {
|
||||
calculateBarcodeInternal: (
|
||||
barcode: string,
|
||||
fixedRates: readonly FreightRateRecord[],
|
||||
) => Promise<FreightCalculationResult>
|
||||
},
|
||||
'calculateBarcodeInternal',
|
||||
).mockImplementation(async (barcode) => {
|
||||
if (barcode === 'FAIL') throw new Error('商品不存在')
|
||||
return result(barcode)
|
||||
})
|
||||
|
||||
const batch = await service.calculateByBarcodes(['CODE-A', 'FAIL', 'CODE-A', 'CODE-B'])
|
||||
expect(batch.results.map((result) => result.barcode)).toEqual(['CODE-A', 'CODE-B'])
|
||||
expect(batch.errors).toEqual([{ barcode: 'FAIL', message: '商品编码“FAIL”不在产品全量档案中,请确认输入的是单个有效商品编码,而不是说明文字或表头' }])
|
||||
})
|
||||
|
||||
it('含空格的说明文字在批量入口被格式拦截', async () => {
|
||||
const service = new FreightConfigService(buildRepositoryStub({
|
||||
archives: [archive('CODE-A')],
|
||||
}) as never)
|
||||
|
||||
const batch = await service.calculateByBarcodes(['CODE-A', '请把 下面的编码都算一遍'])
|
||||
expect(batch.results.map((result) => result.barcode)).toEqual(['CODE-A'])
|
||||
expect(batch.errors).toEqual([{
|
||||
barcode: '请把 下面的编码都算一遍',
|
||||
message: '商品编码格式不正确:不能包含空格、冒号、逗号、分号、句号、问号、感叹号或顿号',
|
||||
}])
|
||||
})
|
||||
|
||||
it('批量档案、固定价映射、费率与省份占比快照都只读取一次', async () => {
|
||||
const repository = buildRepositoryStub({
|
||||
archives: [archive('CODE-A'), archive('CODE-B')],
|
||||
await expect(service.calculateByBarcodes([
|
||||
'CODE-A',
|
||||
'FAIL',
|
||||
'CODE-A',
|
||||
'CODE-B',
|
||||
])).resolves.toEqual({
|
||||
results: [result('CODE-A'), result('CODE-B')],
|
||||
errors: [{ barcode: 'FAIL', message: '商品不存在' }],
|
||||
})
|
||||
const service = new FreightConfigService(repository as never)
|
||||
|
||||
await service.calculateByBarcodes(['CODE-A', 'CODE-B'])
|
||||
expect(repository.findProductArchiveSnapshots).toHaveBeenCalledTimes(1)
|
||||
expect(repository.findBarcodeFixedRateMap).toHaveBeenCalledTimes(1)
|
||||
expect(repository.findCouriersByWarehouse).toHaveBeenCalledTimes(1)
|
||||
expect(repository.findApplicableRates).toHaveBeenCalledTimes(1)
|
||||
expect(repository.findApplicableVolumeRates).toHaveBeenCalledTimes(1)
|
||||
expect(repository.readProvinceShipmentShareSnapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('商品标签缺仓库且无固定价时按条目报错', async () => {
|
||||
const service = new FreightConfigService(buildRepositoryStub({
|
||||
archives: [archive('CODE-A', '没有仓库标签')],
|
||||
}) as never)
|
||||
|
||||
await expect(service.calculateByBarcodes(['CODE-A'])).resolves.toEqual({
|
||||
results: [],
|
||||
errors: [{
|
||||
barcode: 'CODE-A',
|
||||
message: '商品编码 CODE-A 的商品标签中没有“仓库-xxx”“简尚-xxx”或“三方-xxx”',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('仓库无可发货快递且无固定价时按条目报错', async () => {
|
||||
const service = new FreightConfigService(buildRepositoryStub({
|
||||
archives: [archive('CODE-A')],
|
||||
couriers: [],
|
||||
}) as never)
|
||||
|
||||
await expect(service.calculateByBarcodes(['CODE-A'])).resolves.toEqual({
|
||||
results: [],
|
||||
errors: [{ barcode: 'CODE-A', message: '没有查到仓库“测试仓”可发货的快递' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('命中条码固定价时即使档案缺失也出结果并带固定价警告', async () => {
|
||||
const service = new FreightConfigService(buildRepositoryStub({
|
||||
archives: [],
|
||||
fixedRateMap: new Map([['FIXED-ONLY', [fixedRate('FIXED-ONLY')]]]),
|
||||
}) as never)
|
||||
|
||||
const batch = await service.calculateByBarcodes(['FIXED-ONLY'])
|
||||
expect(batch.errors).toEqual([])
|
||||
expect(batch.results).toHaveLength(1)
|
||||
expect(batch.results[0].barcode).toBe('FIXED-ONLY')
|
||||
expect(batch.results[0].warnings[0]).toContain('条码固定运费表')
|
||||
expect(batch.results[0].warnings.join('\n')).toContain('产品全量档案未找到该条码')
|
||||
})
|
||||
|
||||
it('固定价与仓库费率条目可在同一批内混算且保持输入顺序', async () => {
|
||||
const service = new FreightConfigService(buildRepositoryStub({
|
||||
archives: [archive('WAREHOUSE-1')],
|
||||
fixedRateMap: new Map([['FIXED-ONLY', [fixedRate('FIXED-ONLY')]]]),
|
||||
}) as never)
|
||||
|
||||
const batch = await service.calculateByBarcodes(['FIXED-ONLY', 'WAREHOUSE-1'])
|
||||
expect(batch.errors).toEqual([])
|
||||
expect(batch.results.map((result) => result.barcode)).toEqual(['FIXED-ONLY', 'WAREHOUSE-1'])
|
||||
expect(calculate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { buildBreachEvidenceVerdict } from '../supply-chain-breach-evidence.service'
|
||||
|
||||
describe('破价申诉凭证判定', () => {
|
||||
const extract = {
|
||||
buyerPaid: 90,
|
||||
platformDiscount: 50,
|
||||
merchantReceipt: 140,
|
||||
qty: 1,
|
||||
orderNo: 'ORDER-1',
|
||||
}
|
||||
const system = { buyerPaid: 90, platformBear: 50, sellerIncome: 140 }
|
||||
|
||||
it('买家实付低于限价时驳回,即使商家实收已达到限价', () => {
|
||||
const verdict = buildBreachEvidenceVerdict({ extract, limitTotal: 100, system })
|
||||
expect(verdict.suggestion).toBe('REJECT')
|
||||
expect(verdict.note).toContain('买家实付 90.00 < 限价×数量 100.00')
|
||||
expect(verdict.note).toContain('破价 10.00')
|
||||
})
|
||||
|
||||
it('买家实付不低于限价时通过,不按平台优惠后的商家实收倒推', () => {
|
||||
const verdict = buildBreachEvidenceVerdict({
|
||||
extract: { ...extract, buyerPaid: 100, platformDiscount: 0, merchantReceipt: 100 },
|
||||
limitTotal: 100,
|
||||
system: { buyerPaid: 100, platformBear: 0, sellerIncome: 100 },
|
||||
})
|
||||
expect(verdict.suggestion).toBe('PASS')
|
||||
})
|
||||
|
||||
it('截图未识别到买家实付时不自动给结论', () => {
|
||||
const verdict = buildBreachEvidenceVerdict({
|
||||
extract: { ...extract, buyerPaid: null },
|
||||
limitTotal: 100,
|
||||
system,
|
||||
})
|
||||
expect(verdict.suggestion).toBe('')
|
||||
expect(verdict.note).toContain('需包含买家实付 / 数量')
|
||||
})
|
||||
})
|
||||
@ -1842,42 +1842,20 @@ describe('supply-chain-clearance workflow', () => {
|
||||
})).rejects.toThrow('销售状态商品不允许申请折扣')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'closed task channel',
|
||||
stockJianshang: 10,
|
||||
taskChannelClosed: true,
|
||||
dropshipStock: 0,
|
||||
error: '该商品为正常销售状态,当前无需定任务',
|
||||
},
|
||||
{
|
||||
name: 'dropship-only stock',
|
||||
stockJianshang: 0,
|
||||
taskChannelClosed: false,
|
||||
dropshipStock: 10,
|
||||
error: '简尚总库存为0且库存仅在代发仓,不允许申请折扣',
|
||||
},
|
||||
{
|
||||
name: 'configured discount restriction',
|
||||
stockJianshang: 10,
|
||||
taskChannelClosed: false,
|
||||
dropshipStock: 0,
|
||||
error: '该商品已命中禁止申请折扣的限制规则,请将折扣留空',
|
||||
},
|
||||
])('keeps main restrictions for fixed tasks: $name', async (scenario) => {
|
||||
it('allows fixed tasks to apply discounts despite other discount restrictions', async () => {
|
||||
repository.findPoolItem.mockResolvedValue({
|
||||
...poolItem,
|
||||
productStatus: '停产',
|
||||
salesStatus: '停产',
|
||||
stockJianshang: scenario.stockJianshang,
|
||||
stockJianshang: 0,
|
||||
stockFactory: 10,
|
||||
totalStock: 10,
|
||||
extra: {
|
||||
profitCenter: '运营中心A',
|
||||
fixedTaskQty: 10,
|
||||
taskChannelClosed: scenario.taskChannelClosed,
|
||||
discountChannelClosed: false,
|
||||
dropshipStock: scenario.dropshipStock,
|
||||
taskChannelClosed: true,
|
||||
discountChannelClosed: true,
|
||||
dropshipStock: 10,
|
||||
clearanceTotal: 100,
|
||||
},
|
||||
} as never)
|
||||
@ -1907,8 +1885,11 @@ describe('supply-chain-clearance workflow', () => {
|
||||
discount: 0.5,
|
||||
reason: '固定任务申请清货折扣',
|
||||
user,
|
||||
})).rejects.toThrow(scenario.error)
|
||||
expect(repository.createTask).not.toHaveBeenCalled()
|
||||
})).resolves.toMatchObject({
|
||||
taskQty: 10,
|
||||
discount: 0.5,
|
||||
status: 'PENDING',
|
||||
})
|
||||
})
|
||||
|
||||
it('never allows a sales-status fixed task to apply a discount', async () => {
|
||||
|
||||
@ -452,6 +452,34 @@ describe('SupplyChainDiscontinuationPenaltyService additional store scope', () =
|
||||
})
|
||||
})
|
||||
|
||||
it('reviews only the selected penalty ids with the required pending state', async () => {
|
||||
penalty.updateMany.mockResolvedValue({ count: 2 })
|
||||
|
||||
await expect(supplyChainDiscontinuationPenaltyService.review({
|
||||
id: 'penalty-1',
|
||||
viewer,
|
||||
decision: 'PASS',
|
||||
note: '',
|
||||
scope: 'SELECTED',
|
||||
ids: ['penalty-1', 'penalty-2', 'penalty-1'],
|
||||
})).resolves.toEqual({
|
||||
status: 'APPROVED',
|
||||
covered: 2,
|
||||
scope: 'SELECTED',
|
||||
})
|
||||
|
||||
expect(penalty.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: { in: ['penalty-1', 'penalty-2'] },
|
||||
status: 'PENDING_REVIEW',
|
||||
}),
|
||||
data: expect.objectContaining({
|
||||
status: 'APPROVED',
|
||||
reviewedBy: '运营G',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('loads the store platform when cancelling a granted store rejection', async () => {
|
||||
penalty.findUnique.mockImplementation(
|
||||
async ({ select }: { select?: Record<string, boolean> }) => ({
|
||||
|
||||
@ -82,6 +82,7 @@ describe('supply chain permission settings service', () => {
|
||||
action: 'data-scope-all',
|
||||
enabled: true,
|
||||
}])
|
||||
|
||||
await expect(
|
||||
supplyChainPermissionSettingsService.getDiscontinuationDataScope('user-all-1'),
|
||||
).resolves.toBe('ALL')
|
||||
|
||||
@ -10,9 +10,9 @@ import {
|
||||
isFreightBarcodeInput,
|
||||
parseWarehouseFromProductTag,
|
||||
type FreightCourierOption,
|
||||
type FreightProductArchiveSnapshot,
|
||||
type FreightCalculationBatchResult,
|
||||
type FreightCalculationResult,
|
||||
type FreightProductArchiveSnapshot,
|
||||
type FreightRateRecord,
|
||||
type FreightVolumeRateRecord,
|
||||
type ProvinceShipmentShareRow,
|
||||
@ -35,7 +35,6 @@ type FreightRepository = Pick<
|
||||
| 'warmup'
|
||||
| 'listPage'
|
||||
| 'findProductArchiveSnapshot'
|
||||
| 'findProductArchiveSnapshots'
|
||||
| 'findCouriersByWarehouse'
|
||||
| 'findApplicableRates'
|
||||
| 'findApplicableVolumeRates'
|
||||
@ -389,10 +388,6 @@ export class FreightConfigService {
|
||||
const normalizedBarcodes = [...new Set(
|
||||
barcodes.map((barcode) => barcode.trim()).filter(Boolean),
|
||||
)]
|
||||
if (normalizedBarcodes.length === 0) return { results: [], errors: [] }
|
||||
|
||||
// 条码固定价映射一次读取;档案批量读取,快递规则按仓库取一次,
|
||||
// 仓库费率与省份占比只取一次,普通商品共用 createFreightCalculator 的索引快照。
|
||||
let fixedRateMap: Map<string, FreightRateRecord[]>
|
||||
try {
|
||||
fixedRateMap = await this.repository.findBarcodeFixedRateMap(normalizedBarcodes)
|
||||
@ -402,200 +397,37 @@ export class FreightConfigService {
|
||||
error instanceof Error ? error.message : '条码运费读取失败',
|
||||
)
|
||||
}
|
||||
const archives = await this.repository.findProductArchiveSnapshots(normalizedBarcodes)
|
||||
const archiveByUpperBarcode = new Map(
|
||||
archives.map((archive) => [archive.barcode.trim().toUpperCase(), archive] as const),
|
||||
)
|
||||
const results: Array<FreightCalculationResult | null> = new Array(normalizedBarcodes.length)
|
||||
.fill(null)
|
||||
const errors: FreightCalculationBatchResult['errors'] = []
|
||||
const courierErrorsByWarehouse = new Map<string, string>()
|
||||
const couriersByWarehouse = new Map<string, FreightCourierOption[]>()
|
||||
|
||||
type PreparedItem = {
|
||||
barcode: string
|
||||
archive: FreightProductArchiveSnapshot
|
||||
warehouseName: string
|
||||
weight: number
|
||||
volume: number
|
||||
couriers: FreightCourierOption[]
|
||||
fixed: {
|
||||
rates: readonly FreightRateRecord[]
|
||||
fromProductArchive: boolean
|
||||
parsedWarehouseName: string | null
|
||||
} | null
|
||||
}
|
||||
const prepared: PreparedItem[] = []
|
||||
for (const barcode of normalizedBarcodes) {
|
||||
if (!isFreightBarcodeInput(barcode)) {
|
||||
errors.push({ barcode, message: FREIGHT_BARCODE_INPUT_INVALID_MESSAGE })
|
||||
continue
|
||||
}
|
||||
const fixedRates = fixedRateMap.get(barcode) ?? []
|
||||
const archiveFromProduct = archiveByUpperBarcode.get(barcode.toUpperCase()) ?? null
|
||||
if (!archiveFromProduct && fixedRates.length === 0) {
|
||||
errors.push({
|
||||
barcode,
|
||||
message: `商品编码“${barcode.trim()}”不在产品全量档案中,请确认输入的是单个有效商品编码,而不是说明文字或表头`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const archive: FreightProductArchiveSnapshot = archiveFromProduct ?? {
|
||||
barcode: barcode.trim(),
|
||||
productTag: '',
|
||||
inOutWarehouse: '',
|
||||
weight: 0,
|
||||
length: null,
|
||||
width: null,
|
||||
height: null,
|
||||
volume: 0,
|
||||
}
|
||||
const parsedWarehouseName = parseWarehouseFromProductTag(archive.productTag)
|
||||
const warehouseName = parsedWarehouseName
|
||||
?? (fixedRates.length > 0 ? '条码固定报价' : null)
|
||||
if (!warehouseName) {
|
||||
errors.push({
|
||||
barcode,
|
||||
message: `商品编码 ${archive.barcode} 的商品标签中没有“仓库-xxx”“简尚-xxx”或“三方-xxx”`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const rawWeight = fixedRates.length > 0 && archive.weight === null ? 0 : archive.weight
|
||||
const rawVolume = fixedRates.length > 0 && archive.volume === null ? 0 : archive.volume
|
||||
const weight = rawWeight === null ? Number.NaN : Number(rawWeight)
|
||||
const volume = rawVolume === null ? Number.NaN : Number(rawVolume)
|
||||
if (!Number.isFinite(weight) || weight < 0) {
|
||||
errors.push({ barcode, message: `商品编码 ${archive.barcode} 缺少有效重量` })
|
||||
continue
|
||||
}
|
||||
if (!Number.isFinite(volume) || volume < 0) {
|
||||
errors.push({ barcode, message: `商品编码 ${archive.barcode} 缺少有效体积` })
|
||||
continue
|
||||
}
|
||||
if (fixedRates.length > 0) {
|
||||
prepared.push({
|
||||
barcode,
|
||||
archive,
|
||||
warehouseName,
|
||||
weight,
|
||||
volume,
|
||||
couriers: [],
|
||||
fixed: {
|
||||
rates: fixedRates,
|
||||
fromProductArchive: archiveFromProduct !== null,
|
||||
parsedWarehouseName,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!couriersByWarehouse.has(warehouseName) && !courierErrorsByWarehouse.has(warehouseName)) {
|
||||
let cursor = 0
|
||||
const workerCount = Math.min(4, normalizedBarcodes.length)
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
const barcode = normalizedBarcodes[index]
|
||||
if (!barcode) return
|
||||
try {
|
||||
couriersByWarehouse.set(
|
||||
warehouseName,
|
||||
await this.repository.findCouriersByWarehouse({ warehouseName }),
|
||||
results[index] = await this.calculateBarcodeInternal(
|
||||
barcode,
|
||||
fixedRateMap.get(barcode) ?? [],
|
||||
)
|
||||
} catch (error) {
|
||||
courierErrorsByWarehouse.set(
|
||||
warehouseName,
|
||||
error instanceof Error ? error.message : '物流匹配规则读取失败',
|
||||
)
|
||||
}
|
||||
}
|
||||
const courierError = courierErrorsByWarehouse.get(warehouseName)
|
||||
if (courierError) {
|
||||
errors.push({ barcode, message: courierError })
|
||||
continue
|
||||
}
|
||||
const couriers = couriersByWarehouse.get(warehouseName) ?? []
|
||||
if (couriers.length === 0) {
|
||||
errors.push({ barcode, message: `没有查到仓库“${warehouseName}”可发货的快递` })
|
||||
continue
|
||||
}
|
||||
prepared.push({ barcode, archive, warehouseName, weight, volume, couriers, fixed: null })
|
||||
}
|
||||
if (prepared.length === 0) return { results: [], errors }
|
||||
|
||||
const expressNames = [...new Set(prepared.flatMap((item) => item.fixed ? [] : item.couriers
|
||||
.filter((courier) => !freightCourierNameExclusionReason(courier.expressName))
|
||||
.filter((courier) => evaluateFreightCourierRules({
|
||||
rules: courier.rules,
|
||||
weight: item.weight,
|
||||
volume: item.volume,
|
||||
length: item.archive.length,
|
||||
width: item.archive.width,
|
||||
height: item.archive.height,
|
||||
barcode: item.archive.barcode,
|
||||
productTag: item.archive.productTag,
|
||||
}).applicable)
|
||||
.map((courier) => courier.expressName)))]
|
||||
const [rates, volumeRates, shipmentShare] = await Promise.all([
|
||||
expressNames.length > 0
|
||||
? this.repository.findApplicableRates({ expressNames })
|
||||
: Promise.resolve([]),
|
||||
expressNames.length > 0
|
||||
? this.repository.findApplicableVolumeRates({ expressNames })
|
||||
: Promise.resolve([] as FreightVolumeRateRecord[]),
|
||||
this.repository.readProvinceShipmentShareSnapshot(),
|
||||
])
|
||||
const now = new Date()
|
||||
const calculate = createFreightCalculator({
|
||||
rates,
|
||||
volumeRates,
|
||||
shipmentShareRows: shipmentShare.rows,
|
||||
now,
|
||||
})
|
||||
|
||||
const results: FreightCalculationResult[] = []
|
||||
let lastYieldAt = performance.now()
|
||||
for (const item of prepared) {
|
||||
// 大批量测算时定期让出事件循环,健康检查与其他请求不被阻塞。
|
||||
if (performance.now() - lastYieldAt >= 8) {
|
||||
await yieldToEventLoop()
|
||||
lastYieldAt = performance.now()
|
||||
}
|
||||
try {
|
||||
if (item.fixed) {
|
||||
const courierNames = [...new Set(item.fixed.rates.map((rate) => rate.expressName))]
|
||||
const fixedCouriers = courierNames.map((expressName) => ({
|
||||
expressName,
|
||||
rules: [],
|
||||
}))
|
||||
const result = buildFreightCalculation({
|
||||
archive: item.archive,
|
||||
warehouseName: item.warehouseName,
|
||||
couriers: fixedCouriers,
|
||||
rates: item.fixed.rates,
|
||||
volumeRates: [],
|
||||
shipmentShareRows: shipmentShare.rows,
|
||||
now,
|
||||
rateSource: 'BARCODE_FIXED',
|
||||
errors.push({
|
||||
barcode,
|
||||
message: error instanceof Error ? error.message : '快递运费计算失败',
|
||||
})
|
||||
result.warnings.unshift(
|
||||
`商品编码命中条码固定运费表,仅使用固定快递:${courierNames.join('、')};固定单价优先于普通仓库费率,不套用省份/区域发货占比`,
|
||||
)
|
||||
if (!item.fixed.fromProductArchive) {
|
||||
result.warnings.push('产品全量档案未找到该条码,本次直接使用条码固定运费表计算')
|
||||
} else if (!item.fixed.parsedWarehouseName) {
|
||||
result.warnings.push('商品标签未解析出仓库,本次直接使用条码固定运费表计算')
|
||||
}
|
||||
results.push(result)
|
||||
} else {
|
||||
results.push(calculate({
|
||||
archive: item.archive,
|
||||
warehouseName: item.warehouseName,
|
||||
couriers: item.couriers,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
barcode: item.barcode,
|
||||
message: error instanceof Error ? error.message : '快递运费计算失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
const orderIndex = new Map(normalizedBarcodes.map((barcode, index) => [barcode, index]))
|
||||
})
|
||||
await Promise.all(workers)
|
||||
return {
|
||||
results,
|
||||
results: results.filter(
|
||||
(result): result is FreightCalculationResult => result !== null,
|
||||
),
|
||||
errors: errors.sort((left, right) => (
|
||||
(orderIndex.get(left.barcode) ?? 0) - (orderIndex.get(right.barcode) ?? 0)
|
||||
normalizedBarcodes.indexOf(left.barcode) - normalizedBarcodes.indexOf(right.barcode)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,12 +70,13 @@ export function buildBreachEvidenceVerdict(input: {
|
||||
system: { buyerPaid: number | null; platformBear: number | null; sellerIncome: number | null }
|
||||
}): { suggestion: 'PASS' | 'REJECT' | ''; note: string } {
|
||||
const { extract, limitTotal, system } = input
|
||||
if (!extract || (extract.buyerPaid === null && extract.merchantReceipt === null)) {
|
||||
return { suggestion: '', note: '截图识别失败:请人工核对凭证(需包含实付款 / 平台优惠 / 数量)。' }
|
||||
if (!extract || extract.buyerPaid === null) {
|
||||
return { suggestion: '', note: '截图识别失败:请人工核对凭证(需包含买家实付 / 数量)。' }
|
||||
}
|
||||
const buyerPaid = extract.buyerPaid
|
||||
const receipt = extract.merchantReceipt !== null
|
||||
? extract.merchantReceipt
|
||||
: (extract.buyerPaid ?? 0) + (extract.platformDiscount ?? 0)
|
||||
: buyerPaid + (extract.platformDiscount ?? 0)
|
||||
const parts = [
|
||||
`截图:实付款 ${extract.buyerPaid === null ? '—' : extract.buyerPaid.toFixed(2)}`,
|
||||
extract.platformDiscount === null ? null : `+ 平台优惠 ${extract.platformDiscount.toFixed(2)}`,
|
||||
@ -89,12 +90,12 @@ export function buildBreachEvidenceVerdict(input: {
|
||||
let verdict = ''
|
||||
if (limitTotal === null) {
|
||||
verdict = '缺少限价,无法自动判定。'
|
||||
} else if (receipt + 0.005 >= limitTotal) {
|
||||
} else if (buyerPaid + 0.005 >= limitTotal) {
|
||||
suggestion = 'PASS'
|
||||
verdict = `商家实收 ${receipt.toFixed(2)} ≥ 限价×数量 ${limitTotal.toFixed(2)} → 未破价,可通过。`
|
||||
verdict = `买家实付 ${buyerPaid.toFixed(2)} ≥ 限价×数量 ${limitTotal.toFixed(2)} → 未破价,可通过。`
|
||||
} else {
|
||||
suggestion = 'REJECT'
|
||||
verdict = `商家实收 ${receipt.toFixed(2)} < 限价×数量 ${limitTotal.toFixed(2)} → 破价 ${(limitTotal - receipt).toFixed(2)},建议驳回。`
|
||||
verdict = `买家实付 ${buyerPaid.toFixed(2)} < 限价×数量 ${limitTotal.toFixed(2)} → 破价 ${(limitTotal - buyerPaid).toFixed(2)},建议驳回。`
|
||||
}
|
||||
const systemNote = consistent
|
||||
? '与系统数据一致'
|
||||
|
||||
@ -51,7 +51,7 @@ export type BreachOrderListFilters = {
|
||||
|
||||
const BREACH_SORT_FIELDS: ReadonlySet<string> = new Set([
|
||||
'payDate', 'platform', 'shopName', 'itemCode', 'shopStyleCode', 'shopItemCode',
|
||||
'soId', 'oId', 'listedAt', 'flowEndDate', 'sellerIncomeAmount', 'limitTotal', 'gapAmount', 'status',
|
||||
'soId', 'oId', 'listedAt', 'flowEndDate', 'buyerPaidAmount', 'sellerIncomeAmount', 'limitTotal', 'gapAmount', 'status',
|
||||
])
|
||||
|
||||
type Viewer = { id: string; name: string; canReview: boolean }
|
||||
@ -642,39 +642,45 @@ class SupplyChainBreachOrderService {
|
||||
return { status, fineAmount: isSecondRoundPass ? 0 : fineAmount, covered: rowsWithPrimary.length }
|
||||
}
|
||||
|
||||
/** 加载破价豁免所需的流程数据:条码→货号、货号→负责人限价、货号→价格授权。 */
|
||||
/** 加载破价判定数据:条码→货号/商品负责人、货号→负责人限价、货号→价格授权。 */
|
||||
private async buildAuthContext(): Promise<BreachGenerationAuthContext> {
|
||||
const [archives, styleLimits, auths] = await Promise.all([
|
||||
prisma.supplyChainProductArchive.findMany({ select: { barcode: true, sku: true } }),
|
||||
prisma.supplyChainProductArchive.findMany({ select: { barcode: true, sku: true, opsHead: true } }),
|
||||
prisma.supplyChainStyleLimitPrice.findMany(),
|
||||
prisma.supplyChainLimitFlowAuth.findMany(),
|
||||
])
|
||||
const barcodeToStyle = new Map<string, string>()
|
||||
const orderOwnerByBarcode = new Map<string, string>()
|
||||
for (const row of archives) {
|
||||
if (row.barcode && row.sku) barcodeToStyle.set(row.barcode, row.sku)
|
||||
const barcode = row.barcode.trim().toUpperCase()
|
||||
if (!barcode) continue
|
||||
if (row.sku) barcodeToStyle.set(barcode, row.sku.trim().toUpperCase())
|
||||
if (row.opsHead.trim()) orderOwnerByBarcode.set(barcode, row.opsHead.trim())
|
||||
}
|
||||
const ownerLimitByStyle = new Map<string, number>()
|
||||
for (const row of styleLimits) {
|
||||
if (row.ownerLimitPrice !== null) ownerLimitByStyle.set(row.styleCode, Number(row.ownerLimitPrice))
|
||||
const styleCode = row.styleCode.trim().toUpperCase()
|
||||
if (styleCode && row.ownerLimitPrice !== null) ownerLimitByStyle.set(styleCode, Number(row.ownerLimitPrice))
|
||||
}
|
||||
const authsByStyle = new Map<string, BreachGenerationAuth[]>()
|
||||
for (const auth of auths) {
|
||||
if (!auth.styleCode) continue
|
||||
const list = authsByStyle.get(auth.styleCode) ?? []
|
||||
const styleCode = auth.styleCode.trim().toUpperCase()
|
||||
const list = authsByStyle.get(styleCode) ?? []
|
||||
list.push({
|
||||
applyType: auth.applyType,
|
||||
styleCode: auth.styleCode,
|
||||
styleCode,
|
||||
approvedAt: auth.approvedAt,
|
||||
startDate: auth.startDate,
|
||||
endDate: auth.endDate,
|
||||
adjustedLimitPrice: auth.adjustedLimitPrice === null ? null : Number(auth.adjustedLimitPrice),
|
||||
})
|
||||
authsByStyle.set(auth.styleCode, list)
|
||||
authsByStyle.set(styleCode, list)
|
||||
}
|
||||
return { barcodeToStyle, ownerLimitByStyle, authsByStyle }
|
||||
return { barcodeToStyle, ownerLimitByStyle, orderOwnerByBarcode, authsByStyle }
|
||||
}
|
||||
|
||||
/** 从聚水潭订单实收 + 限价表生成/刷新破价单。 */
|
||||
/** 从聚水潭订单明细买家实付 + 适用限价生成/刷新破价单。 */
|
||||
async generate(input: { dateFrom: string; dateTo: string; viewer: Viewer }) {
|
||||
if (!input.viewer.canReview) throw new Error('没有生成权限')
|
||||
const limitPrices = await prisma.supplyChainLinkLimitPrice.findMany()
|
||||
@ -687,12 +693,20 @@ class SupplyChainBreachOrderService {
|
||||
})
|
||||
sourceRows.push(...dayRows)
|
||||
}
|
||||
const linkOwnerByItemId = await jstBreachOrderSourceRepository.listLinkOwners(
|
||||
sourceRows.map((row) => row.shopStyleCode),
|
||||
)
|
||||
const sourceRowsWithOwners = sourceRows.map((row) => ({
|
||||
...row,
|
||||
linkOwnerName: linkOwnerByItemId.get(row.shopStyleCode) ?? '',
|
||||
}))
|
||||
// 生成口径(含手工换货配对)统一在 breach-order-generation 中实现:
|
||||
// 换货单按「换出行原编码」的限价判定,成交数量/商家实收取换入行(抓不到按 0,页面显示 ¥0.00)。
|
||||
// 破价豁免:同价申请(期内按负责人限价、超期按非负责人限价)与活动限价(期内 ≥ 调整后限价不判)。
|
||||
// 换货单按「换出行原编码」的限价判定,成交数量/买家实付取换入行。
|
||||
// 商品负责人与链接负责人一致时取负责人限价,不一致时取非负责人限价。
|
||||
// 破价豁免:同价申请(期内按负责人限价、超期按非负责人限价)与活动限价(买家实付 ≥ 调整后限价不判)。
|
||||
const authContext = await this.buildAuthContext()
|
||||
const { candidates, stats } = buildBreachOrderCandidates(
|
||||
sourceRows,
|
||||
sourceRowsWithOwners,
|
||||
limitPrices.map((row) => ({
|
||||
shopName: row.shopName,
|
||||
itemCode: row.itemCode,
|
||||
|
||||
@ -33,7 +33,7 @@ export const DEFAULT_BREACH_RULE_CONFIG: BreachRuleConfigData = {
|
||||
buyerBearer: ['淘金币'],
|
||||
pendingBearer: ['首单礼金'],
|
||||
ownerTriggerRule: '非运营发起的流程:前两个月全公司运营同价;两个月后按销量触发负责人(销量高者为负责人)。',
|
||||
judgingBasis: '以商家实收为准(实付款 + 平台承担);商家实收 < 限价×数量 判定破价;限价按货号维度、取订单付款时点生效的版本。',
|
||||
judgingBasis: '以订单明细买家实付为准;买家实付 < 适用限价×数量 判定破价。商品负责人与链接负责人一致时用负责人限价,不一致时用非负责人限价。',
|
||||
}
|
||||
|
||||
function cleanTextArray(value: unknown, fallback: string[]) {
|
||||
|
||||
@ -293,6 +293,10 @@ function poolExtra(row: { extra?: unknown }) {
|
||||
: {}
|
||||
}
|
||||
|
||||
function isFixedClearanceTaskItem(row: { extra?: unknown }) {
|
||||
return integer(poolExtra(row).fixedTaskQty) > 0
|
||||
}
|
||||
|
||||
const NONNEGATIVE_POOL_DISPLAY_EXTRA_KEYS = [
|
||||
'bigWarehouseStock',
|
||||
'frontWarehouseStock',
|
||||
@ -416,7 +420,7 @@ function poolClearanceTotal(row: { totalStock: number; extra?: unknown }) {
|
||||
}
|
||||
|
||||
function assertPoolTaskAllowed(row: { extra?: unknown }) {
|
||||
if (Boolean(poolExtra(row).taskChannelClosed)) {
|
||||
if (Boolean(poolExtra(row).taskChannelClosed) && !isFixedClearanceTaskItem(row)) {
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_INVALID_FORMAT,
|
||||
'该商品为正常销售状态,当前无需定任务',
|
||||
@ -693,8 +697,10 @@ function assertDiscountRestrictionAllowed(
|
||||
item: Record<string, unknown>,
|
||||
restrictions: ReturnType<typeof normalizeClearanceDiscountRestrictions>,
|
||||
discount: number,
|
||||
allowFixedTaskDiscount = false,
|
||||
) {
|
||||
if (discount >= 0.995) return
|
||||
if (allowFixedTaskDiscount) return
|
||||
if (
|
||||
integer(item.stockJianshang) <= 0
|
||||
&& integer(poolExtra(item).dropshipStock) > 0
|
||||
@ -2657,6 +2663,7 @@ class SupplyChainClearanceService {
|
||||
}
|
||||
const profitCenter = resolveTaskProfitCenter(poolItem, task.profitCenter)
|
||||
profitCenterByTaskId.set(task.id, profitCenter)
|
||||
const fixedTask = isFixedClearanceTaskItem(poolItem)
|
||||
const discount = number(task.discount) ?? 0
|
||||
assertSalesStatusDiscountAllowed(
|
||||
poolItem.salesStatus || poolItem.productStatus,
|
||||
@ -2672,6 +2679,7 @@ class SupplyChainClearanceService {
|
||||
poolItem,
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
discount,
|
||||
fixedTask,
|
||||
)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
if (task.taskQty > clearanceTotal) {
|
||||
@ -2775,6 +2783,7 @@ class SupplyChainClearanceService {
|
||||
},
|
||||
discountRestrictions,
|
||||
number(task.discount) ?? 0,
|
||||
poolItem ? isFixedClearanceTaskItem(poolItem) : false,
|
||||
)
|
||||
await supplyChainClearanceRepository.transitionTask({
|
||||
id: task.id,
|
||||
@ -2841,13 +2850,18 @@ class SupplyChainClearanceService {
|
||||
priorDiscounts.get(`${barcode}\u0000${profitCenter}`) ?? null,
|
||||
normalizeClearanceReviewScoreConfig(clearanceConfig?.reviewScoreConfig),
|
||||
)
|
||||
const fixedTask = isFixedClearanceTaskItem(poolItem)
|
||||
assertPoolTaskAllowed(poolItem)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
assertSalesStatusDiscountAllowed(poolItem.salesStatus || poolItem.productStatus, input.discount)
|
||||
assertSalesStatusDiscountAllowed(
|
||||
poolItem.salesStatus || poolItem.productStatus,
|
||||
input.discount,
|
||||
)
|
||||
assertDiscountRestrictionAllowed(
|
||||
poolItem,
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
input.discount,
|
||||
fixedTask,
|
||||
)
|
||||
assertTaskReasonAllowed(
|
||||
poolItem.salesStatus || poolItem.productStatus,
|
||||
@ -2940,13 +2954,18 @@ class SupplyChainClearanceService {
|
||||
priorDiscounts.get(`${barcode}\u0000${profitCenter}`) ?? null,
|
||||
normalizeClearanceReviewScoreConfig(clearanceConfig?.reviewScoreConfig),
|
||||
)
|
||||
const fixedTask = isFixedClearanceTaskItem(poolItem)
|
||||
assertPoolTaskAllowed(poolItem)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
assertSalesStatusDiscountAllowed(poolItem.salesStatus || poolItem.productStatus, input.discount)
|
||||
assertSalesStatusDiscountAllowed(
|
||||
poolItem.salesStatus || poolItem.productStatus,
|
||||
input.discount,
|
||||
)
|
||||
assertDiscountRestrictionAllowed(
|
||||
poolItem,
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
input.discount,
|
||||
fixedTask,
|
||||
)
|
||||
assertTaskReasonAllowed(
|
||||
poolItem.salesStatus || poolItem.productStatus,
|
||||
@ -3153,6 +3172,7 @@ class SupplyChainClearanceService {
|
||||
`${barcode} 已在 ${profitCenter} 提交任务,不能重复批量填写`,
|
||||
)
|
||||
}
|
||||
const fixedTask = isFixedClearanceTaskItem(poolItem)
|
||||
const salesStatus = poolItem.salesStatus || poolItem.productStatus
|
||||
const reason = isSalesClearanceStatus(salesStatus) ? '' : input.reason.trim()
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
@ -3169,6 +3189,7 @@ class SupplyChainClearanceService {
|
||||
poolItem,
|
||||
discountRestrictions,
|
||||
input.discount,
|
||||
fixedTask,
|
||||
)
|
||||
if (input.taskQty > clearanceTotal) {
|
||||
throw new AppError(
|
||||
@ -3323,6 +3344,7 @@ class SupplyChainClearanceService {
|
||||
if (!resolved) continue
|
||||
const { poolItem } = resolved
|
||||
const discount = number(task.discount) ?? 0
|
||||
const fixedTask = isFixedClearanceTaskItem(poolItem)
|
||||
assertSalesStatusDiscountAllowed(
|
||||
poolItem.salesStatus || poolItem.productStatus,
|
||||
discount,
|
||||
@ -3341,6 +3363,7 @@ class SupplyChainClearanceService {
|
||||
poolItem,
|
||||
discountRestrictions,
|
||||
discount,
|
||||
fixedTask,
|
||||
)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
if (task.taskQty > clearanceTotal) {
|
||||
@ -3425,11 +3448,13 @@ class SupplyChainClearanceService {
|
||||
])
|
||||
const poolItem = poolItems[0]
|
||||
if (poolItem) {
|
||||
const fixedTask = isFixedClearanceTaskItem(poolItem)
|
||||
assertPoolTaskAllowed(poolItem)
|
||||
assertDiscountRestrictionAllowed(
|
||||
poolItem,
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
number(task.discount) ?? 0,
|
||||
fixedTask,
|
||||
)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
if (task.taskQty > clearanceTotal) {
|
||||
@ -3477,13 +3502,12 @@ class SupplyChainClearanceService {
|
||||
supplyChainClearanceRepository.listPoolItemsForTaskPeriod(task.periodKey, [task.barcode]),
|
||||
])
|
||||
const poolItem = poolItems[0]
|
||||
const fixedTask = poolItem ? isFixedClearanceTaskItem(poolItem) : false
|
||||
assertDiscountRestrictionAllowed(
|
||||
{
|
||||
barcode: task.barcode,
|
||||
spu: poolItem?.spu,
|
||||
},
|
||||
poolItem ?? { barcode: task.barcode },
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
number(task.discount) ?? 0,
|
||||
fixedTask,
|
||||
)
|
||||
const updated = await supplyChainClearanceRepository.transitionTask({
|
||||
id: task.id,
|
||||
@ -3516,12 +3540,13 @@ class SupplyChainClearanceService {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '只有已驳回的任务可以由经管调整')
|
||||
}
|
||||
validateTaskInput(input)
|
||||
assertSalesStatusDiscountAllowed(task.productStatus, input.discount)
|
||||
const [poolItems, clearanceConfig] = await Promise.all([
|
||||
supplyChainClearanceRepository.listPoolItemsForTaskPeriod(task.periodKey, [task.barcode]),
|
||||
supplyChainClearanceRepository.findClearanceConfig(),
|
||||
])
|
||||
const poolItem = poolItems[0]
|
||||
const fixedTask = poolItem ? isFixedClearanceTaskItem(poolItem) : false
|
||||
assertSalesStatusDiscountAllowed(task.productStatus, input.discount)
|
||||
if (poolItem) {
|
||||
assertPoolTaskAllowed(poolItem)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
@ -3533,9 +3558,10 @@ class SupplyChainClearanceService {
|
||||
}
|
||||
}
|
||||
assertDiscountRestrictionAllowed(
|
||||
{ barcode: task.barcode, spu: poolItem?.spu },
|
||||
poolItem ?? { barcode: task.barcode },
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
input.discount,
|
||||
fixedTask,
|
||||
)
|
||||
const updated = await supplyChainClearanceRepository.transitionTask({
|
||||
id: task.id,
|
||||
@ -3574,12 +3600,13 @@ class SupplyChainClearanceService {
|
||||
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '只有待审核的任务可以调整后通过')
|
||||
}
|
||||
validateTaskInput(input)
|
||||
assertSalesStatusDiscountAllowed(task.productStatus, input.discount)
|
||||
const [poolItems, clearanceConfig] = await Promise.all([
|
||||
supplyChainClearanceRepository.listPoolItemsForTaskPeriod(task.periodKey, [task.barcode]),
|
||||
supplyChainClearanceRepository.findClearanceConfig(),
|
||||
])
|
||||
const poolItem = poolItems[0]
|
||||
const fixedTask = poolItem ? isFixedClearanceTaskItem(poolItem) : false
|
||||
assertSalesStatusDiscountAllowed(task.productStatus, input.discount)
|
||||
if (poolItem) {
|
||||
assertPoolTaskAllowed(poolItem)
|
||||
const clearanceTotal = poolClearanceTotal(poolItem)
|
||||
@ -3591,9 +3618,10 @@ class SupplyChainClearanceService {
|
||||
}
|
||||
}
|
||||
assertDiscountRestrictionAllowed(
|
||||
{ barcode: task.barcode, spu: poolItem?.spu },
|
||||
poolItem ?? { barcode: task.barcode },
|
||||
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
|
||||
input.discount,
|
||||
fixedTask,
|
||||
)
|
||||
const updated = await supplyChainClearanceRepository.transitionTaskWithLatestEvent({
|
||||
id: task.id,
|
||||
|
||||
@ -476,7 +476,8 @@ class SupplyChainDiscontinuationPenaltyService {
|
||||
}
|
||||
decision: 'PASS' | 'REJECT' | 'RETURN'
|
||||
note: string
|
||||
scope: 'ORDER' | 'LINK'
|
||||
scope: 'ORDER' | 'LINK' | 'SELECTED'
|
||||
ids?: readonly string[]
|
||||
}) {
|
||||
if (!input.viewer.canReview) throw new Error('只有具备操作权限的经管可以审核申诉')
|
||||
const row = await prisma.supplyChainDiscontinuationPenalty.findUnique({
|
||||
@ -505,6 +506,12 @@ class SupplyChainDiscontinuationPenaltyService {
|
||||
: input.decision === 'REJECT'
|
||||
? 'REJECTED'
|
||||
: 'RETURNED'
|
||||
const selectedIds = Array.from(new Set(
|
||||
(input.ids ?? []).map((id) => id.trim()).filter(Boolean),
|
||||
)).slice(0, 500)
|
||||
if (input.scope === 'SELECTED' && !selectedIds.length) {
|
||||
throw new Error('请选择需要批量审核的明细')
|
||||
}
|
||||
const where = input.scope === 'LINK'
|
||||
? {
|
||||
monthKey: row.monthKey,
|
||||
@ -514,6 +521,12 @@ class SupplyChainDiscontinuationPenaltyService {
|
||||
status: 'PENDING_REVIEW',
|
||||
...discontinuationDataScopeWhere(input.viewer.dataScope),
|
||||
}
|
||||
: input.scope === 'SELECTED'
|
||||
? {
|
||||
id: { in: selectedIds },
|
||||
status: 'PENDING_REVIEW',
|
||||
...discontinuationDataScopeWhere(input.viewer.dataScope),
|
||||
}
|
||||
: {
|
||||
id: input.id,
|
||||
...discontinuationDataScopeWhere(input.viewer.dataScope),
|
||||
@ -538,7 +551,8 @@ class SupplyChainDiscontinuationPenaltyService {
|
||||
canReview: boolean
|
||||
dataScope: DiscontinuationDataScope
|
||||
}
|
||||
scope: 'ORDER' | 'LINK'
|
||||
scope: 'ORDER' | 'LINK' | 'SELECTED'
|
||||
ids?: readonly string[]
|
||||
}) {
|
||||
if (!input.viewer.canReview) throw new Error('只有具备操作权限的经管可以取消驳回')
|
||||
const row = await prisma.supplyChainDiscontinuationPenalty.findUnique({
|
||||
@ -559,6 +573,12 @@ class SupplyChainDiscontinuationPenaltyService {
|
||||
throw new Error('只能处理职责范围内的驳回明细')
|
||||
}
|
||||
if (row.status !== 'REJECTED') throw new Error('只有已驳回明细可以取消驳回')
|
||||
const selectedIds = Array.from(new Set(
|
||||
(input.ids ?? []).map((id) => id.trim()).filter(Boolean),
|
||||
)).slice(0, 500)
|
||||
if (input.scope === 'SELECTED' && !selectedIds.length) {
|
||||
throw new Error('请选择需要批量取消驳回的明细')
|
||||
}
|
||||
const where = input.scope === 'LINK'
|
||||
? {
|
||||
monthKey: row.monthKey,
|
||||
@ -568,6 +588,12 @@ class SupplyChainDiscontinuationPenaltyService {
|
||||
status: 'REJECTED',
|
||||
...discontinuationDataScopeWhere(input.viewer.dataScope),
|
||||
}
|
||||
: input.scope === 'SELECTED'
|
||||
? {
|
||||
id: { in: selectedIds },
|
||||
status: 'REJECTED',
|
||||
...discontinuationDataScopeWhere(input.viewer.dataScope),
|
||||
}
|
||||
: {
|
||||
id: input.id,
|
||||
...discontinuationDataScopeWhere(input.viewer.dataScope),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { spawn, type ChildProcessByStdio } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { Readable } from 'node:stream'
|
||||
import path from 'node:path'
|
||||
import type { Readable } from 'node:stream'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
|
||||
export type DiscontinuationRegenerateStage =
|
||||
|
||||
@ -37,7 +37,10 @@ import {
|
||||
resolveProductArchiveWarehouseFields,
|
||||
} from './supply-chain-product-archive.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { resolveLimitPriceFields } from '@/lib/supply-chain-limit-price-fields'
|
||||
import {
|
||||
resolveLimitPriceFields,
|
||||
resolveOwnerAdjustedLimitPriceAfterFreight,
|
||||
} from '@/lib/supply-chain-limit-price-fields'
|
||||
import {
|
||||
mapProductArchiveRow,
|
||||
normalizeWarehouseName,
|
||||
@ -234,6 +237,7 @@ export class SupplyChainProductArchiveExportService {
|
||||
})
|
||||
const styleLimit = styleLimitByCode.get(String(record.sku ?? '').trim())
|
||||
const limitFields = resolveLimitPriceFields({
|
||||
barcode: row.barcode,
|
||||
productStatus: row.productStatus,
|
||||
ownerLimitPrice: styleLimit?.ownerLimitPrice ?? null,
|
||||
costPrice: String(row.costPrice ?? ''),
|
||||
@ -275,10 +279,20 @@ export class SupplyChainProductArchiveExportService {
|
||||
for await (const batch of rowSource.batches(batchSize)) {
|
||||
const rows = batch.map(mapRow)
|
||||
const freightByBarcode = await freightService.calculateRegionFreightsForArchiveRows(rows)
|
||||
const completeRows = rows.map((row) => ({
|
||||
...row,
|
||||
...freightByBarcode.get(row.barcode),
|
||||
}))
|
||||
const completeRows = rows.map((row) => {
|
||||
const freight = freightByBarcode.get(row.barcode)
|
||||
return {
|
||||
...row,
|
||||
...freight,
|
||||
ownerAdjustedLimitPrice: resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: row.barcode,
|
||||
productStatus: row.productStatus,
|
||||
ownerLimitPrice: row.opsPriceLimit,
|
||||
costPrice: row.costPrice,
|
||||
freightNationwide: freight?.freightNationwide,
|
||||
}),
|
||||
}
|
||||
})
|
||||
processedRows += completeRows.length
|
||||
params.onProgress?.(processedRows, rowSource.total)
|
||||
yield completeRows
|
||||
@ -386,9 +400,21 @@ export class SupplyChainProductArchiveExportService {
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
const record = batch[index]
|
||||
const freight = freightByBarcode.get(row.barcode)
|
||||
const exportRow = {
|
||||
...row,
|
||||
...freightByBarcode.get(row.barcode),
|
||||
...freight,
|
||||
...(isLimitSet
|
||||
? {
|
||||
ownerAdjustedLimitPrice: resolveOwnerAdjustedLimitPriceAfterFreight({
|
||||
barcode: row.barcode,
|
||||
productStatus: row.productStatus,
|
||||
ownerLimitPrice: row.opsPriceLimit,
|
||||
costPrice: row.costPrice,
|
||||
freightNationwide: freight?.freightNationwide,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const values = Object.fromEntries(
|
||||
columns.map((column) => [column.prop, readValue(exportRow, column.prop)]),
|
||||
|
||||
@ -164,6 +164,7 @@ export class SupplyChainProductArchiveService {
|
||||
// 限价派生字段(限价明细页 / 导出共用):负责人限价 + 非负责人 / 京喜 / 阿里 / 分销商折扣
|
||||
const styleLimit = styleLimitByCode.get(String(record.sku ?? '').trim())
|
||||
const limitFields = resolveLimitPriceFields({
|
||||
barcode: row.barcode,
|
||||
productStatus: row.productStatus,
|
||||
ownerLimitPrice: styleLimit?.ownerLimitPrice ?? null,
|
||||
costPrice: String(row.costPrice ?? ''),
|
||||
|
||||
@ -19,6 +19,7 @@ import { listActiveProductArchiveCategoryScaleRows, type ProductArchiveCategoryS
|
||||
import { listProductArchiveReplacementRules } from '@/server/repositories/supply-chain-product-archive-replacement.repository'
|
||||
import { calculateProductArchiveSalesPerformance } from './product-archive-sales-performance'
|
||||
import { storageTypeLabel } from '@/lib/supply-chain-product-archive-values'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
/**
|
||||
* 档案「创建时间」列是 timestamp without time zone(存的是北京时间),Prisma 读取后按 UTC 解释,
|
||||
@ -385,7 +386,11 @@ async function fetchMonthlyAggregateRows(
|
||||
async function listMonthlyAggregates(start: Date, end: Date, replacementIndex: ReplacementIndex) {
|
||||
const rows = await fetchMonthlyAggregateRows(start, end, replacementIndex)
|
||||
if (rows.length === 0) {
|
||||
throw new Error('产品全量档案在所选月份没有已同步的销售数据,请先完成上游销售事实同步,再执行 SABC 核算')
|
||||
const period = `${start.getUTCFullYear()}年${start.getUTCMonth() + 1}月`
|
||||
throw new AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
`${period}还没有已同步的销售数据(上游聚水潭销售事实未出数),请等数据同步后再执行核算`,
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@ -61,6 +61,15 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('supply-chain-product-sabc rules', () => {
|
||||
it('rejects calculation when the selected month has no synced sales facts', async () => {
|
||||
;(prisma.$queryRaw as jest.Mock).mockResolvedValue([])
|
||||
archiveFindMany.mockResolvedValue([])
|
||||
salesAggregates.mockResolvedValue([])
|
||||
|
||||
await expect(new SupplyChainProductSabcService().calculate({ year: 2026, month: 11, apply: false }))
|
||||
.rejects.toThrow('2026年11月还没有已同步的销售数据')
|
||||
})
|
||||
|
||||
it('marks exactly 90 create days as 创建60-90天', () => {
|
||||
expect(sabcNewProductRangeLabel(29)).toBe('创建不足30天')
|
||||
expect(sabcNewProductRangeLabel(30)).toBe('创建30-60天')
|
||||
|
||||
@ -29,7 +29,7 @@ const limit = (itemCode: string, limitUnitPrice = 113.52) => ({
|
||||
})
|
||||
|
||||
describe('buildBreachOrderCandidates 手工换货识别', () => {
|
||||
it('换货单按原编码限价判定,金额取换入行实收(订单金额不变)', () => {
|
||||
it('换货单按原编码限价判定,买家实付和商家实收取换入行', () => {
|
||||
const lines: BreachGenerationLine[] = [
|
||||
{
|
||||
...baseLine,
|
||||
@ -64,7 +64,7 @@ describe('buildBreachOrderCandidates 手工换货识别', () => {
|
||||
expect(candidates[0].fromManualSwap).toBe(true)
|
||||
})
|
||||
|
||||
it('换入行实收抓不到时回退用订单金额,而不是显示 0', () => {
|
||||
it('换入行商家实收抓不到时回退订单金额,但买家实付仍保留明细值', () => {
|
||||
const lines: BreachGenerationLine[] = [
|
||||
{
|
||||
...baseLine,
|
||||
@ -88,8 +88,9 @@ describe('buildBreachOrderCandidates 手工换货识别', () => {
|
||||
const { candidates, stats } = buildBreachOrderCandidates(lines, [limit('SJ004838N1172V01')])
|
||||
expect(stats.swapOrderAmountFallback).toBe(1)
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].buyerPaidAmount).toBe(0)
|
||||
expect(candidates[0].sellerIncomeAmount).toBe(96)
|
||||
expect(candidates[0].gapAmount).toBe(-17.52)
|
||||
expect(candidates[0].gapAmount).toBe(-113.52)
|
||||
})
|
||||
|
||||
it('换货行配对成功后不再按换入编码重复生成', () => {
|
||||
@ -247,18 +248,18 @@ describe('buildBreachOrderCandidates 手工换货识别', () => {
|
||||
expect(candidates.map((item) => item.itemCode)).toEqual(['B-1'])
|
||||
})
|
||||
|
||||
it('普通订单判定行为不变(实收低于限价才生成)', () => {
|
||||
const normal = (itemCode: string, income: number): BreachGenerationLine => ({
|
||||
it('普通订单按买家实付判定,不因商家实收较高而跳过破价', () => {
|
||||
const normal = (itemCode: string, buyerPaid: number, sellerIncome: number): BreachGenerationLine => ({
|
||||
...baseLine,
|
||||
oId: `${itemCode}-order`,
|
||||
soId: `${itemCode}-so`,
|
||||
itemCode,
|
||||
qty: 1,
|
||||
buyerPaidAmount: income,
|
||||
sellerIncomeAmount: income,
|
||||
buyerPaidAmount: buyerPaid,
|
||||
sellerIncomeAmount: sellerIncome,
|
||||
})
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[normal('N-1', 90), normal('N-2', 120)],
|
||||
[normal('N-1', 90, 200), normal('N-2', 120, 50)],
|
||||
[limit('N-1'), limit('N-2', 100)],
|
||||
)
|
||||
expect(candidates.map((item) => item.itemCode)).toEqual(['N-1'])
|
||||
@ -266,6 +267,66 @@ describe('buildBreachOrderCandidates 手工换货识别', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('破价负责人一致性', () => {
|
||||
const ownerLine = (overrides: Partial<BreachGenerationLine> = {}): BreachGenerationLine => ({
|
||||
...baseLine,
|
||||
oId: 'owner-order',
|
||||
soId: 'owner-so',
|
||||
itemCode: 'OWNER-1',
|
||||
qty: 1,
|
||||
buyerPaidAmount: 105,
|
||||
sellerIncomeAmount: 200,
|
||||
linkOwnerName: '虞子期',
|
||||
...overrides,
|
||||
})
|
||||
const context = (orderOwner: string): {
|
||||
barcodeToStyle: Map<string, string>
|
||||
ownerLimitByStyle: Map<string, number>
|
||||
orderOwnerByBarcode: Map<string, string>
|
||||
authsByStyle: Map<string, never[]>
|
||||
} => ({
|
||||
barcodeToStyle: new Map([['OWNER-1', 'OWNER-STYLE']]),
|
||||
ownerLimitByStyle: new Map([['OWNER-STYLE', 100]]),
|
||||
orderOwnerByBarcode: new Map([['OWNER-1', orderOwner]]),
|
||||
authsByStyle: new Map(),
|
||||
})
|
||||
const limits = [{ ...limit('OWNER-1'), limitUnitPrice: 100 }]
|
||||
|
||||
it('负责人一致时按负责人限价和买家实付判定', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[ownerLine({ buyerPaidAmount: 99 })],
|
||||
limits,
|
||||
context('虞子期'),
|
||||
)
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].limitUnitPrice).toBe(100)
|
||||
expect(candidates[0].gapAmount).toBe(-1)
|
||||
expect(candidates[0].ownerName).toBe('虞子期')
|
||||
expect(candidates[0].platformBearAmount).toBe(101)
|
||||
})
|
||||
|
||||
it('负责人不一致时按非负责人限价判定', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[ownerLine({ buyerPaidAmount: 105 })],
|
||||
limits,
|
||||
context('半夏'),
|
||||
)
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].limitUnitPrice).toBe(110)
|
||||
expect(candidates[0].gapAmount).toBe(-5)
|
||||
expect(candidates[0].ownerName).toBe('半夏')
|
||||
})
|
||||
|
||||
it('负责人不一致且买家实付高于非负责人限价时不生成', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[ownerLine({ buyerPaidAmount: 111 })],
|
||||
limits,
|
||||
context('半夏'),
|
||||
)
|
||||
expect(candidates).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
const authLine = (overrides: Partial<BreachGenerationLine> = {}): BreachGenerationLine => ({
|
||||
...baseLine,
|
||||
@ -286,20 +347,21 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
}>) => ({
|
||||
barcodeToStyle: new Map([['AUTH-BARCODE-1', 'STYLE-1']]),
|
||||
ownerLimitByStyle: new Map([['STYLE-1', 100]]),
|
||||
orderOwnerByBarcode: new Map(),
|
||||
authsByStyle: new Map([['STYLE-1', auths.map((auth) => ({ styleCode: 'STYLE-1', ...auth }))]]),
|
||||
})
|
||||
const baseLimit = [{
|
||||
shopName: baseLine.shopName,
|
||||
itemCode: 'AUTH-BARCODE-1',
|
||||
limitUnitPrice: 90, // 常规基准 90;实收 100 ≥ 90 本来不判——故意调低以验证豁免路径
|
||||
limitUnitPrice: 90, // 常规基准 90;买家实付 100 ≥ 90 本来不判——故意调低以验证豁免路径
|
||||
ownerName: '',
|
||||
listedAt: null,
|
||||
flowEndDate: null,
|
||||
}]
|
||||
|
||||
it('同价期内且实收 ≥ 负责人限价 → 豁免不生成', () => {
|
||||
it('同价期内且买家实付 ≥ 负责人限价 → 豁免不生成', () => {
|
||||
const { candidates, stats } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 88 })],
|
||||
[authLine({ buyerPaidAmount: 88 })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 120 }], // 常规基准 120 > 88 本会生成
|
||||
authContext([{
|
||||
applyType: '申请与SPU负责人同价',
|
||||
@ -309,12 +371,12 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
adjustedLimitPrice: null,
|
||||
}]),
|
||||
)
|
||||
// 实收 88 < 负责人限价 100:不豁免,但基准降为负责人限价 100 → 88 < 100 仍生成
|
||||
// 买家实付 88 < 负责人限价 100:不豁免,但基准降为负责人限价 100 → 88 < 100 仍生成
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].limitUnitPrice).toBe(100)
|
||||
|
||||
const exempt = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 100 })],
|
||||
[authLine({ buyerPaidAmount: 100 })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 120 }],
|
||||
authContext([{
|
||||
applyType: '申请与SPU负责人同价',
|
||||
@ -330,7 +392,7 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
|
||||
it('同价超期后仍按负责人价格 → 按非负责人限价(+10)判破价', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 100, payDate: new Date('2026-11-05T10:00:00Z') })],
|
||||
[authLine({ buyerPaidAmount: 100, payDate: new Date('2026-11-05T10:00:00Z') })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 90 }],
|
||||
authContext([{
|
||||
applyType: '申请与SPU负责人同价',
|
||||
@ -345,9 +407,9 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
expect(candidates[0].gapAmount).toBe(-10)
|
||||
})
|
||||
|
||||
it('活动期内实收 ≥ 调整后限价 → 豁免', () => {
|
||||
it('活动期内买家实付 ≥ 调整后限价 → 豁免', () => {
|
||||
const { candidates, stats } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 95 })],
|
||||
[authLine({ buyerPaidAmount: 95 })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 120 }],
|
||||
authContext([{
|
||||
applyType: '活动限价',
|
||||
@ -361,9 +423,9 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
expect(stats.authExempted).toBe(1)
|
||||
})
|
||||
|
||||
it('活动期内实收 < 调整后限价 → 按调整后限价判破价', () => {
|
||||
it('活动期内买家实付 < 调整后限价 → 按调整后限价判破价', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 80 })],
|
||||
[authLine({ buyerPaidAmount: 80 })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 70 }],
|
||||
authContext([{
|
||||
applyType: '活动限价',
|
||||
@ -380,7 +442,7 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
|
||||
it('活动期外 → 回到常规基准', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 100, payDate: new Date('2026-12-05T10:00:00Z') })],
|
||||
[authLine({ buyerPaidAmount: 100, payDate: new Date('2026-12-05T10:00:00Z') })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 90 }],
|
||||
authContext([{
|
||||
applyType: '活动限价',
|
||||
@ -390,12 +452,12 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
adjustedLimitPrice: 80,
|
||||
}]),
|
||||
)
|
||||
expect(candidates).toHaveLength(0) // 常规基准 90,实收 100 不判
|
||||
expect(candidates).toHaveLength(0) // 常规基准 90,买家实付 100 不判
|
||||
})
|
||||
|
||||
it('限价以流程为准:同货号有流程限价时优先于店铺台账限价', () => {
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 95 })],
|
||||
[authLine({ buyerPaidAmount: 95 })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 80 }], // 台账 80;流程 100
|
||||
authContext([]),
|
||||
)
|
||||
@ -408,7 +470,7 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
|
||||
const context = authContext([])
|
||||
context.ownerLimitByStyle = new Map() // 流程无该货号
|
||||
const { candidates } = buildBreachOrderCandidates(
|
||||
[authLine({ sellerIncomeAmount: 85 })],
|
||||
[authLine({ buyerPaidAmount: 85 })],
|
||||
[{ ...baseLimit[0], limitUnitPrice: 90 }],
|
||||
context,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user