Compare commits

..

205 Commits

Author SHA1 Message Date
居居
5fd7e9610e fix(clearance): 重算含运营中心B发货缓存 2026-11-09 16:54:45 +08:00
居居
bd3c00e523 fix(clearance): 发货统计纳入运营中心B店铺 2026-11-09 16:54:09 +08:00
居居
2816c21fae fix(clearance): 清货发货量切换出库单月度增量口径 2026-11-09 16:40:50 +08:00
WuYongJie
cb8044996f fix(modular-processing): 修正列键类型并下沉预警人员查询 2026-11-09 15:15:17 +08:00
居居
4055d22eec data(clearance): 提交11月人工清货任务基线 2026-11-09 15:09:33 +08:00
apple
fba732982d fix(modular-processing): 修复预警名单加载与停用人员保存
- 名单加载失败时禁止保存,避免空名单覆盖原配置\n- 已选但停用或解绑钉钉的人员继续显示并允许删除,保存不再被历史 ID 阻塞\n- 拆分设置菜单、列筛选弹窗与预警人员弹窗组件\n- 补充加载失败、停用人员和历史绑定失效回归测试
2026-11-09 14:50:22 +08:00
apple
0791e94768 feat(modular-processing): 增加可配置预警人员与独立设置界面
- 支持按当前名单查询、勾选和批量删除异常预警人员\n- 支持搜索人员、勾选和批量添加预警人员并校验钉钉绑定状态\n- 将列设置、筛选设置和异常预警人员拆分为独立操作界面\n- 人工重新生成成功后自动失效当日失败预警
2026-11-09 14:27:21 +08:00
WuYongJie
a712e7d65f fix(clearance): 每日指标原子更新并保留并发人工调整 2026-11-09 13:19:10 +08:00
居居
9c4adf46d9 fix(clearance): 每日刷新保留清货池评分 2026-11-09 11:39:26 +08:00
居居
ac72b4e5c1 feat(clearance): 增加库存与任务指标每日刷新 2026-11-09 11:29:41 +08:00
WuYongJie
531c813d9a fix(modular-processing): 补齐 BOM 重试查询与加工行类型声明 2026-11-09 09:51:10 +08:00
apple
daabe4b309 fix(modular-processing): 完善 BOM 重试与冻结表头遮罩
BOM 查询自动重试并在结果不完整时中止快照写入;表头采用独立冻结遮罩,滚动分组表头不再覆盖冻结区域。
2026-11-08 15:31:31 +08:00
WuYongJie
fd24c71a47 fix(clearance): 修复进度更新事务及前置类型错误 2026-11-07 18:12:32 +08:00
WuYongJie
bfaab0ce4e fix(installation): replace existing drawing tasks by complete drawing ID 2026-11-07 18:03:57 +08:00
居居
d54494dcc7 fix(clearance): 修复小数审核分快照并整合主线冲突 2026-11-07 17:28:00 +08:00
居居
b18e4d079e fix(clearance): 恢复备份表总分数权重 2026-11-07 16:58:38 +08:00
居居
5338f8dcb3 feat(clearance): 增加建议折扣配置与货号生产状态 2026-11-07 16:54:44 +08:00
WuYongJie
2f509c133f fix(store-accounts): include page metadata and source dates in exports 2026-11-07 16:42:14 +08:00
居居
d86f7085cf fix(clearance): 回填脚本自动加载环境变量 2026-11-07 16:23:21 +08:00
居居
bb40c6b6bc feat(clearance): 上线审核快照与批量任务流转 2026-11-07 16:22:33 +08:00
居居
d81cc85936 feat(clearance): 增加清货池批量审核工作台 2026-11-07 15:55:48 +08:00
居居
8ab5307d49 fix(clearance): 人工清货价导入改为原子覆盖 2026-11-07 14:58:11 +08:00
居居
9a9c63aadb feat(clearance): 新增外采订单成本并叠加广东任务 2026-11-07 14:50:07 +08:00
居居
016e7ea408 feat(clearance): 增加任务外人工清货价导入 2026-11-07 14:32:08 +08:00
居居
9b6a379404 feat(clearance): 清货价推送改为商品级并合并人工价格 2026-11-07 14:25:03 +08:00
居居
f592e314f1 fix(clearance): 任务发货量汇总全部启用利润中心 2026-11-07 13:44:16 +08:00
WuYongJie
cbfbf6f93b fix(modular-processing): 以整轮生成状态判定成功并补齐超时回归 2026-11-07 13:41:31 +08:00
居居
06cbcf5498 feat(clearance): 替换任务表并统一全渠道销量口径 2026-11-07 13:34:37 +08:00
WuYongJie
514c373a5f fix(barcode-print): preserve Gujia 2026 product name position
Preserve the original top and 48px row for Gujia 2026 names. Limit wrapped text to 24px and verify exact production barcode MKHSJ0425080100326.
2026-11-07 12:09:01 +08:00
apple
ad3d572813 feat(modular-processing): 完善生成监控、异常预警与主表冻结
部件缺口不再阻断整批生成;持久化生成状态并向系统管理员预警;页面展示失败原因与净缺口;冻结条码及之前字段;补充迁移和回归测试。
2026-11-07 11:11:23 +08:00
WuYongJie
a273f77315 fix(barcode-print): fit long product names on Gujia certificates
Measure rendered text, shrink long names, and wrap to two lines inside the product name field. Preserve complete text and other certificate fields.
2026-11-07 11:00:03 +08:00
WuYongJie
065102e35e feat(store-account): add shop metadata and searchable filters 2026-11-07 10:05:28 +08:00
居居
df2389efff fix(clearance): 发货进度统一按完整销售表统计 2026-11-07 09:06:21 +08:00
WuYongJie
96e92719d2 feat(diy): extract editable scene prompts from reference images
Add owned image upload and vision extraction to DIY scene generation; remove preset scene tags.
2026-11-06 17:39:01 +08:00
居居
165ba6a6c8 fix(clearance): 限制任务导出字段并禁止客户端图片抓取 2026-11-06 17:17:26 +08:00
居居
678ebc1238 fix(clearance): 任务导出改为当前查询结果快照 2026-11-06 17:00:14 +08:00
居居
d77b9e1328 fix(clearance): 统一任务导出与页面发货数量口径 2026-11-06 16:21:11 +08:00
WuYongJie
86c2b6d88a feat(store-account): use source shop dates and add renewal notices
(cherry picked from commit d6456eb07748d6afd9a7797add765ae0d33f6406)
2026-11-06 16:20:11 +08:00
WuYongJie
b8e0f445f0 fix(intake): unify SKU numbering and two-template imports 2026-11-06 15:59:34 +08:00
居居
2aef8010db fix(clearance): 修正同步状态轮询与请求类型 2026-11-06 15:40:53 +08:00
居居
9f3fb23265 perf(clearance): 拆分发货同步并增加状态反馈 2026-11-06 15:23:46 +08:00
居居
ef34c8cfbe feat(clearance): 固定任务允许申请折扣 2026-11-06 15:19:49 +08:00
居居
575b1a84ff feat(freight): 支持条码固定运费与三方标签计费 2026-11-06 14:12:31 +08:00
居居
dc22d53e20 fix(clearance): 自动刷新每日发货量并调整分数概览
- 返回每日发货缓存真实截止日并自动轮询刷新\n- 移除清货分数平均分卡片,保留完成率与负责人数量指标
2026-11-06 14:07:14 +08:00
居居
92d6e3ea4c feat(clearance): 调整清货分数概览指标 2026-11-06 13:21:52 +08:00
居居
e0a4a316e6 feat(clearance): 清货分数增加筛选后合计行 2026-11-06 13:18:41 +08:00
WuYongJie
c4b73f20fc feat(diy): add usage records restricted to Xuxiake account 2026-11-06 13:15:05 +08:00
居居
78022874ed fix(clearance): 按条码和利润中心分拆任务进度 2026-11-06 12:02:07 +08:00
居居
c417ea2f3e fix(clearance): 修复筛选选项、分数利润中心与全宽布局 2026-11-06 11:38:38 +08:00
居居
52351ffd32 fix(clearance): 修正每日发货缓存回补与利润中心兜底 2026-11-06 10:08:07 +08:00
WuYongJie
4a2071103b fix(intake): continue SKU numbering from actual code suffix
Use the largest actual SKU suffix within the SPU for manual additions and workbook imports. Align internal sequences with saved identities, share package sequence numbers, and serialize allocation across product records.
2026-11-06 09:50:57 +08:00
居居
33f172b2eb style(clearance): 清货价推送页面全宽平铺 2026-11-06 09:40:53 +08:00
居居
bd2fa7eda6 perf(clearance): 按日增量累计清货发货量 2026-11-06 09:34:11 +08:00
居居
e5523cc8f1 fix(clearance): 重试清货价推送连接失败 2026-11-06 09:09:55 +08:00
居居
db2439da12 feat(hr-sync): 人员信息同步纳入快递公司组织树(覆盖全部钉钉部门) 2026-11-05 18:17:14 +08:00
居居
4b27f821a5 feat(hr-sync): 人员信息同步纳入供应商组织树(仅排除快递公司) 2026-11-05 18:17:12 +08:00
WuYongJie
5fdcbbc735 fix(diy): pin support contact notice above AI panel scroll 2026-11-05 17:57:29 +08:00
WuYongJie
3e5b3ca8dd fix(clearance): match resolved shop IDs before paging and export
Use archived shop identities for mapped-ID searches across list, count and export.
Split shop mapping from repository; preserve nonempty historical IDs.
2026-11-05 17:44:26 +08:00
居居
ee78258482 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-05 17:15:01 +08:00
居居
581112554a fix(discontinuation): never expose unmapped SKU as replacement barcode 2026-11-05 17:14:39 +08:00
居居
654d076745 feat(clearance): 清货价推送增加利润中心 2026-11-05 17:13:27 +08:00
居居
621bc817bf fix(clearance): 补齐历史店铺编号并显示清货价说明 2026-11-05 17:13:11 +08:00
居居
ed2100f95e feat(clearance): 清货价推送增加利润中心 2026-11-05 17:10:46 +08:00
居居
cb2166d3d3 fix(clearance): 补齐历史店铺编号并显示清货价说明 2026-11-05 16:33:25 +08:00
WuYongJie
828883e742 fix(clearance): preserve applied migration and terminate failed exports
Add forward-only filter indexes and propagate download errors/cancellation.
2026-11-05 16:00:42 +08:00
居居
62191d50ab style(clearance): 精简清货价推送筛选条件 2026-11-05 15:31:48 +08:00
居居
05e4509f0d fix(clearance): 保留真实发货量并补齐任务池字段 2026-11-05 15:31:48 +08:00
居居
75b6d6b08d feat(clearance): 增加清货价推送筛选与导出 2026-11-05 15:31:48 +08:00
居居
9e064c4b42 fix(clearance): 从聚水潭店铺表映射店铺编号 2026-11-05 15:31:48 +08:00
居居
72fb742a3d style(clearance): 缩短历史成本价备注 2026-11-05 15:31:48 +08:00
居居
4c1cd5527b perf(clearance): 解耦任务首屏与进度刷新 2026-11-05 15:31:48 +08:00
居居
976fc50be6 perf(clearance): 异步刷新任务出货进度 2026-11-05 15:31:48 +08:00
居居
a7118a19d6 style(clearance): 精简清货价推送筛选条件 2026-11-05 15:30:26 +08:00
居居
2fa5de6faa fix(clearance): 保留真实发货量并补齐任务池字段 2026-11-05 15:22:07 +08:00
居居
cf4533644b feat(clearance): 增加清货价推送筛选与导出 2026-11-05 15:02:49 +08:00
WuYongJie
d102807f1f feat(1688): display and filter product suppliers 2026-11-05 15:02:19 +08:00
居居
92d5751e29 fix(clearance): 从聚水潭店铺表映射店铺编号 2026-11-05 14:55:54 +08:00
居居
0273991735 style(clearance): 缩短历史成本价备注 2026-11-05 14:50:59 +08:00
居居
15b92b21d7 perf(clearance): 解耦任务首屏与进度刷新 2026-11-05 14:50:59 +08:00
居居
fb5b0b4b0b perf(clearance): 异步刷新任务出货进度 2026-11-05 14:48:02 +08:00
WuYongJie
f79dfd05a9 fix(intake): complete inventory tasks when either flow succeeds
Check current SKU barcode success before enqueueing, sending inventory requests, retrying, and recording failure alerts. Keep genuine unresolved failures visible.
2026-11-05 14:03:02 +08:00
居居
6a61f37518 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-clearance-price-push-archive
# Conflicts:
#	furniture-3d-system/prisma/migrations/20261105100000_add_clearance_price_push_archive/migration.sql
#	furniture-3d-system/prisma/schema.prisma
#	furniture-3d-system/src/app/api/supply-chain/clearance/price-push/route.ts
#	furniture-3d-system/src/app/supply-chain/management/clearance/ClearancePricePushView.tsx
#	furniture-3d-system/src/server/repositories/supply-chain-clearance-price-push.repository.ts
#	furniture-3d-system/src/server/services/__tests__/supply-chain-clearance-price-push.service.test.ts
#	furniture-3d-system/src/server/services/supply-chain-clearance-price-push.service.ts
2026-11-05 13:09:40 +08:00
居居
ada5547dab fix(clearance): 映射清货商品编码到实际商品编码 2026-11-05 13:05:11 +08:00
居居
bb36a281f0 fix(clearance): 映射清货商品编码到实际商品编码 2026-11-05 13:05:08 +08:00
居居
2f86d68ad1 fix(clearance): 按利润中心全店铺生成清货价推送 2026-11-05 11:58:24 +08:00
居居
fc6234c3fa fix(clearance): 按利润中心全店铺生成清货价推送 2026-11-05 11:58:21 +08:00
居居
5020cd2cb6 style(discontinuation): compact batch selection checkbox 2026-11-05 11:47:26 +08:00
居居
020685724d feat(discontinuation): batch selected penalty actions 2026-11-05 11:46:28 +08:00
居居
708d6e74fb Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-05 11:16:24 +08:00
居居
ffacc0f8cf fix(discontinuation): review activity appeals per order 2026-11-05 11:16:14 +08:00
居居
562a0965c1 feat(clearance): 新增清货价推送月度存档 2026-11-05 11:12:17 +08:00
居居
caf0d01081 feat(clearance): 新增清货价推送月度存档 2026-11-05 11:12:14 +08:00
居居
51bbaa856d fix(supply-chain): include snapshot images in limit export 2026-11-05 10:58:06 +08:00
居居
e517587be5 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-05 10:52:14 +08:00
居居
4d78ef3bc8 feat(discontinuation): auto review activity appeals 2026-11-05 10:52:10 +08:00
WuYongJie
fc28557c3d fix(discontinuation): guard appeal editing and review until evidence is loaded
Disable appeal and review during loading or failure; allow retry and ignore obsolete detail responses.
Local module source: cc3761b359a8ae61d54effd132179f9846763767
2026-11-05 09:59:33 +08:00
居居
ff6406d1e0 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-05 09:16:27 +08:00
居居
089772817d perf(discontinuation): lazy load appeal evidence 2026-11-05 09:16:22 +08:00
WuYongJie
d7a8f98e71 test(auth): preserve current supply-chain exclusions during release 2026-11-04 14:50:43 +08:00
WuYongJie
46ed372fbc fix(auth): honor personal supply-chain grants for external intake users 2026-11-04 14:45:06 +08:00
居居
ef542d4477 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-04 13:18:08 +08:00
居居
f081897391 fix(discontinuation): restart stale RPA browser before sync 2026-11-04 13:18:05 +08:00
WuYongJie
1188bbb5d3 fix(product-archive): retry marketing sales hourly until 14:00 2026-11-04 10:32:39 +08:00
WuYongJie
efcba62d3b fix(intake): show successful inventory channel exclusively
Hide the other inventory channel once either warehouse or fixed inventory has succeeded. Keep unresolved statuses visible when neither has succeeded.
2026-11-04 09:49:53 +08:00
WuYongJie
4f7ad254c9 fix(diy): avoid reserved layout filename in guide content 2026-11-03 17:59:30 +08:00
居居
4d691dc0c0 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-03 17:52:09 +08:00
WuYongJie
24eb88e178 feat(diy): add illustrated operation guide with searchable chapters
Provide concise instructions for SJ0380 and SJ0450, real interface screenshots, and a flip-door demo. Place the guide capsule after the scheme name and open the manual in a separate tab.
2026-11-03 17:39:13 +08:00
居居
286a707faf fix(clearance): 优化任务加载与分销任务计算 2026-11-03 17:36:22 +08:00
居居
fb33e4f782 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-03 17:11:15 +08:00
居居
24e2c726f5 feat(discontinuation): mark combo replacements 2026-11-03 17:11:08 +08:00
WuYongJie
b11d2efbe1 fix(intake): keep inventory status labels on one line 2026-11-03 16:02:09 +08:00
居居
671ba259a3 feat(clearance): 完善任务映射、计算与配置交互 2026-11-03 15:49:53 +08:00
居居
b8d1177e0a feat(discontinuation): support batch filter queries 2026-11-03 15:37:43 +08:00
居居
960c9d454f Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-03 15:19:10 +08:00
居居
cd15a4d2d8 feat(discontinuation): suppress dedicated warehouse links 2026-11-03 15:19:02 +08:00
WuYongJie
4ed8082fba fix(intake): persist factory inventory plans with product push state
Save warehouse inventory plans in the product push transaction, including partial successes, and show factory and Jianshang inventory status independently.
2026-11-03 15:16:30 +08:00
居居
64182df114 fix(supply-chain): exclude owner-adjusted price from limit exports 2026-11-03 15:13:40 +08:00
居居
760a613950 fix(supply-chain): hide owner-adjusted price column in limit UI
Keep the owner-adjusted price in calculations, snapshots, and exports while excluding it from the limit table, filters, and column settings.
2026-11-03 15:09:42 +08:00
居居
e50184f36d Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-03 14:18:20 +08:00
居居
4fd0b7be1e fix(discontinuation): hide unavailable replacement aliases 2026-11-03 14:18:17 +08:00
居居
3645802d7d feat(clearance): 支持人工任务按利润中心导入 2026-11-03 14:13:41 +08:00
居居
c230981a46 feat(clearance): 接入人工批次与月度批次规则 2026-11-03 14:04:09 +08:00
WuYongJie
824603764b fix(product-archive): sync Jianshang actual stock daily
Use inventory management warehouse scope and sum finished goods qty by barcode.
Refresh source metadata and support a targeted stock-only refresh with backups.
Make the existing daily 08:10 schedule explicitly use Asia/Shanghai.
2026-11-03 13:35:35 +08:00
WuYongJie
7f7d234139 fix(product-archive): prioritize warehouse storage configuration 2026-11-03 12:14:51 +08:00
居居
617703a5b4 fix(discontinuation): apply all link filters 2026-11-03 11:13:02 +08:00
居居
0e5f4c1596 feat(discontinuation): simplify appeal reason options 2026-11-03 11:09:53 +08:00
居居
f6d1c4ce42 feat(discontinuation): separate appeal and review roles 2026-11-03 11:05:22 +08:00
居居
b437c7d9a9 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-discontinuation-store-scope 2026-11-03 10:54:15 +08:00
居居
18d77046bf fix(discontinuation): retain saved data scope overrides 2026-11-03 10:54:13 +08:00
WuYongJie
4c51935284 fix(layout): hide external AI navigation for supplier accounts 2026-11-03 09:51:39 +08:00
WuYongJie
407dff873b fix(diy): highlight new SJ0450 series without changing names 2026-11-03 09:39:54 +08:00
居居
c5e6c19b0e fix(discontinuation): resolve regeneration runner from release root 2026-11-03 09:30:15 +08:00
居居
b1f94fd3a4 feat(discontinuation): add additional store data scopes 2026-11-03 09:27:20 +08:00
居居
eab5da8263 fix(discontinuation): preserve underlying regeneration errors 2026-11-03 09:09:52 +08:00
WuYongJie
01e1f61f91 fix(diy): load uncompressed SJ0450 door without wasm decoders
(cherry picked from commit 00eddb201d88eaa7f4aae2f150bd849b27a050c0)
2026-11-02 19:04:31 +08:00
WuYongJie
f3a123def5 feat(diy): add independent SJ0450 catalog and flip door hardware
(cherry picked from commit be3e390be0e0360a0149daf08e67affa9aa454ff)
2026-11-02 18:48:07 +08:00
居居
4cd214272e fix(discontinuation): bundle runtime tools and report child errors 2026-11-02 18:23:43 +08:00
WuYongJie
24ac1d5af9 feat(distribution): show archive SKU before barcode
Add a goods code column using the product archive SKU. Match only the paged barcode summaries and display a dash for missing codes; keep listing counts, detail expansion, exports and synchronization unchanged.
2026-11-02 18:00:38 +08:00
居居
73cab271a5 fix(discontinuation): run regeneration outside standalone runtime 2026-11-02 17:56:49 +08:00
WuYongJie
dfe8723cb3 Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-ssd-write-reduction 2026-11-02 17:43:15 +08:00
WuYongJie
831cb083ab fix(inventory): preserve Prisma snapshot timestamp semantics 2026-11-02 17:43:12 +08:00
居居
106cd93af8 fix(discontinuation): surface regeneration stage errors 2026-11-02 17:37:38 +08:00
WuYongJie
0150b5000d Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-ssd-write-reduction 2026-11-02 17:25:45 +08:00
WuYongJie
de59c180ce perf(inventory): publish only changed snapshot rows 2026-11-02 17:25:36 +08:00
居居
b07f34b558 feat(discontinuation): add management-only snapshot regeneration 2026-11-02 17:12:14 +08:00
居居
b5243326a7 feat(discontinuation): add one-click filter reset 2026-11-02 16:31:53 +08:00
居居
ca9096e98b feat(discontinuation): add filter options and configurable data scopes 2026-11-02 16:16:50 +08:00
居居
5bd2599c64 feat(clearance): 支持经管人工调整优先级 2026-11-02 15:58:40 +08:00
居居
e6a49f0e0c feat(clearance): 数字条件支持自定义输入 2026-11-02 15:41:20 +08:00
居居
e422504daa fix(supply-chain): fall back to cost when freight is missing
Allow ownerless owner-adjusted prices to use cost alone when nationwide freight is unavailable, while keeping the field empty when cost is also missing.
2026-11-02 15:33:42 +08:00
居居
b87d742886 feat(discontinuation): enforce owner scopes and valid replacements 2026-11-02 15:23:12 +08:00
居居
680a471a7b Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into HEAD 2026-11-02 14:15:09 +08:00
居居
0953ac8600 feat(discontinuation): 接入用户和部门权限配置 2026-11-02 14:15:06 +08:00
居居
43d3de6d7e fix(supply-chain): include freight in owner-adjusted fallback price
Leave DF-prefixed owner-adjusted prices empty, keep owner-limit numeric normalization unchanged, and derive ownerless adjusted prices from cost plus nationwide freight divided by the 40% gross-margin base.
2026-11-02 13:50:15 +08:00
WuYongJie
7e82310cc8 fix(supply-chain): restore modular processing page tab 2026-11-02 13:39:17 +08:00
WuYongJie
56e24b0b5f feat(diy): keep solid door colors consistent in AI renders
Share verified color standards across single and batch renders, add conservative result color review, and support signed development reference URLs.
2026-11-02 11:44:35 +08:00
WuYongJie
1c11a3e2e3 fix: retry failed store account group notifications 2026-11-02 11:41:09 +08:00
居居
2225341a67 fix(supply-chain): SABC 核算缺少销售数据时返回明确原因,不再显示「系统内部错误」
- 所选月份没有已同步销售事实时抛出 AppError(2026年11月还没有已同步的销售数据…),生产环境也能看到具体原因
- 补充单测覆盖该场景
2026-11-02 11:21:57 +08:00
apple
f7784d8faf fix(layout): 供应链账号菜单统一移至侧栏底部
避免供应链工作区顶部重复展示账号入口,并补充回归测试。
2026-11-02 11:07:53 +08:00
居居
b0b1512510 feat(discontinuation): 完善顶部标签与按日按月筛选 2026-11-02 10:28:05 +08:00
居居
07af4cf90b feat(clearance): 条件规则支持优先级字段 2026-11-02 10:18:26 +08:00
居居
01c118891f fix(clearance): 接入顶部标签路由标题 2026-11-02 10:12:14 +08:00
apple
5d4b964669 fix(modular-processing): install daily timer during production deploy 2026-11-02 10:11:39 +08:00
WuYongJie
ad8fee485c feat(diy): add batch image editing and selective downloads 2026-11-02 10:04:31 +08:00
居居
fae629d61f feat(clearance): 折扣限制支持自定义条件规则 2026-11-02 09:28:59 +08:00
居居
e2e1b61b0a feat(clearance): 完成率允许超过一百分 2026-11-02 09:19:05 +08:00
居居
cd9e227ba6 feat(clearance): 允许档位分数超过一百分 2026-11-02 09:16:06 +08:00
apple
4d6d84e104 feat(modular-processing): merge complete management source 2026-11-01 15:10:56 +08:00
WuYongJie
6568bcb349 fix(intake): clearly label products successfully pushed to Jushuitan
Show the successful push status and disable the already-pushed action with an explicit label.
2026-10-30 18:36:27 +08:00
WuYongJie
c64bb73e6d feat(diy): simplify batch records and name downloads after schemes 2026-10-30 17:57:06 +08:00
居居
594939ac74 feat(clearance): 支持无上限完成率档位 2026-10-30 17:20:40 +08:00
居居
e96b8c5b9a ops(supply-chain): add production limit snapshot timer
Install a production systemd timer at 09:30 Asia/Shanghai, persist daily snapshots under the shared release directory, and validate required production data sources before enabling the schedule.
2026-10-30 16:35:20 +08:00
居居
9386f795aa feat(clearance): 完成率档位支持自定义区间 2026-10-30 16:00:19 +08:00
居居
5e26d0edb7 feat(clearance): 规则条件值改为动态多选 2026-10-30 15:55:48 +08:00
居居
dec39d864c feat(supply-chain): add daily limit snapshot workflow
Generate and export an atomic daily limit snapshot from the full product archive, reconcile all limit-dependent archive fields before publication, and apply owner-adjusted pricing without the gross-margin floor when an owner limit exists.
2026-10-30 15:41:04 +08:00
WuYongJie
05d6b9dda8 feat(diy): add shared-angle batch AI rendering and refine scheme dialogs 2026-10-30 15:22:01 +08:00
WuYongJie
2e7a7a1c1c fix(product-archive): unify list and detail image sources 2026-10-30 13:03:20 +08:00
居居
a80326f308 feat(freight): 增加物流快照正式站定时调度 2026-10-30 11:04:04 +08:00
居居
6f1966ff36 feat(freight): 每日九点生成聚水潭物流规则快照 2026-10-30 10:30:09 +08:00
WuYongJie
f5c3aabd26 fix(diy): correct mirrored door texture orientation 2026-10-30 10:10:11 +08:00
居居
cd094bc90c fix(freight): 拦截说明文字作为商品编码提交 2026-10-30 10:05:59 +08:00
居居
649489b16c feat(freight): 支持条码固定价和体积运费测算 2026-10-29 17:27:26 +08:00
WuYongJie
afcede79a0 fix(intake): reuse empty SKU placeholders and normalize imported dimensions
Preserve factory specification names; match equivalent size separators.

