Compare commits

..

143 Commits

Author SHA1 Message Date
WuYongJie
c14a90eecb Merge commit '96c0184b3e77ddb281b61df39c87a11a6add5076' into HEAD 2026-11-07 18:12:41 +08:00
WuYongJie
e6ec1bd7ff feat(clearance): 整合人工清货价、审核工作台与小数评分修复 2026-11-07 18:12:41 +08:00
WuYongJie
96c0184b3e fix(installation): replace existing drawing tasks by complete drawing ID 2026-11-07 18:05:30 +08:00
WuYongJie
3ac1331469 fix(store-accounts): include page metadata and source dates in exports 2026-11-07 16:42:48 +08:00
WuYongJie
60fab9516d fix(modular-processing): 以整轮生成状态判定成功并补齐超时回归 2026-11-07 13:41:38 +08:00
apple
e6c25a648e feat(modular-processing): 完善生成监控、异常预警与主表冻结
部件缺口不再阻断整批生成;持久化生成状态并向系统管理员预警;页面展示失败原因与净缺口;冻结条码及之前字段;补充迁移和回归测试。
2026-11-07 13:17:08 +08:00
WuYongJie
a3e1af3bea fix(barcode-print): preserve Gujia 2026 product name position
Cherry-picked from 514c373a5f1b2c13072c4a5352197064f0454f4e; release only the authorized Gujia product name layout change.
2026-11-07 12:09:34 +08:00
WuYongJie
31d030ce24 fix(barcode-print): fit long product names on Gujia certificates
Cherry-picked from a273f7731548a33e2c9e389f15c05d5353eab612; release only the authorized Gujia product name layout change.
2026-11-07 11:01:31 +08:00
WuYongJie
ef2c6aa7f4 feat(store-account): add shop metadata and searchable filters 2026-11-07 10:06:28 +08:00
WuYongJie
b7b8b1ed64 feat(diy): extract editable scene prompts from reference images
(cherry picked from commit 96e92719d2f00c680a9ec33376675bea3b8fc8b5)
2026-11-06 17:39:03 +08:00
居居
6e443caf93 fix(clearance): 限制任务导出字段并禁止客户端图片抓取 2026-11-06 17:34:00 +08:00
居居
34a72da7dd fix(clearance): 任务导出改为当前查询结果快照 2026-11-06 17:09:41 +08:00
居居
489dbd7545 fix(clearance): 统一任务导出与页面发货数量口径 2026-11-06 17:09:41 +08:00
WuYongJie
6a27f3c19d feat(store-account): use source shop dates and add renewal notices
(cherry picked from commit d6456eb07748d6afd9a7797add765ae0d33f6406)
(cherry picked from commit 86c2b6d88aa245674fbe763945029e6597b1b76b)
2026-11-06 16:21:12 +08:00
WuYongJie
84807a89ab fix(intake): unify SKU numbering and two-template imports 2026-11-06 16:00:48 +08:00
居居
331e84cffb fix(clearance): 修正同步状态轮询与请求类型 2026-11-06 15:46:42 +08:00
居居
f57142164a perf(clearance): 拆分发货同步并增加状态反馈 2026-11-06 15:30:12 +08:00
居居
1e798a5aa2 feat(freight): 支持条码固定运费与三方标签计费 2026-11-06 14:15:33 +08:00
居居
072678f2bd fix(clearance): 自动刷新每日发货量并调整分数概览
- 返回每日发货缓存真实截止日并自动轮询刷新\n- 移除清货分数平均分卡片,保留完成率与负责人数量指标
2026-11-06 14:15:33 +08:00
居居
9fa09c7662 feat(clearance): 调整清货分数概览指标 2026-11-06 13:31:49 +08:00
居居
18335f0ab9 feat(clearance): 清货分数增加筛选后合计行 2026-11-06 13:31:49 +08:00
居居
ae4c382789 fix(clearance): 按条码和利润中心分拆任务进度 2026-11-06 13:31:49 +08:00
居居
9b1d7f7cd0 fix(clearance): 修复筛选选项、分数利润中心与全宽布局 2026-11-06 13:31:49 +08:00
居居
7b200dcc27 style(clearance): 清货价推送页面全宽平铺 2026-11-06 13:31:48 +08:00
WuYongJie
212f67c1eb feat(diy): add usage records restricted to Xuxiake account 2026-11-06 13:15:58 +08:00
居居
7e093835e5 fix(clearance): 修正每日发货缓存回补与利润中心兜底 2026-11-06 10:11:35 +08:00
居居
25ede03411 perf(clearance): 按日增量累计清货发货量 2026-11-06 10:11:35 +08:00
WuYongJie
46a2e3418e 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:57:03 +08:00
居居
405f217282 fix(clearance): 重试清货价推送连接失败 2026-11-06 09:13:59 +08:00
居居
f7a1b2f3ac fix(discontinuation): never expose unmapped SKU as replacement barcode 2026-11-05 18:09:38 +08:00
WuYongJie
88a6855383 fix(diy): pin support contact notice above AI panel scroll 2026-11-05 17:57:59 +08:00
WuYongJie
4d37d95606 Merge remote-tracking branch 'origin/main' into release/clearance-tasks-62191d50 2026-11-05 17:44:36 +08:00
WuYongJie
1cbf0b38f3 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:33 +08:00
居居
b273c62a4e feat(hr-sync): 人员信息同步纳入快递公司组织树(覆盖全部钉钉部门) 2026-11-05 17:35:32 +08:00
居居
b1154f7b30 feat(clearance): 清货价推送增加利润中心 2026-11-05 17:21:31 +08:00
居居
d4d9e0ba08 fix(clearance): 补齐历史店铺编号并显示清货价说明 2026-11-05 17:21:30 +08:00
WuYongJie
c875549d2f feat(clearance): publish task progress, shipment metrics and price exports
Release the seven reviewed clearance changes through 62191d50 and fix 828883e742e57fae4525888e17974d98a2559af4.
Preserve the applied archive migration; add a separate index migration.
Terminate failed/cancelled workbook downloads and retain frontend retry feedback.

Source commits:
976fc50be6c25c9e83dca1c29410a7ad8ee40b7a perf(clearance): 异步刷新任务出货进度
4c1cd5527b4c54403ad985c8e807dbe695316309 perf(clearance): 解耦任务首屏与进度刷新
72fb742a3d56beceecaec485782258a7ccff214d style(clearance): 缩短历史成本价备注
9e064c4b4268b361bdc24c47b2bd50ba163f3e1e fix(clearance): 从聚水潭店铺表映射店铺编号
75b6d6b08d0b4dc97926171600793dd5c0dbcd76 feat(clearance): 增加清货价推送筛选与导出
05e4509f0d443829872b0d8ed6c2c2a9f6b8ede3 fix(clearance): 保留真实发货量并补齐任务池字段
62191d50ab5f96868232331810961d74ad2fc34b style(clearance): 精简清货价推送筛选条件
2026-11-05 16:02:25 +08:00
WuYongJie
45589b97e0 feat(1688): display and filter product suppliers 2026-11-05 15:02:57 +08:00
WuYongJie
69f1a0c30a feat(clearance): add monthly price push archive
Integrate clearance feature from shared commit 6a61f375182ae35fe5b87da23ab459c89544e3f8. Includes full profit-center shop coverage and actual product-code mapping; excludes unrelated shared-branch changes.
2026-11-05 14:32:14 +08:00
WuYongJie
e38560dfdb fix(intake): complete inventory tasks when either flow succeeds
Cherry-picked from f79dfd05a92b6d904a3906101bb1702bf4a0107f; release only the authorized inventory task and alert fix.
2026-11-05 14:06:57 +08:00
居居
9930b1ee90 feat(hr-sync): 人员信息同步纳入供应商组织树(仅排除快递公司) 2026-11-05 11:57:10 +08:00
WuYongJie
b5128e8645 feat(discontinuation): release corrected activity appeal review
Integrate only activity appeal functionality and fixes from 708d6e74fbcd449babb69e60a9b2bb6d7e80b37b, preserving current main permissions and evidence loading guards.
2026-11-05 11:39:19 +08:00
居居
9a501745f4 fix(supply-chain): include snapshot images in limit export 2026-11-05 11:13:40 +08:00
居居
d40ba51515 fix(discontinuation): lazy load appeal evidence with loading protection
Source: ff6406d1e0cd7729528e09a6a704d2f371f0d63f; loading fix: fc28557c3de09606fc058a469c8e3cfba1c555c4. Preserve current main permission documentation.
2026-11-05 10:00:33 +08:00
WuYongJie
aa1671292d test(auth): preserve current supply-chain exclusions during release 2026-11-04 14:51:59 +08:00
WuYongJie
6e57c8f541 fix(auth): honor personal supply-chain grants for external intake users 2026-11-04 14:51:59 +08:00
居居
03c2cc2396 fix(discontinuation): restart stale RPA browser before sync 2026-11-04 13:29:10 +08:00
WuYongJie
495bd42ac8 fix(product-archive): retry marketing sales hourly until 14:00 2026-11-04 10:33:10 +08:00
WuYongJie
58fc8bdbee fix(intake): show successful inventory channel exclusively
Cherry-picked from efcba62d3b1e50deaf34c8df54fbb0fe4228dfb5; release only the authorized inventory status display fix.
2026-11-04 09:51:07 +08:00
居居
f6af1843c2 fix(clearance): 优化任务加载与分销任务计算 2026-11-03 18:06:13 +08:00
WuYongJie
06d61525bb fix(diy): avoid reserved layout filename in guide content 2026-11-03 17:59:48 +08:00
WuYongJie
57f6de24d0 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:44:34 +08:00
居居
84710c8fa5 feat(discontinuation): mark combo replacements 2026-11-03 17:18:05 +08:00
居居
57ef1ec3ef fix(discontinuation): hide unavailable replacement aliases 2026-11-03 17:18:05 +08:00
WuYongJie
5b17403a8c fix(intake): keep inventory status labels on one line
Cherry-picked from b11d2efbe109d46614d94eba916cea27833f8c36
2026-11-03 16:02:43 +08:00
居居
ad7b69c3e4 feat(clearance): 完善任务映射、计算与配置交互 2026-11-03 15:54:03 +08:00
居居
d23590db0a feat(clearance): 支持人工任务按利润中心导入 2026-11-03 15:54:03 +08:00
居居
56db9d6e93 feat(clearance): 接入人工批次与月度批次规则 2026-11-03 15:54:03 +08:00
居居
fc885376e5 feat(clearance): 支持经管人工调整优先级 2026-11-03 15:54:03 +08:00
居居
6fd5e989cc feat(discontinuation): support batch filter queries 2026-11-03 15:54:03 +08:00
WuYongJie
011bb99278 fix(intake): reliably enqueue factory virtual inventory
Cherry-picked from 4ed8082fba353c1ad18662509e3a6c12780ece30; only the authorized inventory fix is released.
2026-11-03 15:29:07 +08:00
居居
d7b640c482 feat(discontinuation): suppress dedicated warehouse links 2026-11-03 15:27:29 +08:00
居居
5b329dda80 fix(supply-chain): exclude owner-adjusted price from limit exports 2026-11-03 15:17:31 +08:00
居居
e9a1473348 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:17:31 +08:00
WuYongJie
3818b8a65d 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.

(cherry picked from commit 824603764b8892689c7043f0f02c0613ed4fed82)
2026-11-03 13:35:36 +08:00
WuYongJie
1be13ea777 fix(product-archive): prioritize warehouse storage configuration 2026-11-03 12:15:24 +08:00
居居
26ff64287b fix(discontinuation): apply all link filters 2026-11-03 11:29:10 +08:00
居居
11821543ae feat(discontinuation): simplify appeal reason options 2026-11-03 11:21:15 +08:00
居居
9cfb8c4dfb feat(discontinuation): separate appeal and review roles 2026-11-03 11:21:15 +08:00
居居
37ff36fe43 feat(discontinuation): add additional store data scopes 2026-11-03 11:21:14 +08:00
居居
685a23cc2a fix(discontinuation): retain saved data scope overrides 2026-11-03 11:00:19 +08:00
WuYongJie
ae28ff1334 fix(layout): hide external AI navigation for supplier accounts 2026-11-03 09:54:12 +08:00
WuYongJie
fa04954206 fix(diy): highlight new SJ0450 series without changing names 2026-11-03 09:40:04 +08:00
居居
ee5c4570b4 fix(discontinuation): resolve regeneration runner from release root 2026-11-03 09:37:36 +08:00
居居
9958a7fbd4 fix(discontinuation): preserve underlying regeneration errors 2026-11-03 09:13:09 +08:00
WuYongJie
677cdfb8e9 fix(diy): load uncompressed SJ0450 door without wasm decoders
(cherry picked from commit 00eddb201d88eaa7f4aae2f150bd849b27a050c0)
(cherry picked from commit 01e1f61f91919a39a4e5942d169a80063a0f8e7d)
2026-11-02 19:04:36 +08:00
WuYongJie
e0d3ae2c89 feat(diy): add independent SJ0450 catalog and flip door hardware
(cherry picked from commit be3e390be0e0360a0149daf08e67affa9aa454ff)
(cherry picked from commit f3a123def54064fdc604e5c6ff50e1ac7cd1be42)
2026-11-02 18:50:18 +08:00
居居
d2f8a7ca76 fix(discontinuation): bundle runtime tools and report child errors 2026-11-02 18:25:44 +08:00
WuYongJie
1cfb598d9e 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:49 +08:00
居居
05a74eeb15 fix(discontinuation): run regeneration outside standalone runtime 2026-11-02 17:59:02 +08:00
WuYongJie
16d0bc90a2 fix(inventory): preserve Prisma snapshot timestamp semantics 2026-11-02 17:43:23 +08:00
居居
a70e5a759e fix(discontinuation): surface regeneration stage errors 2026-11-02 17:39:34 +08:00
WuYongJie
7580d9612d perf(inventory): publish only changed snapshot rows 2026-11-02 17:25:56 +08:00
居居
c0429c23d5 feat(discontinuation): add management-only snapshot regeneration 2026-11-02 17:18:06 +08:00
居居
c094200ec5 feat(discontinuation): add one-click filter reset 2026-11-02 16:41:57 +08:00
居居
c99bff0998 feat(discontinuation): add filter options and configurable data scopes 2026-11-02 16:41:56 +08:00
居居
9df0e0ac38 feat(clearance): 数字条件支持自定义输入 2026-11-02 16:05:30 +08:00
ZCode
1f2275333b revert(discontinuation): drop department permission dimension
User decision 2026-11-02: the department permission config introduced with the discontinuation permission wiring had wrong logic and is removed entirely. Drops department@ subject keys, the department override layer in access resolution, the DepartmentPermissionPanel UI, its wiring in UserPermissionCenter/Tabs, related tests and doc lines. Account and role based configuration for the discontinuation management node stays; the new owner-scope data access from b87d7428 is unaffected.
2026-11-02 15:50:37 +08:00
居居
5f29b761e1 feat(discontinuation): enforce owner scopes and valid replacements 2026-11-02 15:44:47 +08:00
ZCode
8f0c3c6d9e fix(supply-chain): typecheck department permission subject keys
resolveSupplyChainPermissionSubjectKeys returned IntakeNodePermissionRoleKey[]
2026-11-02 14:34:06 +08:00
居居
751f104816 feat(discontinuation): 接入用户和部门权限配置 2026-11-02 14:20:21 +08:00
WuYongJie
2076d0eacf fix(supply-chain): restore modular processing page tab 2026-11-02 13:39:47 +08:00
WuYongJie
e2ff1cab4f 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.