Task: sku-import-empty-placeholder-20261029-01a18026
Source-commit: 86c66147ad569a6aaaf141e7522c87332a767253
2026-10-29 14:42:58 +08:00
WuYongJie
ea08b9cd88 fix(store-account): support JD profiles in account editor 2026-10-29 14:36:45 +08:00
居居
47c66348fd feat(freight): 支持连续对话修正与部分报价计算 2026-10-29 14:35:49 +08:00
WuYongJie
be4bb61f45 fix(intake): update children furniture execution standard to GB 28007-2024 2026-10-29 13:52:10 +08:00
居居
cd81a9230c feat(limit-management): 完善限价与破价订单监控 2026-10-29 13:21:59 +08:00
WuYongJie
37dd0c7230 feat(intake): preserve factory specification names in SKU imports 2026-10-29 11:40:38 +08:00
WuYongJie
caf2a20151 fix(limit-management): bind large style sets as a PostgreSQL array 2026-10-29 10:55:41 +08:00
WuYongJie
dbf3544d85 fix(limit-management): reuse locale comparator for archive sorting 2026-10-29 10:19:30 +08:00
WuYongJie
423d8bc7e6 fix(intake): convert WebP installation drawings and verify image delivery 2026-10-29 10:07:37 +08:00
WuYongJie
13372fdeb1 fix(freight): use framework-compatible event loop scheduling 2026-10-29 09:49:26 +08:00
WuYongJie
8d5a2d2836 feat(material-library): export all boards and normalize surface types 2026-10-29 09:18:40 +08:00
WuYongJie
5ab4fe6115 fix(freight): index batch rates and yield the web event loop 2026-10-28 20:10:03 +08:00
居居
fecc583766 feat(freight-maintenance): 完善批量测算与对话式比价 2026-10-28 18:12:43 +08:00
WuYongJie
0d1fd73f1c fix(intake): use configured vision model for reference cost estimates 2026-10-28 17:04:26 +08:00
居居
f7c9a0ba71 fix(limit-management): include freight in limit exports 2026-10-28 16:34:12 +08:00
WuYongJie
5b0505bfbf style(intake): align SKU toolbar actions by task 2026-10-28 16:16:16 +08:00
WuYongJie
70a581e8d4 feat(intake): add product notification delivery history 2026-10-28 16:03:13 +08:00
居居
832b639f03 feat(limit-management): add regional and national freight averages 2026-10-28 15:25:34 +08:00
居居
7523bd3ec0 feat(supply-chain): 完善快递维护与AI运费比价 2026-10-28 14:43:17 +08:00
WuYongJie
cbfe7f257c feat(intake): import SKU templates with automatic coding 2026-10-28 14:35:39 +08:00
居居
75adc3f7f2 feat(limit-management): consolidate limit and freight maintenance 2026-10-28 13:34:58 +08:00
居居
2af0a45e6a fix(supply-chain): 修复运费配置权限并过滤过期费率 2026-10-28 13:34:56 +08:00
居居
70aa055c83 feat(supply-chain): 新增运费配置查询页面 2026-10-28 13:34:54 +08:00
46 changed files with 1294 additions and 567 deletions

View 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`

View File

@ -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、穿透配置或其他项目服务。

View File

@ -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. 业务口径

View File

@ -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`:
- 「新品限价及限价调整」→ 货号取「货号(非商品编码)」,限价取「期望日常限价」;
其中的「调整限价明细表」子表(申请类型=调整原货号限价)只填「调整后限价」,该列同样作为限价取值

View File

@ -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'
);

View File

@ -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'
);

View File

@ -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) {

View File

@ -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)

View File

@ -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} 折扣`}
/>
)

View File

@ -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>

View File

@ -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 () => {

View File

@ -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) => {

View File

@ -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>
)}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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 () => {

View File

@ -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}

View File

@ -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')
})
})

View File

@ -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({

View File

@ -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]">

View File

@ -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 核算入口按权限显示(非产品开发路径)', () => {

View File

@ -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,

View File

@ -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[];
}

View File

@ -82,7 +82,7 @@ export const BREACH_LINK_SCOPE_APPEAL_TYPES = new Set<string>([
])
export const BREACH_REJECT_REASONS = [
'商家实收低于限价',
'买家实付低于适用限价',
'凭证不完整',
'凭证与订单不符',
'活动前未算好价格',

View File

@ -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('负责人限价或成本价都无效时不生成调整限价', () => {

View File

@ -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

View File

@ -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()

View File

@ -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)
})
})

View File

@ -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('需包含买家实付 / 数量')
})
})

View File

@ -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 () => {

View File

@ -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> }) => ({

View File

@ -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')

View File

@ -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)
)),
}
}

View File

@ -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
? '与系统数据一致'

View File

@ -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,

View File

@ -33,7 +33,7 @@ export const DEFAULT_BREACH_RULE_CONFIG: BreachRuleConfigData = {
buyerBearer: ['淘金币'],
pendingBearer: ['首单礼金'],
ownerTriggerRule: '非运营发起的流程:前两个月全公司运营同价;两个月后按销量触发负责人(销量高者为负责人)。',
judgingBasis: '以商家实收为准(实付款 + 平台承担);商家实收 < 限价×数量 判定破价;限价按货号维度、取订单付款时点生效的版本。',
judgingBasis: '以订单明细买家实付为准;买家实付 < 适用限价×数量 判定破价。商品负责人与链接负责人一致时用负责人限价,不一致时用非负责人限价。',
}
function cleanTextArray(value: unknown, fallback: string[]) {

View File

@ -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,

View File

@ -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),

View File

@ -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 =

View File

@ -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)]),

View File

@ -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 ?? ''),

View File

@ -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
}

View File

@ -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天')

View File

@ -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,
)