(cherry picked from commit 56e24b0b5f21c233cf5525c9839762bf6dc51133)
2026-11-02 11:45:55 +08:00
WuYongJie
01f7a4a191 fix: retry failed store account group notifications 2026-11-02 11:42:00 +08:00
居居
6420741003 feat(discontinuation): 完善顶部标签与按日按月筛选 2026-11-02 10:31:49 +08:00
居居
7fae59e687 feat(clearance): 条件规则支持优先级字段 2026-11-02 10:31:49 +08:00
居居
598fb4b97f fix(clearance): 接入顶部标签路由标题 2026-11-02 10:31:49 +08:00
居居
a0707ca369 feat(clearance): 折扣限制支持自定义条件规则 2026-11-02 10:31:49 +08:00
居居
3088716d9a feat(clearance): 完成率允许超过一百分 2026-11-02 10:31:49 +08:00
居居
6cfdace699 feat(clearance): 允许档位分数超过一百分 2026-11-02 10:31:49 +08:00
apple
95e1a462a8 fix(modular-processing): install daily timer during production deploy 2026-11-02 10:11:05 +08:00
WuYongJie
baabe2c3c2 feat(diy): add batch image editing and selective downloads 2026-11-02 10:04:45 +08:00
apple
940a7d5531 feat(modular-processing): merge complete management source 2026-11-02 09:25:37 +08:00
WuYongJie
747a07d4d6 fix(intake): clearly label products successfully pushed to Jushuitan
(cherry picked from commit 6568bcb349991a41a14cb663c6aa1b4bb4c02254)
2026-10-30 18:37:26 +08:00
WuYongJie
993539eca2 feat(diy): simplify batch records and name downloads after schemes 2026-10-30 17:57:20 +08:00
居居
4e70404ca6 feat(clearance): 支持无上限完成率档位 2026-10-30 17:49:49 +08:00
居居
b1b23a0b5e 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:53:19 +08:00
居居
2a1ae8640b feat(clearance): 完成率档位支持自定义区间 2026-10-30 16:14:21 +08:00
居居
b5fed760cf feat(clearance): 规则条件值改为动态多选 2026-10-30 16:14:20 +08:00
居居
1afd1f7ffa 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 16:14:20 +08:00
WuYongJie
cdd3e8cfcb feat(diy): add shared-angle batch AI rendering and refine scheme dialogs 2026-10-30 15:22:32 +08:00
WuYongJie
c03a9b5e36 fix(product-archive): unify list and detail image sources 2026-10-30 13:05:57 +08:00
居居
9cc2aa9ea7 feat(freight): 增加物流快照正式站定时调度 2026-10-30 11:14:45 +08:00
居居
49e00b0f93 feat(freight): 每日九点生成聚水潭物流规则快照 2026-10-30 10:35:40 +08:00
居居
d590b5d6cd fix(freight): 拦截说明文字作为商品编码提交
原文 cherry-pick 自主线 cd094bc90;因 main 上 calculateByBarcodes 为索引化融合版(不走单条方法),补充同样的 isFreightBarcodeInput 格式拦截与改进后的“不在产品全量档案中”文案到批量循环,并更新批量测试(FAIL 新文案 + 说明文字拦截用例)。
2026-10-30 10:14:08 +08:00
WuYongJie
9ae4ffb44b fix(diy): correct mirrored door texture orientation 2026-10-30 10:10:49 +08:00
居居
8571b5f649 feat(freight): 支持条码固定价和体积运费测算 2026-10-29 17:44:03 +08:00
居居
2ea3f22349 feat(freight): 支持连续对话修正与部分报价计算 2026-10-29 14:48:55 +08:00
WuYongJie
60c933a14e 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: afcede79a052c0d478866f71c59fd15f4c64d3ef
2026-10-29 14:43:43 +08:00
WuYongJie
f18a0f3f80 fix(store-account): support JD profiles in account editor 2026-10-29 14:36:55 +08:00
WuYongJie
ee410934ec fix(intake): update children furniture execution standard to GB 28007-2024 2026-10-29 13:52:20 +08:00
WuYongJie
b936295a7d feat(intake): preserve factory specification names in SKU imports 2026-10-29 11:40:50 +08:00
WuYongJie
9af1d9efda fix(limit-management): bind large style sets as a PostgreSQL array 2026-10-29 10:56:34 +08:00
WuYongJie
2be511f346 perf(freight): index batch barcode calculations with shared calculator
calculateByBarcodes previously called calculateByBarcode per item, re-reading rates, shipment share and courier rules and re-scanning the full rate catalogue for every barcode. Switch it to the shared createFreightCalculator snapshot: batch archive read via findProductArchiveSnapshots, courier rules fetched once per warehouse, rates and province shipment share fetched once, and the calculation loop yields to the event loop every 8ms like the archive page path. Per-item validation messages and result/error shapes are unchanged.
2026-10-29 10:46:16 +08:00
WuYongJie
2830836b92 test(freight): align release fixture with published region formula 2026-10-29 10:22:51 +08:00
WuYongJie
c169f9392c fix(limit-management): reuse locale comparator for archive sorting 2026-10-29 10:20:35 +08:00
居居
09128489fb feat(freight-maintenance): 完善批量测算与对话式比价 2026-10-29 10:13:38 +08:00
WuYongJie
4fd17c6677 fix(intake): convert WebP installation drawings and verify image delivery
Isolated release of 423d8bc7e663788d4bd2f3c7a41d6f3a1b1089d1.
2026-10-29 10:08:56 +08:00
WuYongJie
66db2a26f6 fix(freight): use framework-compatible event loop scheduling 2026-10-29 09:49:12 +08:00
WuYongJie
ec8cf4a819 feat(material-library): export all boards and normalize surface types 2026-10-29 09:19:36 +08:00
WuYongJie
416bcd5939 test(freight): preserve production formulas in scoped release 2026-10-28 20:18:20 +08:00
WuYongJie
e889d53cb6 fix(freight): index batch rates and yield the web event loop 2026-10-28 20:10:56 +08:00
WuYongJie
83a3138fe9 fix(intake): use configured vision model for reference cost estimates 2026-10-28 17:06:19 +08:00
居居
45387c9003 fix(limit-management): include freight in limit exports 2026-10-28 16:36:28 +08:00
WuYongJie
a9f2917c5b style(intake): align SKU toolbar actions by task 2026-10-28 16:16:29 +08:00
WuYongJie
77665c85b5 feat(intake): add product notification delivery history 2026-10-28 16:03:41 +08:00
居居
3f7710ef84 feat(limit-management): add regional and national freight averages 2026-10-28 15:56:14 +08:00
居居
281e52bb95 feat(supply-chain): 完善快递维护与AI运费比价 2026-10-28 15:56:14 +08:00
WuYongJie
2775b4ff83 feat(intake): import SKU templates with automatic coding 2026-10-28 14:36:34 +08:00
WuYongJie
e58f8ba96d fix(limit-management): restore mysql2 query call compatible with locked 3.15.3 typings
75adc3f7 imported QueryValues from mysql2/promise, which only exists in newer mysql2 typings than the package-lock version (3.15.3), breaking tsc on a clean install. Restore the previous equivalent call form (options object + values argument), preserving runtime behavior exactly.
2026-10-28 14:04:01 +08:00
居居
062666f530 feat(limit-management): consolidate limit and freight maintenance 2026-10-28 13:59:30 +08:00
居居
ce0aa8f387 fix(supply-chain): 修复运费配置权限并过滤过期费率 2026-10-28 13:59:30 +08:00
居居
5c5fef8b8e feat(supply-chain): 新增运费配置查询页面 2026-10-28 13:59:30 +08:00
52 changed files with 691 additions and 1811 deletions

View File

@ -16,22 +16,24 @@
只读取得费率、物流规则和商品字段快照,在本机离线运行,不向生产网页接口重放旧重计算。上游费率视图有 75,630 行;复现的 200 行限价页中有 159 行能进行运费计算,共需 4,465 行费率。
同一快照、同一计算时间、相同输入与输出顺序,本机 Node.js 24.22.1 的一次对比:
同一快照、同一计算时间、相同输入与输出顺序,本机 Node.js 24.22.1 的一次带规范化调用计数的对比(计数会影响耗时):
| 项目 | 修复前 | 修复后 |
| --- | ---: | ---: |
| 计算耗时 | 11,394 ms | 133 ms |
| 计算耗时 | 16,056 ms | 124 ms |
| 字符串规范化调用 | 64,631,978 | 16,030 |
| 完整结果 SHA-256 | `fe480d89ec75f3235e891fec47bb43a5d7dbfcf71e777ef366a7e89d8e6576cb` | 相同 |
| 完整结果 SHA-256 | `a34b18ccfd50ae6053b26d7ca2513fa101368cadfdbee3befaf7f752c360efc6` | 相同 |
这是离线计算对比,不代表生产端到端请求耗时,也不包含数据库和网络时间。原始商业数据与 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,8 +243,6 @@ pnpm exec tsx scripts/generate-supply-chain-inventory-alert-snapshot.ts
只有经管业务身份且拥有操作权限的人员可审核通过、驳回或打回。
- 申诉原因固定为“商品编码有误”“活动中,无法下架”“其他”三项,接口和页面使用同一份枚举。
- 申诉和审核均支持按链接批量处理:同一平台店铺商品编码下符合条件的订单一次覆盖。
- 未下架监控支持勾选多条明细后在任一明细中批量通过、驳回、打回或取消驳回;
后端仅更新勾选 ID 中符合当前状态和人员数据权限的记录。
- 审核通过后原乐捐记录保留,但页面展示与月度汇总按 0 元计算,实现自动减免。
- 负责人离职或无法匹配到员工目录在职记录时,状态显示“人员已离职”,乐捐金额按 0 元计算且不能申诉。
- “人员已离职”可作为申诉状态筛选;筛选后可批量向部门负责人的钉钉个人工作通知推送处理提醒。
@ -274,7 +272,6 @@ pnpm exec tsx scripts/generate-supply-chain-inventory-alert-snapshot.ts
- “操作”权限控制申诉、审核、取消驳回和离职负责人批量推送;申诉校验运营业务身份及本人
负责人匹配,审核类操作校验经管业务身份。旧 `discontinuationPenalties` 节点继续兼容审核操作,
已有授权不会失效。
- “人员已离职”明细必须勾选后才能批量推送部门负责人,只推送勾选且仍为离职状态的记录。
## 4. 业务口径

View File

@ -5,28 +5,27 @@
## 一、限价与判定口径
1. **判定基准**:以订单明细中的「买家实付」为准;`买家实付 < 适用限价 × 数量` 即判定破价。
2. **负责人限价规则**:订单商品负责人与链接维度负责人一致时,比较负责人限价;不一致时,比较非负责人限价。无法同时取得两边负责人时,按负责人限价处理。
3. **实付款与平台承担**:实付款 + 平台承担(红包 / 消费券 / 平台券)= 商家实收;平台承担只用于展示金额构成,不作为破价判定金额。凭证必须同时包含「买家实付」和「数量」。
4. **出资方字典**
1. **判定基准**:以「商家实收」为准;`商家实收 < 限价 × 数量` 即判定破价。
2. **实付款与平台承担**:实付款 + 平台承担(红包 / 消费券 / 平台券)= 商家实收;凭证必须同时包含「实付款」和「数量」。
3. **出资方字典**
- 商家承担:店铺优惠(官方立减、单品直降、店铺券)、政府补贴
- 平台承担:红包、消费券、平台券、平台优惠、新品礼金
- 买家承担:淘金币
- 待定:首单礼金(等与运营确认后补录)
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 分钟;
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 分钟;
接口未配置或读取失败时该列显示为空、筛选候选值为空。配置项:`MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY`,
不填则复用素材平台 `MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*`。
14. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
13. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
- 判定按**换出行原编码**的限价执行(客户在平台上购买的那个编码);
- 成交数量、买家实付与商家实收取**换入行**;换入行的商家实收抓不到时可回退订单主表商家实收 → 订单主表买家实付,但破价判定金额始终优先使用换入明细的 `buyer_paid_amount`;
- 成交数量与商家实收取**换入行**(换货不改变订单成交金额);换入行金额抓不到时回退订单主表商家实收 → 订单主表买家实付,都取不到才按 0(由运营申诉说明);
- 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单;
- 配对优先按 `sku_uuid` 精确匹配,剩余行仅在一对一时顺序配对;复杂结构(多对多)暂不强行配对,相关行按各自编码现状处理。
@ -62,7 +61,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`)。订单商品负责人取产品全量档案 `opsHead`;链接负责人按 `shop_i_id` 读取 `op_TargetManagement.ider` 最新记录。报表库查询较慢,逐日增量任务建议按天分片拉取。
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`)。报表库查询较慢,逐日增量任务建议按天分片拉取。
3. **限价来源**:钉钉流程(仅取**已完结**),共 4 个模板,同步脚本 `scripts/sync-limit-workflows.mjs`:
- 「新品限价及限价调整」→ 货号取「货号(非商品编码)」,限价取「期望日常限价」;
其中的「调整限价明细表」子表(申请类型=调整原货号限价)只填「调整后限价」,该列同样作为限价取值

View File

@ -1,9 +0,0 @@
-- 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

@ -1,9 +0,0 @@
-- 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,13 +17,11 @@ 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', 'SELECTED']).default('ORDER'),
ids: z.array(z.string().min(1)).max(500).optional(),
scope: z.enum(['ORDER', 'LINK']).default('ORDER'),
}),
z.object({
action: z.literal('cancel-reject'),
scope: z.enum(['ORDER', 'LINK', 'SELECTED']).default('ORDER'),
ids: z.array(z.string().min(1)).max(500).optional(),
scope: z.enum(['ORDER', 'LINK']).default('ORDER'),
}),
])
@ -89,13 +87,11 @@ 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,9 +9,6 @@ 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),与该页面的入口权限一致。
@ -28,20 +25,10 @@ async function withRegionFreight(
const freightByBarcode = await freightConfigService.calculateRegionFreightsForArchiveRows(page.rows)
return {
...page,
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,
}),
}
}),
rows: page.rows.map((row) => ({
...row,
...freightByBarcode.get(row.barcode),
})),
}
} catch (error) {
console.warn('[limit-products] 区域和全国运费计算失败,保留其他限价数据', error)

View File

@ -453,11 +453,6 @@ 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()
}
@ -1195,14 +1190,8 @@ export function ClearanceView({
return
}
const clearanceTotal = rowClearanceTotal(row)
const fixedTask = isFixedTaskRow(row)
const salesNoDiscount = isSalesStatus(row.salesStatus, row.productStatus)
const discountDisabled = salesNoDiscount || (!fixedTask && (
row.discountRestricted
|| row.discountChannelClosed
|| row.dropshipOnlyStock
))
const discount = discountDisabled || !edit.discount.trim()
const discount = salesNoDiscount || row.discountRestricted || !edit.discount.trim()
? 1
: parseDiscountInput(edit.discount)
const reason = salesNoDiscount ? '' : edit.reason.trim()
@ -1522,12 +1511,11 @@ 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 || fixedTask)
&& !row.taskChannelClosed
if (column.kind === 'image') return <PoolImageCell row={row} />
if (column.key === 'priority' && canReview) {
return (
@ -1576,7 +1564,7 @@ export function ClearanceView({
}
}}
placeholder={
row.taskChannelClosed && !fixedTask
row.taskChannelClosed
? '正常销售无需定任务'
: comboStatus === '多包异常'
? comboStatus
@ -1584,7 +1572,7 @@ export function ClearanceView({
}
aria-label={`${row.productName || row.barcode} 任务量`}
title={
row.taskChannelClosed && !fixedTask
row.taskChannelClosed
? '销售状态且可用天数小于30天,无需定任务和申请折扣'
: comboStatus === '多包异常'
? `该商品${comboStatus},不允许填写任务或申请折扣`
@ -1595,33 +1583,11 @@ export function ClearanceView({
}
if (column.kind === 'discount') {
const salesNoDiscount = isSalesStatus(row.salesStatus, row.productStatus)
const discountDisabled = salesNoDiscount || (!fixedTask && (
row.discountRestricted
const discountDisabled =
salesNoDiscount
|| 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`}
@ -1637,8 +1603,28 @@ export function ClearanceView({
saveDraftNow(row.barcode)
}
}}
placeholder={discountPlaceholder}
title={discountTitle}
placeholder={
salesNoDiscount
? '销售状态无需折扣'
: row.dropshipOnlyStock
? '仅代发仓有库存,不可申请折扣'
: row.discountChannelClosed
? '正常销售无需折扣'
: row.discountRestricted
? '已配置禁止折扣'
: '如 5折,可留空'
}
title={
salesNoDiscount
? '销售状态商品不允许申请折扣'
: row.dropshipOnlyStock
? '简尚总库存为0且仅代发仓有库存,不允许申请折扣'
: row.discountChannelClosed
? '正常销售状态无需定任务和折扣'
: row.discountRestricted
? '该商品编码或 SPU 已配置禁止申请折扣'
: undefined
}
aria-label={`${row.productName || row.barcode} 折扣`}
/>
)

View File

@ -74,12 +74,6 @@ 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({
@ -222,7 +216,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, 170, 110, 88]
const DEFAULT_COL_WIDTHS = [92, 180, 150, 140, 150, 180, 110, 118, 150, 88]
const [colWidths, setColWidths] = useState<number[]>(DEFAULT_COL_WIDTHS)
const startColumnResize = (index: number, event: React.MouseEvent) => {
event.preventDefault()
@ -455,13 +449,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.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]
? [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]
lines.push(cells.map((value) => `"${String(value ?? '').replaceAll('"', '""')}"`).join(','))
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
@ -621,15 +615,14 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
{sortTh('soId', '订单号', 5, 'soId')}
{sortTh('listedAt', '上架时间', 6)}
{sortTh('flowEndDate', '流程结束日期', 7)}
{sortTh('buyerPaidAmount', '买家实付 / 限价', 8)}
{sortTh('sellerIncomeAmount', '商家实收', 9)}
{sortTh('status', '状态', 10, 'status')}
{sortTh('sellerIncomeAmount', '商家实收 / 限价', 8)}
{sortTh('status', '状态', 9, 'status')}
</tr>
</thead>
<tbody>
{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>}
{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>}
{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>
@ -641,12 +634,10 @@ 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.buyerPaidAmount)}
<b className={styles.gap}> {buyerPaidGap(row) !== null ? buyerPaidGap(row)?.toFixed(2) : ''}</b>
{money(row.sellerIncomeAmount)}
<b className={styles.gap}> {row.gapAmount !== null ? row.gapAmount.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>
))}
@ -678,12 +669,9 @@ export function BreachOrderTab({ canExport = false }: { canExport?: boolean }) {
<h3>订单与破价数据</h3>
<div className={styles.in}>
<div className={styles.price}>
<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 className={(order.gapAmount ?? 0) < 0 ? styles.bad : undefined}>{money(order.sellerIncomeAmount)}</b></div>
<div><span>限价×数量</span><b>{money(order.limitTotal)}</b></div>
<div><span>价差</span><b className={(buyerPaidGap(order) ?? 0) < 0 ? styles.bad : undefined}>{buyerPaidGap(order) !== null ? buyerPaidGap(order)?.toFixed(2) : '—'}</b></div>
<div><span>价差</span><b className={(order.gapAmount ?? 0) < 0 ? styles.bad : undefined}>{order.gapAmount !== null ? order.gapAmount.toFixed(2) : '—'}</b></div>
</div>
<div className={styles.facts}>
<div><span>付款日期</span><b>{day(order.payDate)}</b></div>
@ -816,8 +804,7 @@ 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,35 +756,11 @@ describe('限价管理', () => {
const row = await screen.findByText('BG006701N1063V01')
fireEvent.click(row)
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()
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()
})
it('破价订单审核不展示上架时间、流程结束、实收区间和价差区间筛选', async () => {
@ -796,7 +772,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 () => {
@ -814,10 +790,9 @@ describe('限价管理', () => {
const row = await screen.findByText('BG006701N1063V01')
fireEvent.click(row)
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()
expect(await screen.findByText('商家实收')).toBeInTheDocument()
expect(screen.queryByText('审核要点(仅审核人可见)')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '通过' })).not.toBeInTheDocument()
})
it('快递测算已迁出限价管理页面', async () => {

View File

@ -26,53 +26,6 @@ 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()
@ -141,105 +94,6 @@ 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,7 +397,6 @@ 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,
)
@ -550,7 +549,6 @@ export function DiscontinuationView({
setLoading(true)
setError('')
setPageNumber(1)
setSelectedPenaltyIds([])
setApplied({ ...filters })
}
@ -559,7 +557,6 @@ export function DiscontinuationView({
setLoading(true)
setError('')
setPageNumber(1)
setSelectedPenaltyIds([])
setFilters(next)
setApplied(next)
}
@ -590,7 +587,6 @@ export function DiscontinuationView({
const changePage = (nextPage: number) => {
setLoading(true)
setError('')
setSelectedPenaltyIds([])
setPageNumber(nextPage)
}
@ -606,16 +602,6 @@ 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
@ -629,25 +615,6 @@ 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))
@ -657,10 +624,6 @@ 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 || '')
@ -792,7 +755,6 @@ 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 {
@ -801,29 +763,20 @@ export function DiscontinuationView({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'review',
decision,
note: reviewNote,
scope: useSelectedBatch ? 'SELECTED' : reviewScope,
...(useSelectedBatch ? { ids: selectedPenaltyRows.map((row) => row.id) } : {}),
}),
body: JSON.stringify({ action: 'review', decision, note: reviewNote, scope: reviewScope }),
},
)
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 : '审核失败')
@ -834,7 +787,6 @@ export function DiscontinuationView({
const cancelPenaltyReject = async () => {
if (!selectedPenalty || !penaltyDetail.ready || penaltyActionBusy) return
const useSelectedBatch = selectedPenaltyRows.length > 1
setPenaltyActionBusy(true)
setPenaltyActionMessage('')
try {
@ -843,11 +795,7 @@ export function DiscontinuationView({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'cancel-reject',
scope: useSelectedBatch ? 'SELECTED' : reviewScope,
...(useSelectedBatch ? { ids: selectedPenaltyRows.map((row) => row.id) } : {}),
}),
body: JSON.stringify({ action: 'cancel-reject', scope: reviewScope }),
},
)
const body = await response.json()
@ -857,7 +805,6 @@ export function DiscontinuationView({
setPenaltyActionMessage(
`已取消驳回,${body.data?.covered ?? 1} 条明细恢复为待审核`,
)
if (useSelectedBatch) setSelectedPenaltyIds([])
setRefreshTick((value) => value + 1)
} catch (error) {
setPenaltyActionMessage(error instanceof Error ? error.message : '取消驳回失败')
@ -867,10 +814,7 @@ export function DiscontinuationView({
}
const pushLeftEmployees = async () => {
if (!selectedLeftEmployeeIds.length) {
setPushMessage('请先勾选需要推送的人员已离职明细')
return
}
if (!penaltyRows.length) return
setPushingLeftEmployees(true)
setPushMessage('')
try {
@ -879,7 +823,7 @@ export function DiscontinuationView({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: selectedLeftEmployeeIds }),
body: JSON.stringify({ ids: penaltyRows.map((row) => row.id) }),
},
)
const body = await response.json()
@ -892,8 +836,6 @@ 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 {
@ -1011,7 +953,6 @@ export function DiscontinuationView({
setFilters(nextFilters)
setApplied(nextFilters)
setPageNumber(1)
setSelectedPenaltyIds([])
}}
>
{item.label}
@ -1209,7 +1150,6 @@ export function DiscontinuationView({
setFilters(next)
setApplied(next)
setPageNumber(1)
setSelectedPenaltyIds([])
}}
style={{
display: 'inline-flex',
@ -1261,21 +1201,14 @@ export function DiscontinuationView({
<b>¥{Number(payload?.monthlyPenaltyTotal ?? 0).toFixed(2)}</b>
<span style={{ color: '#78716c' }}>(¥10/单)</span>
</span>
{canReviewPenalty && pageHasLeftEmployeeRows && (
{filters.status.includes('LEFT_EMPLOYEE') && canReviewPenalty && penaltyRows.length > 0 && (
<button
type="button"
disabled={pushingLeftEmployees || selectedLeftEmployeeIds.length === 0}
disabled={pushingLeftEmployees}
onClick={() => void pushLeftEmployees()}
style={{
...primaryButtonStyle,
background: '#1d4ed8',
borderColor: '#1d4ed8',
opacity: pushingLeftEmployees || selectedLeftEmployeeIds.length === 0 ? 0.55 : 1,
}}
style={{ ...primaryButtonStyle, background: '#1d4ed8', borderColor: '#1d4ed8', opacity: pushingLeftEmployees ? 0.55 : 1 }}
>
{pushingLeftEmployees
? '推送中…'
: `批量推送部门负责人(${selectedLeftEmployeeIds.length})`}
{pushingLeftEmployees ? '推送中…' : `批量推送部门负责人(${penaltyRows.length})`}
</button>
)}
{pushMessage ? <span style={{ color: pushMessage.includes('失败') ? '#b91c1c' : '#1d4ed8', fontSize: 12 }}>{pushMessage}</span> : null}
@ -1324,22 +1257,6 @@ 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>
))}
@ -1349,36 +1266,9 @@ export function DiscontinuationView({
{penaltyRows.map((row) => (
<tr
key={row.id}
style={{
borderTop: '1px solid #f0f0f1',
cursor: 'pointer',
background: selectedPenaltyIdSet.has(row.id) ? '#f0f6ff' : undefined,
}}
style={{ borderTop: '1px solid #f0f0f1', cursor: 'pointer' }}
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}
@ -1409,7 +1299,6 @@ export function DiscontinuationView({
row={selectedPenalty}
canSubmitAppeal={canSubmitAppealPenalty}
canReview={canReviewPenalty}
batchSelectedCount={selectedPenaltyRows.length}
viewerName={viewerName}
busy={penaltyActionBusy}
detailLoading={penaltyDetail.loading}
@ -1534,7 +1423,6 @@ function PenaltyAppealDrawer({
row,
canSubmitAppeal,
canReview,
batchSelectedCount,
viewerName,
busy,
detailLoading,
@ -1565,7 +1453,6 @@ function PenaltyAppealDrawer({
row: PenaltyRow | null
canSubmitAppeal: boolean
canReview: boolean
batchSelectedCount: number
viewerName: string
busy: boolean
detailLoading: boolean
@ -1596,7 +1483,6 @@ 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)
@ -1819,15 +1705,10 @@ 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>
{!useSelectedBatch ? <label
<label
style={{
display: 'flex',
alignItems: 'flex-start',
@ -1853,7 +1734,7 @@ function PenaltyAppealDrawer({
勾选后,同一平台店铺商品编码下的全部待审核订单会使用相同结果一次审核。
</span>
</span>
</label> : null}
</label>
<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 }}>
@ -1913,10 +1794,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' }}>打回补充{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>
<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>
</div>
</section>
)}
@ -1924,15 +1805,10 @@ 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>
{!useSelectedBatch ? <label
<label
style={{
display: 'flex',
alignItems: 'flex-start',
@ -1958,16 +1834,14 @@ function PenaltyAppealDrawer({
勾选后,同一平台店铺商品编码下的全部已驳回明细会一起恢复为待审核。
</span>
</span>
</label> : null}
</label>
<button
type="button"
disabled={busy}
onClick={onCancelReject}
style={{ ...primaryButtonStyle, background: '#1d4ed8', borderColor: '#1d4ed8', opacity: busy ? 0.5 : 1 }}
>
{busy
? '处理中…'
: `取消驳回${useSelectedBatch ? `(${batchSelectedCount})` : ''}`}
{busy ? '处理中…' : '取消驳回'}
</button>
</section>
)}

View File

@ -194,29 +194,12 @@ describe('ModularProductProcessingOrdersView pane resizing', () => {
/>,
)
const tableScroller = document.querySelector('table')?.parentElement
const mergedArchiveGroup = document.querySelector('th[data-group-key="archive"]')
expect(mergedArchiveGroup).toHaveTextContent('商品基础信息')
expect(document.querySelector('th[data-group-key="archive:frozen"]')).toBeNull()
expect(tableScroller).not.toBeNull()
tableScroller!.scrollLeft = 120
fireEvent.scroll(tableScroller!)
const frozenArchiveGroup = document.querySelector('th[data-group-key="archive:frozen"]')
const scrolledArchiveGroup = document.querySelector('th[data-group-key="archive:scroll"]')
const frozenGroupOverlay = document.querySelector('[data-group-key="archive:frozen-overlay"]')
const planningGroup = document.querySelector('th[data-group-key="planning:scroll"]')
const generationHeader = document.querySelector('th[data-frozen-column="generatedAt"]')
const barcodeHeader = document.querySelector('th[data-column-key="barcode"]')
const barcodeCell = document.querySelector('td[data-column-key="barcode"]')
const productNameHeader = document.querySelector('th[data-column-key="productName"]')
expect(generationHeader).toHaveStyle({ left: '42px' })
expect(frozenArchiveGroup).toHaveTextContent('')
expect(frozenArchiveGroup).toHaveStyle({ left: '210px' })
expect(frozenGroupOverlay).toHaveTextContent('商品基础信息')
expect(scrolledArchiveGroup).toHaveTextContent('')
expect(planningGroup).toHaveTextContent('加工规划')
expect(barcodeHeader).toHaveAttribute('data-frozen-column', 'barcode')
expect(barcodeHeader).toHaveStyle({ left: '584px' })
expect(barcodeCell).toHaveStyle({ left: '584px' })

View File

@ -29,7 +29,6 @@ import {
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
@ -692,7 +691,6 @@ export function ModularProductProcessingOrdersView({
const [quickDrafts, setQuickDrafts] = useState<Record<string, string>>({})
const [refreshing, setRefreshing] = useState(false)
const [refreshNotice, setRefreshNotice] = useState<string | null>(null)
const [orderTableScrolled, setOrderTableScrolled] = useState(false)
const [workOrderData, setWorkOrderData] = useState<WorkOrderPageData>({
rows: [],
pageInfo: { page: 1, pageSize: 100, total: 0, totalPages: 1 },
@ -738,7 +736,6 @@ export function ModularProductProcessingOrdersView({
const [masterPaneShare, setMasterPaneShare] = useState(DEFAULT_MASTER_PANE_SHARE)
const firstRequest = useRef(true)
const loadRequestId = useRef(0)
const orderTableScrollRef = useRef<HTMLDivElement>(null)
const masterDetailRegionRef = useRef<HTMLDivElement>(null)
const exportMenuRef = useRef<HTMLSpanElement>(null)
const paneResizeCleanupRef = useRef<(() => void) | null>(null)
@ -763,51 +760,6 @@ export function ModularProductProcessingOrdersView({
}
const lastFrozenOrderFieldKey =
frozenOrderFields[frozenOrderFields.length - 1]?.key ?? null
const frozenGroupOverlay = frozenOrderFields.length > 0
? {
left: frozenOrderFieldLeft.get(frozenOrderFields[0].key) ?? 0,
width: frozenOrderFields.reduce(
(total, field) =>
total + (columnWidths.orders[field.key] ?? field.width),
0,
),
label:
GROUPS.find((group) => group.key === frozenOrderFields[0].group)?.label ??
'',
}
: null
const visibleOrderGroupHeaderSegments: Array<{
key: string
label: string
className: string
count: number
firstFieldKey: string
lastFieldKey: string
frozen: boolean
}> = []
for (const field of visibleOrderFields) {
const group = GROUPS.find((candidate) => candidate.key === field.group)
if (!group) continue
const frozen = frozenOrderFieldLeft.has(field.key)
const segmentKey = `${group.key}:${frozen ? 'frozen' : 'scroll'}`
const previous = visibleOrderGroupHeaderSegments[
visibleOrderGroupHeaderSegments.length - 1
]
if (previous?.key === segmentKey) {
previous.count += 1
previous.lastFieldKey = field.key
continue
}
visibleOrderGroupHeaderSegments.push({
key: segmentKey,
label: group.label,
className: group.className,
count: 1,
firstFieldKey: field.key,
lastFieldKey: field.key,
frozen,
})
}
const orderTableWidth = 42 + generationColumnWidth + visibleOrderFields.reduce(
(total, field) => total + (columnWidths.orders[field.key] ?? field.width), 0,
)
@ -816,14 +768,6 @@ export function ModularProductProcessingOrdersView({
)
const initialOrderLoading = loading && data.rows.length === 0
useLayoutEffect(() => {
if (activeTab !== 'orders') return
const scroller = orderTableScrollRef.current
if (!scroller) return
const scrolled = scroller.scrollLeft > 0
setOrderTableScrolled((current) => current === scrolled ? current : scrolled)
}, [activeTab])
useEffect(() => {
try {
const saved = JSON.parse(window.localStorage.getItem(COLUMN_WIDTH_STORAGE_KEY) || '{}') as Record<TableKind, Record<string, number>>
@ -2179,29 +2123,7 @@ export function ModularProductProcessingOrdersView({
>
<section id="modular-processing-master-pane" className={styles.masterPane} aria-label="模块化商品加工主表">
<div className={styles.tableRegion}>
<div
ref={orderTableScrollRef}
className={styles.tableScroll}
onScroll={(event) => {
const scrolled = event.currentTarget.scrollLeft > 0
setOrderTableScrolled((current) =>
current === scrolled ? current : scrolled)
}}
>
{orderTableScrolled && frozenGroupOverlay ? (
<div className={styles.frozenGroupHeaderLayer} aria-hidden="true">
<div
className={styles.frozenGroupHeaderOverlay}
data-group-key="archive:frozen-overlay"
style={{
left: frozenGroupOverlay.left,
width: frozenGroupOverlay.width,
}}
>
{frozenGroupOverlay.label}
</div>
</div>
) : null}
<div className={styles.tableScroll}>
<table className={styles.orderTable} style={{ width: orderTableWidth }}>
<colgroup>
<col style={{ width: 42 }} />
@ -2248,51 +2170,12 @@ export function ModularProductProcessingOrdersView({
}}
/>
</th>
{orderTableScrolled
? visibleOrderGroupHeaderSegments.map((segment, index) => {
const frozenLeft = segment.frozen
? frozenOrderFieldLeft.get(segment.firstFieldKey)
: undefined
const frozenBoundary =
segment.lastFieldKey === lastFrozenOrderFieldKey
return (
<th
key={`${segment.key}:${index}`}
colSpan={segment.count}
className={[
segment.className,
segment.frozen ? styles.frozenHeader : '',
frozenBoundary ? styles.frozenBoundary : '',
].filter(Boolean).join(' ')}
style={frozenLeft !== undefined ? { left: frozenLeft } : undefined}
data-group-key={segment.key}
data-frozen-group={segment.frozen ? 'true' : undefined}
>
{segment.frozen
? ''
: segment.key === 'archive:scroll'
? ''
: segment.label}
</th>
)
})
: GROUPS.map((group) => {
const count = visibleOrderFields.filter(
(field) => field.group === group.key,
).length
return count > 0
? (
<th
key={group.key}
colSpan={count}
className={group.className}
data-group-key={group.key}
>
{group.label}
</th>
)
: null
})}
{GROUPS.map((group) => {
const count = visibleOrderFields.filter((field) => field.group === group.key).length
return count > 0
? <th key={group.key} colSpan={count} className={group.className}>{group.label}</th>
: null
})}
</tr>
<tr>
{visibleOrderFields.map((field) => {

View File

@ -232,14 +232,7 @@
.definitionTable th,
.definitionTable td { border-right: 1px solid var(--sc-border); border-bottom: 1px solid var(--sc-border); }
.orderTable thead th { position: sticky; z-index: 2; height: auto; min-height: 40px; padding: 5px 8px; background: var(--sc-surface-muted); color: var(--sc-ink); font-size: 12px; font-weight: 650; text-align: center; }
.orderTable thead .groupRow th {
top: 0;
z-index: 7;
height: 27px;
background: var(--sc-surface-muted);
font-size: 11px;
text-align: center;
}
.orderTable thead .groupRow th { top: 0; height: 27px; font-size: 11px; }
.orderTable thead tr:nth-child(2) th { top: 27px; }
.orderTable thead th.filterableHeader,
.componentTable thead th.filterableHeader { position: sticky; padding-right: 27px; }
@ -457,10 +450,6 @@
padding: 0;
font-size: 10px;
}
.orderTable thead .groupRow .selectHeader {
z-index: 20;
background: var(--sc-surface-muted);
}
.orderTable thead .generationHeader {
position: sticky;
top: 0;
@ -470,38 +459,10 @@
font-size: 10px;
white-space: normal;
}
.orderTable thead .groupRow .generationHeader {
z-index: 20;
background: var(--sc-surface-muted);
}
.orderTable thead .frozenHeader {
z-index: 8;
z-index: 6;
background: var(--sc-surface-muted);
}
.frozenGroupHeaderLayer {
position: sticky;
top: 0;
left: 0;
z-index: 30;
width: 100%;
height: 0;
pointer-events: none;
}
.frozenGroupHeaderOverlay {
position: absolute;
top: 0;
display: flex;
height: 27px;
align-items: center;
justify-content: center;
border-right: 1px solid var(--sc-border);
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface-muted);
color: var(--sc-ink);
font-size: 11px;
font-weight: 650;
box-shadow: 3px 0 5px -3px rgb(15 23 42 / 42%);
}
.generationHeaderInner { display: inline-flex; align-items: center; gap: 4px; }
.groupArchive { box-shadow: inset 0 2px #64748b; }
.groupPlanning { box-shadow: inset 0 2px #2563eb; }

View File

@ -1,37 +0,0 @@
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

@ -1,124 +0,0 @@
'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,16 +17,7 @@ jest.mock('@/hooks/useAuth', () => ({
}))
jest.mock('./usePermissionManagedUsers', () => ({
usePermissionManagedUsers: () => ({
users: [],
departments: [{
id: 'dept-1',
name: '供应链中心',
fullPath: '供应链中心',
}],
loading: false,
error: null,
}),
usePermissionManagedUsers: () => ({ users: [], loading: false, error: null }),
}))
jest.mock('./usePermissionRoles', () => ({
@ -41,10 +32,6 @@ 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 }))
@ -62,8 +49,6 @@ 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,7 +12,6 @@ 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'
@ -93,13 +92,7 @@ export function UserPermissionCenter() {
</header>
<main className="min-h-0 flex-1 overflow-hidden">
{activeView === 'departments' ? (
<DepartmentPermissionPanel
departments={departments}
loading={loading}
error={error}
/>
) : activeView === 'roles' ? (
{activeView === 'roles' ? (
<RoleManagementPanel
roles={permissionRoles}
loading={permissionRolesLoading}

View File

@ -3,12 +3,11 @@ import userEvent from '@testing-library/user-event'
import { UserPermissionTabs } from './UserPermissionTabs'
describe('UserPermissionTabs', () => {
it('shows account, role and department permission tabs', () => {
it('shows account and role management without a separate directory tab', () => {
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()
@ -22,13 +21,4 @@ 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,13 +1,12 @@
'use client'
import { BriefcaseBusiness, Building2, Users } from 'lucide-react'
import { BriefcaseBusiness, Users } from 'lucide-react'
export type UserPermissionView = 'accounts' | 'roles' | 'departments'
export type UserPermissionView = 'accounts' | 'roles'
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 />
</>}
{!isSupplyChain ? <UserMenu user={user ?? null} /> : null}
<UserMenu user={user ?? 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,7 +194,6 @@ 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,5 +1,4 @@
import { buildBreachLinkGroupKey } from '@/lib/supply-chain-breach-order'
import { deriveNonOwnerLimitPrice } from '@/lib/supply-chain-limit-price-fields'
/**
* 破价单生成:换货识别 + 限价判定(service 与预演脚本共用,保证口径一致)。
@ -7,8 +6,8 @@ import { deriveNonOwnerLimitPrice } from '@/lib/supply-chain-limit-price-fields'
* 业务口径(已与业务确认):
* - 手工换货单:聚水潭明细里表现为「被替换行(item_status=Replaced,数量归 0)」
* + 「换入行(item_ext_data 含 item_source=手工换货/手工添加,金额与数量落在这一行)」。
* - 判定按「换出行原编码」的限价执行;成交数量与买家实付取换入行。
* - 订单商品负责人与链接负责人一致时使用负责人限价;不一致时使用非负责人限价。
* - 判定按「换出行原编码」的限价执行;成交数量与商家实收取换入行(抓不到按 0 参与判定,
* 即页面上显示 ¥0.00,由运营申诉说明)。
* - 换货行配对成功后不再按换入编码单独生成,避免同一订单重复生成两条破价单。
*/
@ -28,13 +27,11 @@ export type BreachGenerationLine = {
refundStatus: string
/** 订单主表商家实收(换货单兜底金额) */
orderSellerIncomeAmount?: number | null
/** 订单主表买家实付(仅用于换货行商家实收缺失时兜底,不替代明细买家实付) */
/** 订单主表买家实付(换货单次兜底金额) */
orderPaidAmount?: number | null
itemStatus: string
itemSource: string
skuUuid: string | null
/** 链接维度负责人(店铺款式编码 → 负责人),由生成服务按源数据补齐。 */
linkOwnerName?: string | null
/** 内部标记:该行为手工换货配对后按原编码生成的判定行 */
fromManualSwap?: boolean
}
@ -64,8 +61,6 @@ export type BreachGenerationAuthContext = {
barcodeToStyle: Map<string, string>
/** 货号 → 负责人限价 */
ownerLimitByStyle: Map<string, number>
/** 商品编码(条码) → 订单商品负责人 */
orderOwnerByBarcode: Map<string, string>
/** 货号 → 价格授权列表 */
authsByStyle: Map<string, BreachGenerationAuth[]>
}
@ -109,7 +104,7 @@ export type BreachGenerationStats = {
swapOrderAmountFallback: number
/** 进入限价匹配的判定行数(正常行 + 换货行) */
scanned: number
/** 命中破价(买家实付 < 限价×数量)的行数 */
/** 命中破价(实收 < 限价×数量)的行数 */
breached: number
/** 因没有限价记录而被跳过的行数 */
skippedNoLimit: number
@ -124,16 +119,6 @@ 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>()
@ -151,16 +136,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
const buyerPaidAmount = incomingPaid
let 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 {
@ -214,7 +199,7 @@ function pairManualSwapLines(lines: BreachGenerationLine[]): SwapPairResult {
/**
* 根据订单明细与限价表生成破价单候选(不写库)。
* 同一 (订单, 商品编码) 的多行先聚合,再按「买家实付 < 限价×数量」判定。
* 同一 (订单, 商品编码) 的多行先聚合,再按「商家实收 < 限价×数量」判定。
*/
export function buildBreachOrderCandidates(
lines: BreachGenerationLine[],
@ -263,28 +248,22 @@ export function buildBreachOrderCandidates(
stats.scanned += 1
// —— 限价来源(业务口径:以流程为准)——
// 优先使用限价流程同步的「货号负责人限价」;流程中没有该货号时,回退到店铺限价(台账)。
const normalizedItemCode = normalizeCode(row.itemCode)
const styleCode = authContext ? authContext.barcodeToStyle.get(normalizedItemCode) : undefined
const styleCode = authContext ? authContext.barcodeToStyle.get(String(row.itemCode ?? '').trim()) : undefined
const flowUnitPrice = styleCode ? authContext?.ownerLimitByStyle.get(styleCode) ?? null : null
const linkLimit = limitMap.get(`${row.shopName}::${row.itemCode}`)
const ownerUnitPrice = flowUnitPrice ?? (linkLimit ? Number(linkLimit.limitUnitPrice) : null)
if (ownerUnitPrice === null) {
const baseUnitPrice = flowUnitPrice ?? (linkLimit ? Number(linkLimit.limitUnitPrice) : null)
if (baseUnitPrice === 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 = ownerMatches ? ownerUnitPrice : deriveNonOwnerLimitPrice(ownerUnitPrice)
// 同价期内:按负责人限价判定,实收 ≥ 负责人限价 → 豁免;超期后恢复非负责人限价基准。
// 活动期内:实收 ≥ 调整后限价 → 豁免;低于调整后限价则按调整后限价判定。
let effectiveUnitPrice = baseUnitPrice
if (authContext && styleCode) {
const auths = authContext.authsByStyle.get(styleCode)
if (auths?.length) {
@ -298,12 +277,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 (buyerPaid >= ownerPrice) exempted = true
if (sellerIncome >= ownerPrice) exempted = true
else effectiveUnitPrice = ownerPrice
break
}
if (Number.isFinite(end) && payTime > end) {
effectiveUnitPrice = deriveNonOwnerLimitPrice(ownerPrice)
effectiveUnitPrice = ownerPrice + Math.max(ownerPrice * 0.05, 10)
break
}
}
@ -311,7 +290,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 (buyerPaid >= auth.adjustedLimitPrice) exempted = true
if (sellerIncome >= auth.adjustedLimitPrice) exempted = true
else effectiveUnitPrice = auth.adjustedLimitPrice
break
}
@ -326,9 +305,9 @@ export function buildBreachOrderCandidates(
}
const limitTotal = effectiveUnitPrice * qty
if (!(buyerPaid < limitTotal)) continue
if (!(sellerIncome < limitTotal)) continue
stats.breached += 1
const platformBear = Math.max(0, Number((sellerIncome - buyerPaid).toFixed(4)))
const platformBear = Math.max(0, Number((sellerIncome - Number(row.buyerPaidAmount || 0)).toFixed(4)))
candidates.push({
oId: row.oId,
platform: row.platform || '',
@ -345,13 +324,13 @@ export function buildBreachOrderCandidates(
itemCode: row.itemCode,
}),
qty: Number(row.qty),
buyerPaidAmount: buyerPaid,
buyerPaidAmount: Number(row.buyerPaidAmount || 0),
platformBearAmount: platformBear,
sellerIncomeAmount: sellerIncome,
limitUnitPrice: effectiveUnitPrice,
limitTotal,
gapAmount: Number((buyerPaid - limitTotal).toFixed(4)),
ownerName: orderOwnerName || linkLimit?.ownerName || linkOwnerName,
gapAmount: Number((sellerIncome - limitTotal).toFixed(4)),
ownerName: linkLimit?.ownerName || '',
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, type QueryOptions } from 'mysql2/promise';
import { createPool, type Pool } from 'mysql2/promise';
type GlobalMysql = typeof globalThis & { __selectionMarketMysqlPoolV2?: Pool };
const globalForMysql = globalThis as GlobalMysql;
@ -70,12 +70,7 @@ export async function mysqlQuery<T = unknown>(
timeoutMs = 60000,
): Promise<T[]> {
const pool = getPool();
const queryOptions: QueryOptions = {
sql: sqlText,
values: params as QueryOptions['values'],
timeout: timeoutMs,
};
const [rows] = await pool.query(queryOptions);
const [rows] = await pool.query({ sql: sqlText, timeout: timeoutMs }, params);
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,6 +1,5 @@
import {
deriveOwnerAdjustedLimitPrice,
resolveOwnerAdjustedLimitPriceAfterFreight,
resolveLimitPriceFields,
} from './supply-chain-limit-price-fields'
@ -93,7 +92,7 @@ describe('resolveLimitPriceFields', () => {
})).toBe(166.88)
})
it('负责人限价为空时基础字段留空,待全国运费计算后生成调整限价', () => {
it('负责人限价为空时解析字段仍生成调整限价', () => {
const fields = resolveLimitPriceFields({
...ACTIVE_INPUT,
ownerLimitPrice: null,
@ -101,77 +100,7 @@ describe('resolveLimitPriceFields', () => {
})
expect(fields.opsPriceLimit).toBe('')
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('')
expect(fields.ownerAdjustedLimitPrice).toBe('166.88')
})
it('负责人限价或成本价都无效时不生成调整限价', () => {

View File

@ -84,54 +84,6 @@ 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: '',
@ -148,7 +100,6 @@ function emptyLimitPriceFields() {
}
export function resolveLimitPriceFields(input: {
barcode?: string
productStatus: string
ownerLimitPrice: number | null
costPrice: string
@ -165,18 +116,17 @@ 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

@ -1,117 +0,0 @@
import { supplyChainDiscontinuationSourceRepository } from '../supply-chain-discontinuation-source.repository'
jest.mock('mssql', () => ({
__esModule: true,
default: {
MAX: 'MAX',
NVarChar: jest.fn((size: unknown) => ({ type: 'NVarChar', size })),
Date: jest.fn(() => ({ type: 'Date' })),
ConnectionPool: jest.fn(),
},
}))
describe('supply-chain-discontinuation-source repository', () => {
const repository = supplyChainDiscontinuationSourceRepository
const originalGetPool = (repository as unknown as {
getPool: () => Promise<unknown>
}).getPool
afterEach(() => {
;(repository as unknown as { getPool: () => Promise<unknown> }).getPool = originalGetPool
})
it('aggregates enabled store centers for each task profit center by day', async () => {
const request = {
input: jest.fn().mockReturnThis(),
query: jest.fn().mockResolvedValue({
recordset: [{
barcode: 'AD033203N263V01',
profitCenter: '运营中心G',
statDate: '2026-11-02',
qty: 1n,
}],
}),
}
const pool = {
request: jest.fn(() => request),
}
;(repository as unknown as {
getPool: () => Promise<typeof pool>
}).getPool = jest.fn().mockResolvedValue(pool)
await expect(repository.listClearanceDailyShipmentsByProfitCenter({
subjects: [
{ barcode: 'AD033203N263V01', profitCenter: '运营中心G' },
{ barcode: 'AD033203N263V01', profitCenter: '运营中心B' },
],
startDate: '2026-11-01',
endDateExclusive: '2026-11-06',
})).resolves.toEqual([{
barcode: 'AD033203N263V01',
profitCenter: '运营中心G',
statDate: '2026-11-02',
qty: 1,
}])
const sqlText = String(request.query.mock.calls[0]?.[0] ?? '')
expect(sqlText).toContain('rpt.dws_sn_sales_full_info_di')
expect(sqlText).toContain('sales.goods_count')
expect(sqlText).toContain('sales.profit_center')
expect(sqlText).toContain('sales.stat_date')
expect(sqlText).toContain("N'运营中心A'")
expect(sqlText).toContain("N'运营中心C'")
expect(sqlText).toContain("N'运营中心G'")
expect(sqlText).toContain("N'分销部'")
expect(sqlText).not.toContain('= subjects_input.profitCenter')
expect(sqlText).not.toContain('jst_salesOutboundInfo')
expect(sqlText).not.toContain('jst_qmcust_salesoutbound')
const subjectsInput = request.input.mock.calls.find(([name]) => name === 'subjects')
expect(JSON.parse(String(subjectsInput?.[2] ?? '[]'))).toEqual([
{ barcode: 'AD033203N263V01', profitCenter: '运营中心G' },
{ barcode: 'AD033203N263V01', profitCenter: '运营中心B' },
])
expect(request.input).toHaveBeenCalledWith('startDate', expect.anything(), '2026-11-01')
expect(request.input).toHaveBeenCalledWith(
'endDate',
expect.anything(),
'2026-11-06',
)
})
it('aggregates enabled store centers for each task profit center for the month', async () => {
const request = {
input: jest.fn().mockReturnThis(),
query: jest.fn().mockResolvedValue({
recordset: [{
barcode: 'DNZ039613N193V01',
profitCenter: '运营中心A',
qty: 222n,
through: '2026-11-06',
}],
}),
}
const pool = {
request: jest.fn(() => request),
}
;(repository as unknown as {
getPool: () => Promise<typeof pool>
}).getPool = jest.fn().mockResolvedValue(pool)
await expect(repository.listClearanceOutboundSalesByProfitCenter({
periodKey: '2026-11',
subjects: [{ barcode: 'DNZ039613N193V01', profitCenter: '运营中心A' }],
})).resolves.toEqual([{
barcode: 'DNZ039613N193V01',
profitCenter: '运营中心A',
qty: 222,
through: '2026-11-06',
}])
const sqlText = String(request.query.mock.calls[0]?.[0] ?? '')
expect(sqlText).toContain("N'运营中心A'")
expect(sqlText).toContain("N'运营中心C'")
expect(sqlText).toContain("N'运营中心G'")
expect(sqlText).toContain("N'分销部'")
expect(sqlText).not.toContain('= subjects_input.profitCenter')
})
})

View File

@ -24,8 +24,6 @@ export type JstBreachOrderSourceRow = {
itemSource: string
/** item_ext_data 中的 sku_uuid,用于把「换出行」与「换入行」精确配对 */
skuUuid: string | null
/** 店铺款式编码对应的链接维度负责人 */
linkOwnerName?: string
}
function requiredEnv(name: string) {
@ -94,7 +92,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, orderPaidExpr: string) => `
const lineColumns = (refundExpr: string, orderSellerIncomeExpr: 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,
@ -109,7 +107,7 @@ class JstBreachOrderSourceRepository {
CAST(ISNULL(o.status, '') AS varchar(40)) AS orderStatus,
${refundExpr} AS refundStatus,
${orderSellerIncomeExpr} AS orderSellerIncomeAmount,
${orderPaidExpr} AS orderPaidAmount,
CAST(ISNULL(o.paid_amount, 0) AS decimal(18,4)) AS orderPaidAmount,
CAST(ISNULL(i.item_status, '') AS varchar(40)) AS itemStatus,
CASE
WHEN i.item_ext_data LIKE N'%"item_source":"手工换货"%' THEN N'手工换货'
@ -124,18 +122,13 @@ 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))`,
`CAST(ISNULL(o.paid_amount, 0) AS decimal(18,4))`,
)}
SELECT ${lineColumns(`CAST('' AS varchar(40))`, `CAST(NULL 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)
@ -144,50 +137,6 @@ 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

@ -296,53 +296,6 @@ describe('replacement rule lookup', () => {
})
})
describe('BOM read retry', () => {
it('retries a timed-out BOM query and returns the successful result', async () => {
const previous = Object.fromEntries(
['JST_REPORT_DB_HOST', 'JST_REPORT_DB_NAME', 'JST_REPORT_DB_USER', 'JST_REPORT_DB_PASSWORD']
.map((key) => [key, process.env[key]]),
)
Object.assign(process.env, {
JST_REPORT_DB_HOST: 'localhost',
JST_REPORT_DB_NAME: 'report_test',
JST_REPORT_DB_USER: 'reader',
JST_REPORT_DB_PASSWORD: 'test-only',
})
const input = jest.fn().mockReturnThis()
const query = jest.fn()
.mockResolvedValueOnce({
recordset: [
{ name: 'sku_id' },
{ name: 'map_outer_sku_id' },
{ name: 'rm_qty' },
],
})
.mockRejectedValueOnce(new Error('operation timed out'))
.mockResolvedValueOnce({
recordset: [{ componentBarcode: 'PART-1', requiredStockTotal: 10 }],
})
const pool = {
connect: jest.fn().mockResolvedValue({ request: () => ({ input, query }) }),
} as unknown as sql.ConnectionPool
try {
const repository = new ModularProcessingUpstreamRepositoryClass(() => pool)
const result = await repository.listBomDemandTotals([{
parentBarcode: 'BARCODE-1',
processingQuantity: 2,
}])
expect(result).toEqual(new Map([['PART-1', 10]]))
expect(query).toHaveBeenCalledTimes(3)
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
})
})
describe('open manufacture in-transit calculation', () => {
it('joins open headers to items and sums qty minus io_qty by sku_id', async () => {
const previous = Object.fromEntries(

View File

@ -240,39 +240,6 @@ export class ModularProcessingUpstreamUnavailableError extends Error {
}
}
const BOM_READ_MAX_ATTEMPTS = 3
const BOM_READ_RETRY_BASE_DELAY_MS = 500
async function withBomReadRetry<T>(
operation: string,
task: () => Promise<T>,
): Promise<T> {
let lastError: unknown
for (let attempt = 1; attempt <= BOM_READ_MAX_ATTEMPTS; attempt += 1) {
try {
return await task()
} catch (error) {
lastError = error
if (attempt >= BOM_READ_MAX_ATTEMPTS) break
console.warn('[modular-processing-bom] read failed, retrying', {
operation,
attempt,
maxAttempts: BOM_READ_MAX_ATTEMPTS,
message: error instanceof Error ? error.message : String(error),
})
await new Promise((resolve) => {
setTimeout(resolve, BOM_READ_RETRY_BASE_DELAY_MS * attempt)
})
}
}
const message = lastError instanceof Error
? lastError.message
: String(lastError)
throw new ModularProcessingUpstreamUnavailableError(
`${operation} 读取失败,已自动重试 ${BOM_READ_MAX_ATTEMPTS} 次:${message}`,
)
}
function requiredConfig() {
const server = process.env.JST_REPORT_DB_HOST?.trim()
const database = process.env.JST_REPORT_DB_NAME?.trim()
@ -594,10 +561,7 @@ export class ModularProcessingUpstreamRepositoryClass
return { source: `${DEFAULT_SCHEMA}.${BOM_ITEM_TABLE}`, records: [] }
}
const batch = await withBomReadRetry(
'BOM 部件明细读取',
() => this.queryBomComponentsForParents([normalizedParent], true),
)
const batch = await this.queryBomComponentsForParents([normalizedParent], true)
return {
source: batch.source,
records: batch.recordsByParent.get(normalizedParent) ?? [],
@ -632,10 +596,7 @@ export class ModularProcessingUpstreamRepositoryClass
}
async listBomComponentsForParents(parentBarcodes: string[]) {
return withBomReadRetry(
'BOM 部件明细批量读取',
() => this.queryBomComponentsForParents(parentBarcodes, false),
)
return this.queryBomComponentsForParents(parentBarcodes, false)
}
async listBomSellabilityFlags(
@ -665,34 +626,32 @@ export class ModularProcessingUpstreamRepositoryClass
const config = requiredConfig()
const pool = await this.poolFactory().connect()
const result = await withBomReadRetry(
'BOM 售卖判断读取',
() => pool.request()
.input(
'demandsJson',
sql.NVarChar(sql.MAX),
JSON.stringify(
Array.from(demandsByParent, ([parentBarcode, processingQuantity]) => ({
parentBarcode,
processingQuantity,
})),
),
)
.input(
'stocksJson',
sql.NVarChar(sql.MAX),
JSON.stringify(
Array.from(componentStockByBarcode, ([componentBarcode, availableStock]) => ({
componentBarcode: normalizedBarcode(componentBarcode),
availableStock: numeric(availableStock),
})).filter((row) => row.componentBarcode),
),
)
.query<{
parentBarcode: string
lacksTotalRequirement: boolean
lacksSingleRequirement: boolean
}>(`
const result = await pool.request()
.input(
'demandsJson',
sql.NVarChar(sql.MAX),
JSON.stringify(
Array.from(demandsByParent, ([parentBarcode, processingQuantity]) => ({
parentBarcode,
processingQuantity,
})),
),
)
.input(
'stocksJson',
sql.NVarChar(sql.MAX),
JSON.stringify(
Array.from(componentStockByBarcode, ([componentBarcode, availableStock]) => ({
componentBarcode: normalizedBarcode(componentBarcode),
availableStock: numeric(availableStock),
})).filter((row) => row.componentBarcode),
),
)
.query<{
parentBarcode: string
lacksTotalRequirement: boolean
lacksSingleRequirement: boolean
}>(`
WITH demands AS (
SELECT
UPPER(LTRIM(RTRIM(parentBarcode))) AS parentBarcode,
@ -746,10 +705,9 @@ export class ModularProcessingUpstreamRepositoryClass
INNER JOIN totals t ON t.componentBarcode = b.componentBarcode
LEFT JOIN stocks s ON s.componentBarcode = b.componentBarcode
GROUP BY b.parentBarcode
OPTION (HASH JOIN, MAXDOP 4)`),
)
OPTION (HASH JOIN, MAXDOP 4)`)
const flagsByBarcode = new Map<string, ModularProcessingBomSellabilityFlag>(
return new Map<string, ModularProcessingBomSellabilityFlag>(
result.recordset.flatMap((row) => {
const barcode = normalizedBarcode(row.parentBarcode)
return barcode
@ -760,14 +718,6 @@ export class ModularProcessingUpstreamRepositoryClass
: []
}),
)
const missingBarcodes = Array.from(demandsByParent.keys())
.filter((barcode) => !flagsByBarcode.has(barcode))
if (missingBarcodes.length > 0) {
throw new ModularProcessingUpstreamUnavailableError(
`BOM 识别结果缺少 ${missingBarcodes.length} 个成品条码的部件判断:${missingBarcodes.slice(0, 10).join('、')}`,
)
}
return flagsByBarcode
}
private async queryBomComponentsForParents(
@ -870,20 +820,18 @@ export class ModularProcessingUpstreamRepositoryClass
const config = requiredConfig()
const pool = await this.poolFactory().connect()
const result = await withBomReadRetry(
'BOM 部件需求汇总读取',
() => pool.request()
.input(
'demandsJson',
sql.NVarChar(sql.MAX),
JSON.stringify(
Array.from(demandByParent, ([parentBarcode, processingQuantity]) => ({
parentBarcode,
processingQuantity,
})),
),
)
.query<{ componentBarcode: string; requiredStockTotal: number }>(`
const result = await pool.request()
.input(
'demandsJson',
sql.NVarChar(sql.MAX),
JSON.stringify(
Array.from(demandByParent, ([parentBarcode, processingQuantity]) => ({
parentBarcode,
processingQuantity,
})),
),
)
.query<{ componentBarcode: string; requiredStockTotal: number }>(`
WITH demands AS (
SELECT
UPPER(LTRIM(RTRIM(parentBarcode))) AS parentBarcode,
@ -903,8 +851,7 @@ export class ModularProcessingUpstreamRepositoryClass
FROM ${quoteIdentifier(config.schema)}.${quoteIdentifier(BOM_ITEM_TABLE)} b
INNER JOIN demands d
ON d.parentBarcode = UPPER(LTRIM(RTRIM(CONVERT(nvarchar(255), b.${quoteIdentifier(parent)}))))
GROUP BY UPPER(LTRIM(RTRIM(CONVERT(nvarchar(255), b.${quoteIdentifier(component)}))))`),
)
GROUP BY UPPER(LTRIM(RTRIM(CONVERT(nvarchar(255), b.${quoteIdentifier(component)}))))`)
return new Map<string, number>(
result.recordset

View File

@ -1,7 +1,4 @@
import sql from 'mssql'
import {
CLEARANCE_SHIPMENT_PROFIT_CENTERS,
} from '@/lib/clearance-profit-centers'
export type DiscontinuationInventoryProfile = {
barcode: string
@ -72,10 +69,6 @@ type ClearancePoolTaskMetadataSourceRow = {
priority: string
}
const CLEARANCE_SHIPMENT_PROFIT_CENTER_SQL = CLEARANCE_SHIPMENT_PROFIT_CENTERS
.map((profitCenter) => `N'${profitCenter.replace(/'/g, "''")}'`)
.join(', ')
export type DiscontinuationLiveProductStatus = {
barcode: string
productStatus: string
@ -736,8 +729,7 @@ class SupplyChainDiscontinuationSourceRepository {
}
/**
* 按任务利润中心读取单日全渠道实际发货,一天一条汇总。
* 每条任务汇总 A、C、G、分销部四个利润中心的店铺销量。
* 按任务利润中心读取单日实际发货,一天一条汇总。
*/
async listClearanceDailyShipmentsByProfitCenter(input: {
subjects: Array<{ barcode: string; profitCenter: string }>
@ -753,15 +745,13 @@ class SupplyChainDiscontinuationSourceRepository {
const totals = new Map<string, ClearanceDailyShipmentProfile>()
const pool = await this.getPool()
for (let offset = 0; offset < subjects.length; offset += 1_500) {
const result = await pool.request()
.input(
'subjects',
sql.NVarChar(sql.MAX),
JSON.stringify(subjects.slice(offset, offset + 1_500)),
)
for (let offset = 0; offset < subjects.length; offset += 400) {
const subjectsJson = JSON.stringify(subjects.slice(offset, offset + 400))
const regularRequest = pool.request()
.input('subjects', sql.NVarChar(sql.MAX), subjectsJson)
.input('startDate', sql.Date, input.startDate)
.input('endDate', sql.Date, input.endDateExclusive)
.input('startText', sql.NVarChar(10), input.startDate)
.query<ClearanceDailyShipmentProfile>(`
WITH input_subjects AS (
SELECT
@ -772,26 +762,72 @@ class SupplyChainDiscontinuationSourceRepository {
barcode nvarchar(200) '$.barcode',
profitCenter nvarchar(200) '$.profitCenter'
)
),
shops AS (
SELECT DISTINCT code, LTRIM(RTRIM(Department)) AS profitCenter
FROM rpt.op_ShopConfig_view WITH (NOLOCK)
)
SELECT
subjects_input.barcode,
subjects_input.profitCenter,
CONVERT(varchar(10), sales.stat_date, 23) AS statDate,
SUM(CAST(ISNULL(sales.goods_count, 0) AS bigint)) AS qty
@startText AS statDate,
SUM(CAST(ISNULL(item.qty, 0) AS bigint)) AS qty
FROM input_subjects subjects_input
INNER JOIN rpt.dws_sn_sales_full_info_di sales WITH (NOLOCK)
ON UPPER(LTRIM(RTRIM(CAST(sales.skuoid AS nvarchar(200))))) = subjects_input.barcode
WHERE sales.stat_date >= @startDate
AND sales.stat_date < @endDate
AND LTRIM(RTRIM(CAST(sales.profit_center AS nvarchar(200)))) IN (${CLEARANCE_SHIPMENT_PROFIT_CENTER_SQL})
INNER JOIN shops ON shops.profitCenter = subjects_input.profitCenter
INNER JOIN rpt.jst_salesOutboundInfo outbound WITH (NOLOCK)
ON outbound.shop_id = shops.code
INNER JOIN rpt.jst_OrderItem item WITH (NOLOCK)
ON item.o_id = outbound.o_id
AND UPPER(LTRIM(RTRIM(CAST(item.sku_id AS nvarchar(200))))) = subjects_input.barcode
WHERE outbound.io_date >= @startDate
AND outbound.io_date < @endDate
AND outbound.status = N'Confirmed'
GROUP BY
subjects_input.barcode,
subjects_input.profitCenter,
sales.stat_date
HAVING SUM(CAST(ISNULL(sales.goods_count, 0) AS bigint)) <> 0
subjects_input.profitCenter
OPTION (RECOMPILE)
`)
for (const row of result.recordset) {
const qmcustRequest = pool.request()
.input('subjects', sql.NVarChar(sql.MAX), subjectsJson)
.input('startText', sql.NVarChar(10), input.startDate)
.input('endText', sql.NVarChar(10), input.endDateExclusive)
.query<ClearanceDailyShipmentProfile>(`
WITH input_subjects AS (
SELECT
UPPER(LTRIM(RTRIM(barcode))) AS barcode,
LTRIM(RTRIM(profitCenter)) AS profitCenter
FROM OPENJSON(@subjects)
WITH (
barcode nvarchar(200) '$.barcode',
profitCenter nvarchar(200) '$.profitCenter'
)
),
shops AS (
SELECT DISTINCT code, LTRIM(RTRIM(Department)) AS profitCenter
FROM rpt.op_ShopConfig_view WITH (NOLOCK)
)
SELECT
subjects_input.barcode,
subjects_input.profitCenter,
@startText AS statDate,
SUM(CAST(ISNULL(item.qty, 0) AS bigint)) AS qty
FROM input_subjects subjects_input
INNER JOIN shops ON shops.profitCenter = subjects_input.profitCenter
INNER JOIN rpt.jst_qmcust_salesoutbound outbound WITH (NOLOCK)
ON outbound.shop_id = shops.code
INNER JOIN rpt.jst_qmcust_orderlist_item item WITH (NOLOCK)
ON item.o_id = outbound.o_id
AND UPPER(LTRIM(RTRIM(CAST(item.sku_id AS nvarchar(200))))) = subjects_input.barcode
WHERE outbound.io_date >= @startText
AND outbound.io_date < @endText
AND outbound.status = N'Confirmed'
GROUP BY
subjects_input.barcode,
subjects_input.profitCenter
OPTION (RECOMPILE)
`)
const [regular, qmcust] = await Promise.all([regularRequest, qmcustRequest])
for (const row of [...regular.recordset, ...qmcust.recordset]) {
const barcode = row.barcode.trim().toUpperCase()
const profitCenter = row.profitCenter.trim()
const statDate = String(row.statDate ?? '').trim()
@ -811,7 +847,7 @@ class SupplyChainDiscontinuationSourceRepository {
/**
* 清货发货数量按任务利润中心归集。
* 每条任务的发货量统一汇总 A、C、G、分销部四个利润中心。
* 销售全量表已统一承载各渠道实际发货,并提供利润中心和店铺维度。
*/
async listClearanceOutboundSalesByProfitCenter(input: {
periodKey: string
@ -856,9 +892,9 @@ class SupplyChainDiscontinuationSourceRepository {
FROM input_subjects subjects_input
INNER JOIN rpt.dws_sn_sales_full_info_di sales WITH (NOLOCK)
ON UPPER(LTRIM(RTRIM(CAST(sales.skuoid AS nvarchar(200))))) = subjects_input.barcode
AND LTRIM(RTRIM(CAST(sales.profit_center AS nvarchar(200)))) = subjects_input.profitCenter
WHERE sales.stat_date >= @startDate
AND sales.stat_date < @endDate
AND LTRIM(RTRIM(CAST(sales.profit_center AS nvarchar(200)))) IN (${CLEARANCE_SHIPMENT_PROFIT_CENTER_SQL})
GROUP BY subjects_input.barcode, subjects_input.profitCenter
HAVING SUM(CAST(ISNULL(sales.goods_count, 0) AS bigint)) <> 0
OPTION (RECOMPILE)

View File

@ -1,64 +1,166 @@
import {
type FreightCalculationResult,
import {
FREIGHT_CALCULATION_PROVINCES,
type FreightCourierOption,
type FreightProductArchiveSnapshot,
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: {
findBarcodeFixedRateMap: jest.fn(),
},
freightConfigRepository: {},
}))
const repository = jest.mocked(freightConfigRepository)
function result(barcode: string): FreightCalculationResult {
function archive(barcode: string, productTag = '仓库-测试仓'): FreightProductArchiveSnapshot {
return {
barcode,
productTag: '仓库-测试仓',
warehouseName: '测试仓',
productTag,
inOutWarehouse: '测试仓',
weight: 1,
length: 10,
width: 10,
height: 10,
volume: 1000,
courierCount: 0,
calculatedCourierCount: 0,
regionAverages: [],
nationalAverage: null,
rateSource: 'WAREHOUSE',
couriers: [],
warnings: [],
}
}
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,
})),
}),
}
}
describe('FreightConfigService batch calculation', () => {
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)
})
it('去重后批量计算,缺失档案且无固定价的编码单独报错且不打断其他条目', async () => {
const service = new FreightConfigService(buildRepositoryStub({
archives: [archive('CODE-A'), archive('CODE-B')],
}) as never)
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 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')],
})
expect(calculate).toHaveBeenCalledTimes(3)
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'])
})
})

View File

@ -1,38 +0,0 @@
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

@ -1722,20 +1722,42 @@ describe('supply-chain-clearance workflow', () => {
})).rejects.toThrow('销售状态商品不允许申请折扣')
})
it('allows fixed tasks to apply discounts despite other discount restrictions', async () => {
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) => {
repository.findPoolItem.mockResolvedValue({
...poolItem,
productStatus: '停产',
salesStatus: '停产',
stockJianshang: 0,
stockJianshang: scenario.stockJianshang,
stockFactory: 10,
totalStock: 10,
extra: {
profitCenter: '运营中心A',
fixedTaskQty: 10,
taskChannelClosed: true,
discountChannelClosed: true,
dropshipStock: 10,
taskChannelClosed: scenario.taskChannelClosed,
discountChannelClosed: false,
dropshipStock: scenario.dropshipStock,
clearanceTotal: 100,
},
} as never)
@ -1765,11 +1787,8 @@ describe('supply-chain-clearance workflow', () => {
discount: 0.5,
reason: '固定任务申请清货折扣',
user,
})).resolves.toMatchObject({
taskQty: 10,
discount: 0.5,
status: 'PENDING',
})
})).rejects.toThrow(scenario.error)
expect(repository.createTask).not.toHaveBeenCalled()
})
it('never allows a sales-status fixed task to apply a discount', async () => {

View File

@ -452,34 +452,6 @@ 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,7 +82,6 @@ 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,6 +35,7 @@ type FreightRepository = Pick<
| 'warmup'
| 'listPage'
| 'findProductArchiveSnapshot'
| 'findProductArchiveSnapshots'
| 'findCouriersByWarehouse'
| 'findApplicableRates'
| 'findApplicableVolumeRates'
@ -388,6 +389,10 @@ 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)
@ -397,37 +402,200 @@ export class FreightConfigService {
error instanceof Error ? error.message : '条码运费读取失败',
)
}
const results: Array<FreightCalculationResult | null> = new Array(normalizedBarcodes.length)
.fill(null)
const archives = await this.repository.findProductArchiveSnapshots(normalizedBarcodes)
const archiveByUpperBarcode = new Map(
archives.map((archive) => [archive.barcode.trim().toUpperCase(), archive] as const),
)
const errors: FreightCalculationBatchResult['errors'] = []
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
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)) {
try {
results[index] = await this.calculateBarcodeInternal(
barcode,
fixedRateMap.get(barcode) ?? [],
couriersByWarehouse.set(
warehouseName,
await this.repository.findCouriersByWarehouse({ warehouseName }),
)
} catch (error) {
errors.push({
barcode,
message: error instanceof Error ? error.message : '快递运费计算失败',
})
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,
})
await Promise.all(workers)
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',
})
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]))
return {
results: results.filter(
(result): result is FreightCalculationResult => result !== null,
),
results,
errors: errors.sort((left, right) => (
normalizedBarcodes.indexOf(left.barcode) - normalizedBarcodes.indexOf(right.barcode)
(orderIndex.get(left.barcode) ?? 0) - (orderIndex.get(right.barcode) ?? 0)
)),
}
}

View File

@ -312,44 +312,6 @@ describe('modular product processing order source readiness', () => {
})
})
it('aborts snapshot publication and alerts administrators when BOM remains unavailable', async () => {
const snapshots = snapshotRepository()
const source = upstream()
source.listBomDemandTotals.mockRejectedValue(
new Error('BOM 售卖判断读取 读取失败,已自动重试 3 次:operation timed out'),
)
const beginAttempt = jest.fn().mockResolvedValue(undefined)
const markSucceeded = jest.fn()
const markFailedAndNotify = jest.fn().mockResolvedValue(undefined)
const service = new ModularProductProcessingOrdersService(
repository(archiveRecord.updatedAt),
source,
rules(),
manualValues(),
async () => null,
snapshots,
async () => null,
workOrderRepository(),
{
beginAttempt,
markSucceeded,
markFailedAndNotify,
getHealth: jest.fn(),
},
)
await expect(service.generateDailySnapshot('2026-11-07')).rejects.toThrow(
'已自动重试 3 次',
)
expect(snapshots.replaceDailySnapshot).not.toHaveBeenCalled()
expect(markSucceeded).not.toHaveBeenCalled()
expect(markFailedAndNotify).toHaveBeenCalledWith(expect.objectContaining({
snapshotDate: '2026-11-07',
failureReason: expect.stringContaining('BOM 售卖判断读取'),
}))
})
it('ensures the daily snapshot only when the date has no record yet', async () => {
const snapshots = snapshotRepository()
const service = new ModularProductProcessingOrdersService(

View File

@ -1386,11 +1386,9 @@ export class ModularProductProcessingOrdersService {
private async loadComponentDemandSnapshot(
baseData: RealtimeBaseData,
componentStockByBarcode: Map<string, number> | null,
options: { throwOnBomError?: boolean } = {},
): Promise<ComponentDemandSnapshot> {
const cached = this.componentDemandSnapshotCache
if (
!options.throwOnBomError &&
cached &&
cached.version === baseData.version &&
cached.generationKey === baseData.generationKey
@ -1398,16 +1396,13 @@ export class ModularProductProcessingOrdersService {
return cached
}
let rows = await this.applySellability(
let rows: ModularProcessingOrderRow[] = await this.applySellability(
baseData.calculationRows,
baseData.calculationRows,
componentStockByBarcode,
baseData.version,
baseData.manualValues,
{
refreshSelectiveTransferPlan: true,
throwOnBomError: options.throwOnBomError,
},
{ refreshSelectiveTransferPlan: true },
)
let capacityAdjustments = new Map<string, number>()
let unresolvedComponentShortages: ComponentDemandSnapshot['unresolvedComponentShortages'] = []
@ -1577,7 +1572,6 @@ export class ModularProductProcessingOrdersService {
manualValuesByBarcode: ReadonlyMap<string, ModularProcessingManualValueRecord>,
originalByBarcode: ReadonlyMap<string, ModularProcessingSellability>,
forceRefresh = false,
throwOnBomError = false,
) {
if (
!forceRefresh &&
@ -1754,7 +1748,6 @@ export class ModularProductProcessingOrdersService {
}
return plan
} catch (cause) {
if (throwOnBomError) throw cause
console.error('[modular-processing-selective-transfer] calculation failed', cause)
return emptyPlan
}
@ -1766,10 +1759,7 @@ export class ModularProductProcessingOrdersService {
componentStockByBarcode: Map<string, number> | null = null,
cacheVersion?: number,
manualValuesByBarcode: ReadonlyMap<string, ModularProcessingManualValueRecord> = new Map(),
options: {
refreshSelectiveTransferPlan?: boolean
throwOnBomError?: boolean
} = {},
options: { refreshSelectiveTransferPlan?: boolean } = {},
) {
const calculationByBarcode = new Map(
calculationRows.flatMap((row) => {
@ -1867,7 +1857,6 @@ export class ModularProductProcessingOrdersService {
}))
}
} catch (cause) {
if (options.throwOnBomError) throw cause
this.upstreamMessage(cause, 'bom')
originalByBarcode = new Map(requestedBarcodes.map((barcode) => {
const row = calculationByBarcode.get(barcode)
@ -1891,7 +1880,6 @@ export class ModularProductProcessingOrdersService {
manualValuesByBarcode,
originalByBarcode,
options.refreshSelectiveTransferPlan,
options.throwOnBomError,
)
return pageRows.map((row) => {
const barcode = normalizeOrderCode(row.barcode)
@ -2135,10 +2123,7 @@ export class ModularProductProcessingOrdersService {
input: ModularProcessingOrderFilters,
mode: ModularProcessingExportMode,
selectedBarcodes: string[] = [],
options: {
generationKey?: string
throwOnBomError?: boolean
} = {},
options: { generationKey?: string } = {},
): Promise<ModularProcessingOrderExportData> {
const generationKey = options.generationKey ??
(await this.currentGeneration()).generationKey
@ -2160,7 +2145,6 @@ export class ModularProductProcessingOrdersService {
componentStockByBarcode,
baseData.version,
baseData.manualValues,
{ throwOnBomError: options.throwOnBomError },
)
const rows = this.applyCombinedReplacement(
sortProcessingRows(
@ -2175,11 +2159,7 @@ export class ModularProductProcessingOrdersService {
: undefined,
)
const componentDemandSnapshot = mode === 'withComponents'
? await this.loadComponentDemandSnapshot(
baseData,
componentStockByBarcode,
{ throwOnBomError: options.throwOnBomError },
)
? await this.loadComponentDemandSnapshot(baseData, componentStockByBarcode)
: null
const capacityAdjustedRows = componentDemandSnapshot
? rows.map((row) =>
@ -2521,7 +2501,6 @@ export class ModularProductProcessingOrdersService {
const demandSnapshot = await this.loadComponentDemandSnapshot(
baseData,
componentStockByBarcode,
{ throwOnBomError: true },
)
const updates = Array.from(demandSnapshot.capacityAdjustments, ([barcode, quantity]) => ({
barcode,
@ -2562,10 +2541,7 @@ export class ModularProductProcessingOrdersService {
const generationKey = generatedAt.toISOString()
this.clearRealtimeCaches()
const capacity = await this.enforceComponentCapacityForGeneration(generationKey)
const data = await this.listExportData({}, 'main', [], {
generationKey,
throwOnBomError: true,
})
const data = await this.listExportData({}, 'main', [], { generationKey })
const rows = data.rows.flatMap((row) => {
const recordKey = String(row.id ?? row.barcode ?? '').trim()
if (!recordKey) return []

View File

@ -70,13 +70,12 @@ 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) {
return { suggestion: '', note: '截图识别失败:请人工核对凭证(需包含买家实付 / 数量)。' }
if (!extract || (extract.buyerPaid === null && extract.merchantReceipt === null)) {
return { suggestion: '', note: '截图识别失败:请人工核对凭证(需包含实付款 / 平台优惠 / 数量)。' }
}
const buyerPaid = extract.buyerPaid
const receipt = extract.merchantReceipt !== null
? extract.merchantReceipt
: buyerPaid + (extract.platformDiscount ?? 0)
: (extract.buyerPaid ?? 0) + (extract.platformDiscount ?? 0)
const parts = [
`截图:实付款 ${extract.buyerPaid === null ? '—' : extract.buyerPaid.toFixed(2)}`,
extract.platformDiscount === null ? null : `+ 平台优惠 ${extract.platformDiscount.toFixed(2)}`,
@ -90,12 +89,12 @@ export function buildBreachEvidenceVerdict(input: {
let verdict = ''
if (limitTotal === null) {
verdict = '缺少限价,无法自动判定。'
} else if (buyerPaid + 0.005 >= limitTotal) {
} else if (receipt + 0.005 >= limitTotal) {
suggestion = 'PASS'
verdict = `买家实付 ${buyerPaid.toFixed(2)} ≥ 限价×数量 ${limitTotal.toFixed(2)} → 未破价,可通过。`
verdict = `商家实收 ${receipt.toFixed(2)} ≥ 限价×数量 ${limitTotal.toFixed(2)} → 未破价,可通过。`
} else {
suggestion = 'REJECT'
verdict = `买家实付 ${buyerPaid.toFixed(2)} < 限价×数量 ${limitTotal.toFixed(2)} → 破价 ${(limitTotal - buyerPaid).toFixed(2)},建议驳回。`
verdict = `商家实收 ${receipt.toFixed(2)} < 限价×数量 ${limitTotal.toFixed(2)} → 破价 ${(limitTotal - receipt).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', 'buyerPaidAmount', 'sellerIncomeAmount', 'limitTotal', 'gapAmount', 'status',
'soId', 'oId', 'listedAt', 'flowEndDate', 'sellerIncomeAmount', 'limitTotal', 'gapAmount', 'status',
])
type Viewer = { id: string; name: string; canReview: boolean }
@ -642,45 +642,39 @@ 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, opsHead: true } }),
prisma.supplyChainProductArchive.findMany({ select: { barcode: true, sku: true } }),
prisma.supplyChainStyleLimitPrice.findMany(),
prisma.supplyChainLimitFlowAuth.findMany(),
])
const barcodeToStyle = new Map<string, string>()
const orderOwnerByBarcode = new Map<string, string>()
for (const row of archives) {
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())
if (row.barcode && row.sku) barcodeToStyle.set(row.barcode, row.sku)
}
const ownerLimitByStyle = new Map<string, number>()
for (const row of styleLimits) {
const styleCode = row.styleCode.trim().toUpperCase()
if (styleCode && row.ownerLimitPrice !== null) ownerLimitByStyle.set(styleCode, Number(row.ownerLimitPrice))
if (row.ownerLimitPrice !== null) ownerLimitByStyle.set(row.styleCode, Number(row.ownerLimitPrice))
}
const authsByStyle = new Map<string, BreachGenerationAuth[]>()
for (const auth of auths) {
if (!auth.styleCode) continue
const styleCode = auth.styleCode.trim().toUpperCase()
const list = authsByStyle.get(styleCode) ?? []
const list = authsByStyle.get(auth.styleCode) ?? []
list.push({
applyType: auth.applyType,
styleCode,
styleCode: auth.styleCode,
approvedAt: auth.approvedAt,
startDate: auth.startDate,
endDate: auth.endDate,
adjustedLimitPrice: auth.adjustedLimitPrice === null ? null : Number(auth.adjustedLimitPrice),
})
authsByStyle.set(styleCode, list)
authsByStyle.set(auth.styleCode, list)
}
return { barcodeToStyle, ownerLimitByStyle, orderOwnerByBarcode, authsByStyle }
return { barcodeToStyle, ownerLimitByStyle, authsByStyle }
}
/** 从聚水潭订单明细买家实付 + 适用限价生成/刷新破价单。 */
/** 从聚水潭订单实收 + 限价表生成/刷新破价单。 */
async generate(input: { dateFrom: string; dateTo: string; viewer: Viewer }) {
if (!input.viewer.canReview) throw new Error('没有生成权限')
const limitPrices = await prisma.supplyChainLinkLimitPrice.findMany()
@ -693,20 +687,12 @@ 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(
sourceRowsWithOwners,
sourceRows,
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

@ -280,10 +280,6 @@ function poolExtra(row: { extra?: unknown }) {
: {}
}
function isFixedClearanceTaskItem(row: { extra?: unknown }) {
return integer(poolExtra(row).fixedTaskQty) > 0
}
const NONNEGATIVE_POOL_DISPLAY_EXTRA_KEYS = [
'bigWarehouseStock',
'frontWarehouseStock',
@ -407,7 +403,7 @@ function poolClearanceTotal(row: { totalStock: number; extra?: unknown }) {
}
function assertPoolTaskAllowed(row: { extra?: unknown }) {
if (Boolean(poolExtra(row).taskChannelClosed) && !isFixedClearanceTaskItem(row)) {
if (Boolean(poolExtra(row).taskChannelClosed)) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'该商品为正常销售状态,当前无需定任务',
@ -684,10 +680,8 @@ 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
@ -2539,7 +2533,6 @@ 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,
@ -2555,7 +2548,6 @@ class SupplyChainClearanceService {
poolItem,
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
discount,
fixedTask,
)
const clearanceTotal = poolClearanceTotal(poolItem)
if (task.taskQty > clearanceTotal) {
@ -2659,7 +2651,6 @@ class SupplyChainClearanceService {
},
discountRestrictions,
number(task.discount) ?? 0,
poolItem ? isFixedClearanceTaskItem(poolItem) : false,
)
await supplyChainClearanceRepository.transitionTask({
id: task.id,
@ -2726,18 +2717,13 @@ 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,
@ -2830,18 +2816,13 @@ 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,
@ -3048,7 +3029,6 @@ 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)
@ -3065,7 +3045,6 @@ class SupplyChainClearanceService {
poolItem,
discountRestrictions,
input.discount,
fixedTask,
)
if (input.taskQty > clearanceTotal) {
throw new AppError(
@ -3220,7 +3199,6 @@ class SupplyChainClearanceService {
if (!resolved) continue
const { poolItem } = resolved
const discount = number(task.discount) ?? 0
const fixedTask = isFixedClearanceTaskItem(poolItem)
assertSalesStatusDiscountAllowed(
poolItem.salesStatus || poolItem.productStatus,
discount,
@ -3239,7 +3217,6 @@ class SupplyChainClearanceService {
poolItem,
discountRestrictions,
discount,
fixedTask,
)
const clearanceTotal = poolClearanceTotal(poolItem)
if (task.taskQty > clearanceTotal) {
@ -3324,13 +3301,11 @@ 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) {
@ -3378,12 +3353,13 @@ class SupplyChainClearanceService {
supplyChainClearanceRepository.listPoolItemsForTaskPeriod(task.periodKey, [task.barcode]),
])
const poolItem = poolItems[0]
const fixedTask = poolItem ? isFixedClearanceTaskItem(poolItem) : false
assertDiscountRestrictionAllowed(
poolItem ?? { barcode: task.barcode },
{
barcode: task.barcode,
spu: poolItem?.spu,
},
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
number(task.discount) ?? 0,
fixedTask,
)
const updated = await supplyChainClearanceRepository.transitionTask({
id: task.id,
@ -3416,13 +3392,12 @@ 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)
@ -3434,10 +3409,9 @@ class SupplyChainClearanceService {
}
}
assertDiscountRestrictionAllowed(
poolItem ?? { barcode: task.barcode },
{ barcode: task.barcode, spu: poolItem?.spu },
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
input.discount,
fixedTask,
)
const updated = await supplyChainClearanceRepository.transitionTask({
id: task.id,
@ -3476,13 +3450,12 @@ 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)
@ -3494,10 +3467,9 @@ class SupplyChainClearanceService {
}
}
assertDiscountRestrictionAllowed(
poolItem ?? { barcode: task.barcode },
{ barcode: task.barcode, spu: poolItem?.spu },
normalizeClearanceDiscountRestrictions(clearanceConfig?.discountRestrictions),
input.discount,
fixedTask,
)
const updated = await supplyChainClearanceRepository.transitionTaskWithLatestEvent({
id: task.id,

View File

@ -476,8 +476,7 @@ class SupplyChainDiscontinuationPenaltyService {
}
decision: 'PASS' | 'REJECT' | 'RETURN'
note: string
scope: 'ORDER' | 'LINK' | 'SELECTED'
ids?: readonly string[]
scope: 'ORDER' | 'LINK'
}) {
if (!input.viewer.canReview) throw new Error('只有具备操作权限的经管可以审核申诉')
const row = await prisma.supplyChainDiscontinuationPenalty.findUnique({
@ -506,12 +505,6 @@ 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,
@ -521,12 +514,6 @@ 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),
@ -551,8 +538,7 @@ class SupplyChainDiscontinuationPenaltyService {
canReview: boolean
dataScope: DiscontinuationDataScope
}
scope: 'ORDER' | 'LINK' | 'SELECTED'
ids?: readonly string[]
scope: 'ORDER' | 'LINK'
}) {
if (!input.viewer.canReview) throw new Error('只有具备操作权限的经管可以取消驳回')
const row = await prisma.supplyChainDiscontinuationPenalty.findUnique({
@ -573,12 +559,6 @@ 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,
@ -588,12 +568,6 @@ 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 path from 'node:path'
import type { Readable } from 'node:stream'
import path from 'node:path'
import type { AuthUser } from '@/server/core/types'
export type DiscontinuationRegenerateStage =

View File

@ -37,10 +37,7 @@ import {
resolveProductArchiveWarehouseFields,
} from './supply-chain-product-archive.service'
import { prisma } from '@/lib/prisma'
import {
resolveLimitPriceFields,
resolveOwnerAdjustedLimitPriceAfterFreight,
} from '@/lib/supply-chain-limit-price-fields'
import { resolveLimitPriceFields } from '@/lib/supply-chain-limit-price-fields'
import {
mapProductArchiveRow,
normalizeWarehouseName,
@ -237,7 +234,6 @@ 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 ?? ''),
@ -279,20 +275,10 @@ 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) => {
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,
}),
}
})
const completeRows = rows.map((row) => ({
...row,
...freightByBarcode.get(row.barcode),
}))
processedRows += completeRows.length
params.onProgress?.(processedRows, rowSource.total)
yield completeRows
@ -400,21 +386,9 @@ 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,
...freight,
...(isLimitSet
? {
ownerAdjustedLimitPrice: resolveOwnerAdjustedLimitPriceAfterFreight({
barcode: row.barcode,
productStatus: row.productStatus,
ownerLimitPrice: row.opsPriceLimit,
costPrice: row.costPrice,
freightNationwide: freight?.freightNationwide,
}),
}
: {}),
...freightByBarcode.get(row.barcode),
}
const values = Object.fromEntries(
columns.map((column) => [column.prop, readValue(exportRow, column.prop)]),

View File

@ -164,7 +164,6 @@ 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,7 +19,6 @@ 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 解释,
@ -386,11 +385,7 @@ async function fetchMonthlyAggregateRows(
async function listMonthlyAggregates(start: Date, end: Date, replacementIndex: ReplacementIndex) {
const rows = await fetchMonthlyAggregateRows(start, end, replacementIndex)
if (rows.length === 0) {
const period = `${start.getUTCFullYear()}年${start.getUTCMonth() + 1}月`
throw new AppError(
ErrorCode.VALIDATION_FAILED,
`${period}还没有已同步的销售数据(上游聚水潭销售事实未出数),请等数据同步后再执行核算`,
)
throw new Error('产品全量档案在所选月份没有已同步的销售数据,请先完成上游销售事实同步,再执行 SABC 核算')
}
return rows
}

View File

@ -61,15 +61,6 @@ 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('换入行商家实收抓不到时回退订单金额,但买家实付仍保留明细值', () => {
it('换入行实收抓不到时回退用订单金额,而不是显示 0', () => {
const lines: BreachGenerationLine[] = [
{
...baseLine,
@ -88,9 +88,8 @@ 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(-113.52)
expect(candidates[0].gapAmount).toBe(-17.52)
})
it('换货行配对成功后不再按换入编码重复生成', () => {
@ -248,18 +247,18 @@ describe('buildBreachOrderCandidates 手工换货识别', () => {
expect(candidates.map((item) => item.itemCode)).toEqual(['B-1'])
})
it('普通订单按买家实付判定,不因商家实收较高而跳过破价', () => {
const normal = (itemCode: string, buyerPaid: number, sellerIncome: number): BreachGenerationLine => ({
it('普通订单判定行为不变(实收低于限价才生成)', () => {
const normal = (itemCode: string, income: number): BreachGenerationLine => ({
...baseLine,
oId: `${itemCode}-order`,
soId: `${itemCode}-so`,
itemCode,
qty: 1,
buyerPaidAmount: buyerPaid,
sellerIncomeAmount: sellerIncome,
buyerPaidAmount: income,
sellerIncomeAmount: income,
})
const { candidates } = buildBreachOrderCandidates(
[normal('N-1', 90, 200), normal('N-2', 120, 50)],
[normal('N-1', 90), normal('N-2', 120)],
[limit('N-1'), limit('N-2', 100)],
)
expect(candidates.map((item) => item.itemCode)).toEqual(['N-1'])
@ -267,66 +266,6 @@ 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,
@ -347,21 +286,20 @@ 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({ buyerPaidAmount: 88 })],
[authLine({ sellerIncomeAmount: 88 })],
[{ ...baseLimit[0], limitUnitPrice: 120 }], // 常规基准 120 > 88 本会生成
authContext([{
applyType: '申请与SPU负责人同价',
@ -371,12 +309,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({ buyerPaidAmount: 100 })],
[authLine({ sellerIncomeAmount: 100 })],
[{ ...baseLimit[0], limitUnitPrice: 120 }],
authContext([{
applyType: '申请与SPU负责人同价',
@ -392,7 +330,7 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
it('同价超期后仍按负责人价格 → 按非负责人限价(+10)判破价', () => {
const { candidates } = buildBreachOrderCandidates(
[authLine({ buyerPaidAmount: 100, payDate: new Date('2026-11-05T10:00:00Z') })],
[authLine({ sellerIncomeAmount: 100, payDate: new Date('2026-11-05T10:00:00Z') })],
[{ ...baseLimit[0], limitUnitPrice: 90 }],
authContext([{
applyType: '申请与SPU负责人同价',
@ -407,9 +345,9 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
expect(candidates[0].gapAmount).toBe(-10)
})
it('活动期内买家实付 ≥ 调整后限价 → 豁免', () => {
it('活动期内实收 ≥ 调整后限价 → 豁免', () => {
const { candidates, stats } = buildBreachOrderCandidates(
[authLine({ buyerPaidAmount: 95 })],
[authLine({ sellerIncomeAmount: 95 })],
[{ ...baseLimit[0], limitUnitPrice: 120 }],
authContext([{
applyType: '活动限价',
@ -423,9 +361,9 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
expect(stats.authExempted).toBe(1)
})
it('活动期内买家实付 < 调整后限价 → 按调整后限价判破价', () => {
it('活动期内实收 < 调整后限价 → 按调整后限价判破价', () => {
const { candidates } = buildBreachOrderCandidates(
[authLine({ buyerPaidAmount: 80 })],
[authLine({ sellerIncomeAmount: 80 })],
[{ ...baseLimit[0], limitUnitPrice: 70 }],
authContext([{
applyType: '活动限价',
@ -442,7 +380,7 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
it('活动期外 → 回到常规基准', () => {
const { candidates } = buildBreachOrderCandidates(
[authLine({ buyerPaidAmount: 100, payDate: new Date('2026-12-05T10:00:00Z') })],
[authLine({ sellerIncomeAmount: 100, payDate: new Date('2026-12-05T10:00:00Z') })],
[{ ...baseLimit[0], limitUnitPrice: 90 }],
authContext([{
applyType: '活动限价',
@ -452,12 +390,12 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
adjustedLimitPrice: 80,
}]),
)
expect(candidates).toHaveLength(0) // 常规基准 90,买家实付 100 不判
expect(candidates).toHaveLength(0) // 常规基准 90,实收 100 不判
})
it('限价以流程为准:同货号有流程限价时优先于店铺台账限价', () => {
const { candidates } = buildBreachOrderCandidates(
[authLine({ buyerPaidAmount: 95 })],
[authLine({ sellerIncomeAmount: 95 })],
[{ ...baseLimit[0], limitUnitPrice: 80 }], // 台账 80;流程 100
authContext([]),
)
@ -470,7 +408,7 @@ describe('破价判定流程豁免(同价申请 / 活动限价)', () => {
const context = authContext([])
context.ownerLimitByStyle = new Map() // 流程无该货号
const { candidates } = buildBreachOrderCandidates(
[authLine({ buyerPaidAmount: 85 })],
[authLine({ sellerIncomeAmount: 85 })],
[{ ...baseLimit[0], limitUnitPrice: 90 }],
context,
)