Compare commits

...
Author SHA1 Message Date
ouyangxq cec1e2d77c fix(fleet): validate pallet destination identity before capacity confirmation 2026-10-09 11:48:53 +08:00
admin dd47143d6e Merge pull request '发布已通过 TEST 回归的 2026-10-08 批次至灰度' (#16) from release/test into main 2026-10-08 22:17:40 +08:00
liuyihe c90a191d83 优化 2026-10-08 20:25:25 +08:00
liuyihe 115461683b 优化 2026-10-08 20:16:19 +08:00
liuyihe 9c70eab606 优化 2026-10-08 18:13:43 +08:00
ouyangxq 5946bdfcf7 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-08 17:08:04 +08:00
ouyangxq 7b0df1db37 优化 2026-10-08 17:08:03 +08:00
liuyihe 73af3affac restore overseas warehouse navigation entries 2026-10-08 15:52:38 +08:00
liuyihe 3c3a7fcd55 merge admin frontend changes 2026-10-08 15:45:26 +08:00
liuyihe 8fa397bb5e 优化 2026-10-08 15:34:19 +08:00
ouyangxq cc37a42298 fix(overseas): restore first-leg before and after sailing tabs 2026-10-08 15:12:49 +08:00
ouyangxq 6551b703cb fix(fleet): align capacity availability and dispatch review navigation 2026-10-08 14:11:54 +08:00
ouyangxq f24b592705 fix(admin): restore loading list detail page navigation 2026-10-08 13:59:20 +08:00
admin 10da46beab Merge pull request '恢复装柜清单及货柜海外操作入口' (#15) from release/test into main 2026-10-08 11:26:58 +08:00
ouyangxq d7911ad09d fix(admin): restore manifest menu and overseas container access 2026-10-08 11:16:58 +08:00
admin cc5ac2e68a Merge pull request 'Release internal fleet capacity pool to gray' (#14) from release/test into main 2026-10-08 08:13:24 +08:00
ouyangxq 080042d941 test(fleet): verify offer-scoped quote selection and pricing 2026-10-07 23:43:14 +08:00
ouyangxq 9ff2720721 fix(admin): keep fleet arrangement references fully readable 2026-10-07 22:45:49 +08:00
ouyangxq e4e78796a1 fix(orders): display unknown pooled freight facts without defaults 2026-10-07 19:44:42 +08:00
ouyangxq ba6d0ff13f fix(fleet): guard workspace navigation with unsaved pool drafts 2026-10-07 18:53:06 +08:00
ouyangxq 2bc095ecac fix(fleet): preserve drafts before dialog close and respect dashboard permissions 2026-10-07 18:21:03 +08:00
ouyangxq 2477667c27 feat(fleet): add internal sales capacity pool workflow 2026-10-07 16:46:57 +08:00
admin e8e7465e4e Merge pull request '统一司机、车次及出车记录列表样式' (#13) from release/test into main 2026-10-07 12:25:14 +08:00
ouyangxq 04e1d7dbfa fix(admin): unify fleet driver trip and operation list layouts 2026-10-07 12:24:21 +08:00
admin b4b802a547 Merge pull request '发布当前测试版本至临时灰度环境' (#12) from release/test into main 2026-10-07 11:11:52 +08:00
ouyangxq ca7f6aa414 fix(deploy): support isolated temporary production release 2026-10-07 11:09:41 +08:00
ouyangxq 899978e53d test(admin): cover late driver detail and history responses 2026-10-05 18:39:25 +08:00
ouyangxq cb5f0d1bec feat(fleet): show authoritative trip receivables and receipts on demand 2026-10-05 11:58:16 +08:00
ouyangxq 110c2e0be2 fix(transfer): format optional relabel summary in order detail 2026-10-05 07:28:36 +08:00
ouyangxq 93f193c5d6 feat(transfer): expose stock outbound and durable receipt recovery UI 2026-10-05 06:16:21 +08:00
ouyangxq 578df0f927 fix(overseas): restore pending resource confirmation from original command 2026-10-05 05:40:21 +08:00
ouyangxq 28eb43893a fix(dispatch): submit and preserve independent loading groups 2026-10-05 03:30:06 +08:00
ouyangxq 058b0778a2 fix(overseas): reuse original resource command after reopening conversion 2026-10-05 02:13:24 +08:00
ouyangxq ea24496b11 fix(overseas): normalize transfer inventory quantities at API boundary 2026-10-04 22:26:36 +08:00
ouyangxq 939cc11d7b feat(overseas): select stock transfer sources and follow relabel 2026-10-04 21:19:11 +08:00
ouyangxq d3a2671fd7 fix(auth): revoke expired admin sessions without restoring login 2026-10-04 19:41:45 +08:00
ouyangxq cc973c72c0 fix(overseas): expose permission-gated cargo discard review 2026-10-04 16:57:16 +08:00
ouyangxq 1d73a40a07 fix(overseas): synchronize receipt input accessibility with command lock 2026-10-04 12:43:07 +08:00
ouyangxq a8ff0ef1de feat(overseas): show return receipt and redispatch history 2026-10-04 10:48:38 +08:00
ouyangxq 2c2e19c4ea fix(fleet): mount measured controls after record loads 2026-10-04 10:07:45 +08:00
ouyangxq caf9fd89d4 fix(fleet): show contained operation-record tab panels 2026-10-04 01:48:59 +08:00
ouyangxq f4d58e669b fix(drivers): isolate cached detail requests and reject absent route IDs 2026-10-04 01:02:28 +08:00
ouyangxq 5c4a381d87 fix(drivers): refresh cached list and summary when returning from details 2026-10-03 23:44:35 +08:00
ouyangxq 9ec1fb549e fix(delivery): preserve nullable signed quantity in admin contract 2026-10-03 23:10:44 +08:00
ouyangxq 6df6f6dabc fix(fleet): refresh negotiated price controls when reopening booking 2026-10-03 20:37:03 +08:00
ouyangxq 60dd86ab88 fix(fleet): leave unknown vehicle fuel consumption empty 2026-10-03 19:41:55 +08:00
ouyangxq 049f895b44 fix(fleet): ignore stale vehicle list responses 2026-10-03 19:04:31 +08:00
ouyangxq 102194460c fix(fleet): type exception target selection handlers 2026-10-03 18:55:06 +08:00
ouyangxq 4dc497c8ca feat(fleet): complete exception processing and trip evidence interfaces 2026-10-03 16:40:36 +08:00
ouyangxq 9fa73f50d6 fix(dispatch): preserve return sources when restoring prearrangement drafts 2026-10-03 15:57:53 +08:00
ouyangxq 6a135c3982 fix(fleet): distinguish signed returned and remaining trip cargo 2026-10-03 15:00:10 +08:00
ouyangxq 5641ea0f7e feat(fleet): select versioned sales quotes and isolate cost drafts 2026-10-03 13:20:35 +08:00
ouyangxq 3440f9dd11 fix(fleet): preserve retry intent and restore inquiry editing 2026-10-03 13:00:32 +08:00
ouyangxq 0b5094dd8f feat(fleet): connect operations quotes costs and handover workspaces 2026-10-03 12:43:25 +08:00
ouyangxq 863ad5249c fix: keep schedule form within the dialog body 2026-10-03 11:42:26 +08:00
ouyangxq 8bae21dea2 fix: restore original-container sailing schedule editing 2026-10-03 11:14:11 +08:00
ouyangxq 1a52cc0817 fix(fleet): normalize nullable maintenance fields before editing 2026-10-03 10:56:47 +08:00
ouyangxq fa82710e6b fix(fleet): clarify maintenance record remark hint 2026-10-03 10:54:05 +08:00
ouyangxq 8c462cef17 fix(fleet): label missing delivery customer names 2026-10-03 10:33:34 +08:00
ouyangxq f7b84ea887 feat(fleet): show vehicle delivery and reassignment records 2026-10-03 10:12:55 +08:00
ouyangxq 94b8b8b31c fix(fleet): fit booking action column to trip stage 2026-10-03 06:27:30 +08:00
ouyangxq 22dcae616c fix(fleet): show placeholders for unassigned trip fields 2026-10-03 05:02:39 +08:00
ouyangxq dc35288a1b feat(fleet): integrate trip management and fee coverage workflows 2026-10-03 04:35:55 +08:00
ouyangxq bca08f8749 fix(orders): send version and idempotency key for platform cancellation 2026-10-02 23:16:19 +08:00
ouyangxq 678bccd4d1 fix(admin): distinguish linked bookings from missing channel names 2026-10-02 13:39:15 +08:00
ouyangxq f50850f001 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-30 14:27:41 +08:00
ouyangxq 8c51e9c1cf 优化 2026-09-30 14:01:58 +08:00
liuyihe 34a9861681 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-29 21:01:41 +08:00
liuyihe f8bddeeaf4 merge admin changes into release/test 2026-09-29 21:01:37 +08:00
ouyangxq f14e1a2ea3 接入双PDA独立H5测试入口 2026-09-29 20:54:00 +08:00
liuyihe 7edc07d235 优化 2026-09-29 20:52:21 +08:00
ouyangxq a3005d1c02 优化 2026-09-29 18:47:41 +08:00
ouyangxq 2bf4353b1b 优化 2026-09-29 17:56:00 +08:00
ouyangxq 4ead51a5fe fix: record customs results in Shanghai business time 2026-09-29 15:45:37 +08:00
ouyangxq ae13afd3d7 Merge remote-tracking branch 'origin/release/test' into hotfix 2026-09-29 13:50:05 +08:00
ouyangxq 6bada2aca9 fix(admin): clarify customs results and apply dialog density 2026-09-29 13:50:05 +08:00
ouyangxq 1324cf16ce feat(admin): support manual customs exception reasons 2026-09-29 04:20:58 +01:00
ouyangxq 584b74e5e1 test(admin): active price book sailing channels 2026-09-29 04:18:46 +01:00
ouyangxq 5279fc774a fix(admin): load sailing channels from active price book 2026-09-29 04:17:43 +01:00
ouyangxq 8fe256372c fix(admin): separate sailing search and actions 2026-09-29 03:58:24 +01:00
ouyangxq ea35a12f8b fix(admin): separate sailing search and actions 2026-09-29 03:58:14 +01:00
ouyangxq f89a04ad2f fix(admin): improve calendar toolbar breakpoint 2026-09-29 03:52:50 +01:00
ouyangxq 5ad8c5c462 fix(admin): improve calendar toolbar breakpoint 2026-09-29 03:52:40 +01:00
ouyangxq ab5a64c6f7 fix(admin): compact sailing calendar toolbar 2026-09-29 03:49:09 +01:00
ouyangxq a903020610 fix(admin): compact sailing calendar toolbar 2026-09-29 03:48:49 +01:00
ouyangxq d7489a20be fix(admin): type sailing warehouse change handler 2026-09-28 17:11:47 +01:00
ouyangxq 365a7f7bc4 fix(admin): type sailing warehouse change handler 2026-09-28 17:11:10 +01:00
ouyangxq 0798143bad fix(admin): reuse master data in sailing forms 2026-09-28 17:09:46 +01:00
ouyangxq 8c0daaa5db fix(admin): reuse master data in sailing forms 2026-09-28 17:05:47 +01:00
liuyihe 86601fd45f Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-28 17:26:28 +08:00
liuyihe e1b2f5155a 优化提交 2026-09-28 17:26:26 +08:00
admin db920660bc Merge pull request 'Release/test' (#11) from release/test into main
Reviewed-on: #11
2026-09-28 15:59:28 +08:00
ouyangxq e962577eca fix(admin): align test contracts and sailing integrations 2026-09-28 15:24:33 +08:00
ouyangxq 730defabca feat(customs): show broker submission state in admin 2026-09-28 13:21:59 +08:00
ouyangxq fad49a07a8 fix(admin): preserve sailing metadata and generation choices 2026-09-28 12:30:23 +08:00
ouyangxq ffe91a1936 feat(admin): show customs receipt history 2026-09-28 09:49:53 +08:00
ouyangxq 5cc1512a28 fix(admin): advance sailing copy selection 2026-09-27 23:41:28 +08:00
ouyangxq 3cf676cbbd fix(admin): clarify weekly sailing defaults 2026-09-27 22:58:22 +08:00
ouyangxq 5a90503542 fix(admin): start weekly generation from today 2026-09-27 22:50:58 +08:00
ouyangxq 62c9eb58be feat(admin): improve sailing cutoff and weekly generation 2026-09-27 22:45:41 +08:00
ouyangxq 524a23b488 feat(admin): show adjacent sailing merge reference 2026-09-27 22:27:28 +08:00
ouyangxq 517aad5e5e fix(admin): clarify sailing display data sources 2026-09-27 22:18:55 +08:00
ouyangxq a34feddd4a feat(admin): narrow sailing calendar to read-only display 2026-09-27 22:07:54 +08:00
ouyangxq 855d8c0d5d fix(admin): create declaration batch after broker handoff 2026-09-27 20:48:19 +08:00
ouyangxq 29cb0bd57a feat(admin): add formal customs result actions 2026-09-27 20:41:39 +08:00
ouyangxq 45e1e9b509 fix(admin): send template pagination params 2026-09-27 19:12:17 +08:00
ouyangxq ec6de70e67 feat(admin): add sailing calendar management 2026-09-27 18:55:19 +08:00
ouyangxq 4a0dd3b597 fix(admin): align customs document workflow status 2026-09-26 17:31:29 +08:00
ouyangxq 47bd39d2d5 feat(admin): gate final customs upload on broker submission 2026-09-26 14:22:45 +08:00
ouyangxq d2e063ea3f feat(admin): manage customs receipt and final files 2026-09-26 13:28:48 +08:00
ouyangxq 391369a3d0 优化 2026-09-24 17:02:36 +08:00
ouyangxq 945a3d3230 完善退税报关回执交互 2026-09-24 16:29:31 +08:00
ouyangxq 4e37dc3330 优化 2026-09-24 14:07:48 +08:00
liuyihe 3815eab85e Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-24 08:55:55 +08:00
ouyangxq 2934a795ad 优化 2026-09-24 08:54:28 +08:00
ouyangxq ff0f388daf 优化 2026-09-23 18:26:09 +08:00
ouyangxq c25aaaca53 优化 2026-09-23 17:47:54 +08:00
ouyangxq 9da23280e9 Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-23 17:46:08 +08:00
ouyangxq 6b7a4a097d 优化 2026-09-23 17:46:01 +08:00
liuyihe 60eff4f8ab Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-23 11:25:37 +08:00
liuyihe 409d8b3f57 贴标优化0923 2026-09-23 11:25:23 +08:00
ouyangxq 10e20f896a 优化 2026-09-23 09:59:46 +08:00
ouyangxq db92d1dfe1 优化 2026-09-23 08:59:17 +08:00
ouyangxq 841741dc83 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-22 14:27:18 +08:00
ouyangxq 5035e02357 Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-22 14:26:53 +08:00
ouyangxq 4dd1b83a6b 优化 2026-09-22 14:26:49 +08:00
liuyihe f0ee25ca18 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-22 13:52:10 +08:00
liuyihe fed257a720 优化 2026-09-22 13:51:58 +08:00
ouyangxq 33fc30911e feat: add lingxing connection test action 2026-09-22 11:44:07 +08:00
ouyangxq 4391a69c7d feat: show lingxing sync switch status 2026-09-22 11:33:08 +08:00
liuyihe 3b37249c47 合并 release/test 管理前端冲突 2026-09-22 09:42:02 +08:00
ouyangxq 358ad38335 feat: add overseas warehouse location management 2026-09-22 09:38:56 +08:00
liuyihe 2e2f3ed2d2 修改提交 2026-09-22 08:47:43 +08:00
ouyangxq 3cb8b5b70e fix: keep mojia sku sync statuses to three states 2026-09-21 23:32:12 +08:00
ouyangxq 12dc5dfd82 feat: add mojia sku sync admin page 2026-09-21 23:19:17 +08:00
admin e93d00b79e Merge pull request 'Release/test' (#10) from release/test into main
Reviewed-on: #10
2026-09-21 11:36:04 +08:00
ouyangxq e6771e8e86 优化 2026-09-21 11:17:11 +08:00
ouyangxq 3e8fa3c5d7 fix: allow manual FBA quote when rate is missing 2026-09-21 10:44:14 +08:00
admin 73dd411763 Merge pull request 'fix: keep Admin BaoTa proxy stable' (#9) from release/test into main 2026-09-20 23:41:52 +08:00
ouyangxq b9e5bf3fec fix: preserve stable Admin proxy port 2026-09-20 23:39:39 +08:00
admin b529430ea2 Merge pull request 'feat: production blue-green deployment for Admin' (#8) from release/test into main
Reviewed-on: #8
2026-09-20 22:23:33 +08:00
ouyangxq 3f78e1d087 优化 2026-09-20 22:13:56 +08:00
ouyangxq 7586f9d783 优化 2026-09-20 22:04:59 +08:00
ouyangxq 1cc8ae44c5 优化 2026-09-20 21:52:12 +08:00
ouyangxq 6e0144d7cd feat: add production blue-green frontend deployment 2026-09-20 21:44:56 +08:00
ouyangxq 592f01eb1f 优化 2026-09-20 19:57:51 +08:00
admin f24ed30ae8 Merge pull request 'Release/test' (#7) from release/test into main
Reviewed-on: #7
2026-09-20 19:49:08 +08:00
ouyangxq db9e496b35 优化 2026-09-20 19:45:27 +08:00
ouyangxq 2e9e8819b6 优化 2026-09-20 19:33:53 +08:00
ouyangxq 6bcfb6ba0b 优化 2026-09-20 18:25:17 +08:00
liuyihe be0f712dd6 Merge remote-tracking branch 'origin/release/test' into release/test 2026-09-20 15:51:48 +08:00
liuyihe aefcd2565f fix admin frontend build after branch merge 2026-09-20 15:50:15 +08:00
ouyangxq 22853336b6 优化 2026-09-20 15:47:56 +08:00
liuyihe 940010dcc2 merge release test changes and preserve local frontend updates 2026-09-20 15:23:26 +08:00
ouyangxq 53c887d353 优化 2026-09-20 15:03:28 +08:00
ouyangxq 158ea8c8ea 优化 2026-09-20 14:43:31 +08:00
ouyangxq 810b4e11f5 优化 2026-09-20 14:26:55 +08:00
ouyangxq 60a41af3ac 优化 2026-09-20 14:22:12 +08:00
admin 19a64fbaa5 Merge pull request '优化' (#6) from release/test into main
Reviewed-on: #6
2026-09-20 10:34:26 +08:00
ouyangxq 1305753b71 优化 2026-09-20 10:06:23 +08:00
admin ec4b31a46f Merge pull request '优化' (#5) from release/test into main
Reviewed-on: #5
2026-09-20 09:20:19 +08:00
ouyangxq e94915ced3 优化 2026-09-20 09:17:56 +08:00
admin 3fff8c8ea0 Merge pull request 'Release/test' (#4) from release/test into main
Reviewed-on: #4
2026-09-19 22:57:39 +08:00
ouyangxq ad8e1fd2c2 优化 2026-09-19 22:54:22 +08:00
ouyangxq 3054096995 优化 2026-09-19 17:30:31 +08:00
ouyangxq 3475cdfe49 优化 2026-09-19 17:02:17 +08:00
ouyangxq 7e3cd93425 优化 2026-09-18 18:50:53 +08:00
admin 92ec1e11b1 Merge pull request 'Release/test' (#3) from release/test into main
Reviewed-on: #3
2026-09-18 18:03:16 +08:00
ouyangxq 1dd139c1c6 优化 2026-09-18 18:02:33 +08:00
ouyangxq 24fa3d1c9d 优化 2026-09-18 16:48:40 +08:00
ouyangxq e10d0534ae fix: show dropship outbound measurements 2026-09-18 15:23:43 +08:00
ouyangxq f64dedfb52 fix: resolve dispatch weight type duplication 2026-09-18 15:03:19 +08:00
ouyangxq b51a847306 test: cover overseas row actions 2026-09-18 14:59:14 +08:00
ouyangxq 0ab2302720 fix: display measurements throughout overseas fulfillment 2026-09-18 14:59:14 +08:00
wujinliang d77d145405 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-18 11:41:38 +08:00
wujinliang 451663b6ff 1 2026-09-18 11:41:27 +08:00
ouyangxq efba5d9f14 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-18 09:59:13 +08:00
ouyangxq 84e768f41a 优化 2026-09-18 09:59:12 +08:00
wujinliang f2d5b2d8e4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-18 09:15:51 +08:00
wujinliang 9f1ea75504 1 2026-09-18 09:15:40 +08:00
liuyihe 579a4b7353 Merge release/test into dev 2026-09-17 16:42:02 +08:00
liuyihe b6a839a3c6 优化 2026-09-17 16:34:36 +08:00
ouyangxq dbbaa44f92 优化 2026-09-17 16:31:01 +08:00
liuyihe 9c9cb91593 优化 2026-09-17 13:36:31 +08:00
admin e665877737 Merge pull request 'Release/test 合并上生产' (#2) from release/test into main
Reviewed-on: #2
2026-09-17 11:50:55 +08:00
ouyangxq d54f857929 ci: 增加 Admin 生产部署流程 2026-09-17 11:49:00 +08:00
ouyangxq b78cea3caf 优化 2026-09-16 19:32:58 +08:00
ouyangxq 5b6d369e65 优化 2026-09-16 17:49:23 +08:00
ouyangxq 61c8cf13f7 优化 2026-09-16 16:17:41 +08:00
ouyangxq f0d10389d0 优化 2026-09-16 15:21:01 +08:00
ouyangxq 61562e59f0 优化 2026-09-16 11:43:04 +08:00
ouyangxq 78a0d7916c 优化 2026-09-16 11:06:36 +08:00
ouyangxq d75e8381b6 fix: clarify whole-pallet box and SKU counts 2026-09-16 10:18:55 +08:00
ouyangxq 343a5ea260 补齐海外出库状态展示 2026-09-15 21:05:09 +08:00
ouyangxq 203366c662 优化 2026-09-15 20:52:05 +08:00
ouyangxq 93a13e5eb9 优化 2026-09-15 19:34:55 +08:00
ouyangxq a7db793c95 优化 2026-09-15 18:17:17 +08:00
ouyangxq 55576a7c84 fix(admin): unify inbound fee refresh controls 2026-09-15 18:16:09 +08:00
ouyangxq ccc72be4b9 优化 2026-09-15 17:46:44 +08:00
ouyangxq 14e31d4679 优化 2026-09-15 16:39:46 +08:00
ouyangxq c78ecbef31 优化 2026-09-15 16:11:20 +08:00
ouyangxq 7d5bee80b8 优化 2026-09-15 15:53:09 +08:00
ouyangxq bed46a58d8 优化 2026-09-15 15:53:03 +08:00
liuyihe 5d00618c53 Merge release/test into dev 2026-09-15 15:36:26 +08:00
ouyangxq 9fb43ff2d3 优化 2026-09-15 15:28:57 +08:00
ouyangxq 71ca2253c9 优化 2026-09-15 15:26:20 +08:00
ouyangxq e6d9474df1 优化 2026-09-15 15:25:56 +08:00
ouyangxq 2e57098ad9 优化 2026-09-15 15:25:11 +08:00
ouyangxq 5d2465d1ea 优化 2026-09-15 15:24:59 +08:00
liuyihe ea30e3f37a 优化 2026-09-15 15:20:28 +08:00
ouyangxq 90f97e846f 优化 2026-09-15 15:01:01 +08:00
ouyangxq ac6b9ef9eb 优化 2026-09-15 14:30:50 +08:00
ouyangxq a311ec41c3 优化 2026-09-15 14:18:23 +08:00
ouyangxq fb41713930 优化 2026-09-15 13:46:20 +08:00
ouyangxq bd207b8d88 fix: show completed orders after overseas delivery 2026-09-15 13:12:24 +08:00
ouyangxq 47f2bfdd23 优化 2026-09-15 12:35:46 +08:00
ouyangxq ca90b0bc75 fix: limit dispatch vehicle choices 2026-09-15 12:17:20 +08:00
ouyangxq 785194209b fix: remove manual pallet carton mark input 2026-09-15 12:08:07 +08:00
ouyangxq ff25847b02 优化 2026-09-15 11:54:19 +08:00
ouyangxq a807071ed2 优化 2026-09-15 10:38:03 +08:00
ouyangxq fb316e4d5a fix(admin): unify dropship pallet print action styling 2026-09-15 10:19:05 +08:00
ouyangxq 4bf66d9571 优化 2026-09-15 09:58:15 +08:00
ouyangxq 3f998bdb88 优化 2026-09-14 20:58:37 +08:00
ouyangxq bd6d54ac33 优化 2026-09-14 20:21:02 +08:00
ouyangxq 349e8f60df 优化 2026-09-14 20:03:27 +08:00
ouyangxq 66f49b97e5 优化 2026-09-14 18:34:34 +08:00
ouyangxq 25825685c3 优化 2026-09-14 17:54:21 +08:00
ouyangxq 7e1b9b4cee feat: add desktop dropship pallet printing 2026-09-14 17:52:00 +08:00
ouyangxq 22c755b02a fix: scope smart loading recommendation requests 2026-09-14 17:22:08 +08:00
liuyihe 01cbe69eb4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-14 17:07:27 +08:00
liuyihe 604420f07a Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-14 17:06:56 +08:00
ouyangxq 7e29e6c25c 优化 2026-09-14 17:06:55 +08:00
ouyangxq 9e28e10e5f feat: edit collaboration remarks across order loading 2026-09-14 16:56:11 +08:00
liuyihe c56f60d4ef 优化 2026-09-14 16:51:35 +08:00
ouyangxq 33520e8fd3 fix: lock dropship inbound plan pallet count 2026-09-14 16:38:51 +08:00
ouyangxq e3baf69db5 优化 2026-09-14 15:52:00 +08:00
ouyangxq 877fcc2740 fix(customers): close editor after successful save 2026-09-14 15:38:29 +08:00
ouyangxq b0de9c026f 优化 2026-09-14 15:33:35 +08:00
ouyangxq 23d956ba15 优化 2026-09-14 14:44:01 +08:00
ouyangxq afa49bf1f1 fix: remove sealed smart-loading rows by pallet 2026-09-14 14:35:30 +08:00
ouyangxq 85b3606eca 优化 2026-09-14 13:48:15 +08:00
ouyangxq ba31dd0563 优化 2026-09-14 12:00:54 +08:00
ouyangxq 1da976ecb6 Merge origin/dev into release/test preserving current workflows 2026-09-14 11:05:47 +08:00
ouyangxq bd12b0a594 1 2026-09-14 10:27:47 +08:00
ouyangxq 78f31fb9a3 fix: 修正海外出库前端质量门禁 2026-09-14 10:22:27 +08:00
ouyangxq 89dc3bf671 优化 2026-09-14 10:02:39 +08:00
liuyihe a0e46a05e5 优化 2026-09-13 21:10:05 +08:00
ouyangxq 8bc86c8567 优化 2026-09-12 11:45:57 +08:00
ouyangxq 497530d6a9 优化 2026-09-11 18:44:01 +08:00
ouyangxq b9330a9674 优化 2026-09-11 17:27:30 +08:00
ouyangxq ce399e4153 优化 2026-09-11 17:20:58 +08:00
ouyangxq 2334bd846a 优化 2026-09-11 17:01:59 +08:00
ouyangxq 737dd41ff1 优化 2026-09-11 16:17:21 +08:00
ouyangxq 80a4b3ab5e fix: clarify automatic pallet supplementation 2026-09-11 16:14:14 +08:00
ouyangxq 3ea1e279c6 fix: show cross-plan merged pallet ownership 2026-09-11 16:11:31 +08:00
ouyangxq 6d26634b1b feat: clarify overseas pallet supplementation 2026-09-11 15:39:47 +08:00
ouyangxq b673e22cb7 fix: validate dropship pallet estimate against cartons 2026-09-11 12:26:45 +08:00
ouyangxq e0b1334d2d feat: add expected pallet count for dropship receiving 2026-09-11 12:12:41 +08:00
ouyangxq 385c5d9e9e 优化 2026-09-11 11:51:33 +08:00
ouyangxq 6b7536b95c 优化 2026-09-11 11:19:45 +08:00
ouyangxq 23e991722c 优化 2026-09-11 11:15:04 +08:00
ouyangxq f702a819fa fix: complete overseas receiving detail facts 2026-09-11 10:32:29 +08:00
ouyangxq d92a883f8a fix: open dispatch BOL as PDF 2026-09-11 10:09:14 +08:00
ouyangxq b7b9835bf9 fix: wait for customer dimension initialization 2026-09-11 02:16:51 +08:00
ouyangxq 2817ca1d3f fix: isolate BOL print layout 2026-09-10 23:30:16 +08:00
ouyangxq 950967f101 feat: align finance account and overseas actions 2026-09-10 22:43:10 +08:00
ouyangxq b6b91297ae fix(customers): wait for billing defaults before loading 2026-09-10 22:12:27 +08:00
ouyangxq 781d25de08 feat: add dispatch BOL printing 2026-09-10 21:22:06 +08:00
ouyangxq 14907551d7 优化 2026-09-10 20:38:06 +08:00
ouyangxq 219fd3009f 优化 2026-09-10 20:31:17 +08:00
ouyangxq 5a165add2c Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-10 20:26:57 +08:00
ouyangxq b57ea2d628 feat(admin): use smart-loading groups for simulations 2026-09-10 19:25:11 +08:00
ouyangxq 27c4ec759c 优化 2026-09-10 17:13:08 +08:00
ouyangxq 5f1a7f04f1 fix: clarify automatic pallet generation 2026-09-10 17:05:48 +08:00
ouyangxq 0ed913a114 fix: expose manual plans awaiting pallets 2026-09-10 16:33:43 +08:00
ouyangxq 4382351569 fix: show pallets across warehouses by default 2026-09-10 16:03:01 +08:00
ouyangxq dc1cbfcf4a fix: align inbound pallet plans with preview 2026-09-10 15:40:55 +08:00
liuyihe 19fed894d9 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-10 15:33:55 +08:00
liuyihe aaca62f461 优化 2026-09-10 15:33:54 +08:00
ouyangxq a2f1ecc6b9 fix(admin): default manual node times to now 2026-09-10 15:23:39 +08:00
ouyangxq 568f2dea8a fix(admin): remove duplicate loading list heading 2026-09-10 14:41:47 +08:00
ouyangxq 84a194db80 优化 2026-09-10 14:17:08 +08:00
ouyangxq c3d69d74c2 优化 2026-09-10 14:16:20 +08:00
ouyangxq 33b79edd18 优化 2026-09-10 14:15:44 +08:00
ouyangxq 2460278cd9 优化 2026-09-10 14:14:24 +08:00
ouyangxq 60734cbb03 fix(admin): show empty customs state for unloaded plan 2026-09-10 14:02:37 +08:00
ouyangxq 10fa397013 优化 2026-09-10 12:18:39 +08:00
ouyangxq 20e4da5bb5 fix(admin): allow manual container nodes without tracking 2026-09-10 12:17:54 +08:00
ouyangxq e3b2552d8d 优化 2026-09-10 11:55:58 +08:00
ouyangxq ae7d8fb22e 优化 2026-09-10 11:52:30 +08:00
ouyangxq 911c6acad2 test(admin): cover outer dimension item update 2026-09-10 11:42:14 +08:00
ouyangxq 8c9f4468af 优化 2026-09-10 11:38:38 +08:00
ouyangxq 9b5b324fe8 优化 2026-09-10 11:17:45 +08:00
ouyangxq 6a038c89ca fix(admin): update submitted measurement calculation strategy 2026-09-10 11:02:52 +08:00
ouyangxq fc0226d4cb 优化 2026-09-10 10:22:48 +08:00
ouyangxq 503a4bac89 优化 2026-09-10 10:10:04 +08:00
ouyangxq 57c987da88 优化 2026-09-09 20:53:30 +08:00
ouyangxq 8b862da084 fix(admin): lock submitted measurement rule editing 2026-09-09 20:36:49 +08:00
ouyangxq f5a43c1cfe test: verify pallet label internal code dialog 2026-09-09 18:35:35 +08:00
ouyangxq 114def1c11 优化 2026-09-09 18:29:47 +08:00
ouyangxq 108ad5c7b5 feat: show FBA internal code on pallet labels 2026-09-09 18:20:05 +08:00
ouyangxq ae01e066ca 优化 2026-09-09 17:58:23 +08:00
ouyangxq 2fd9c5fcc7 优化 2026-09-09 17:42:19 +08:00
ouyangxq a47a7e9767 feat: add pallet label printing 2026-09-09 17:27:00 +08:00
ouyangxq f58c433fa4 fix: complete container and overseas fee contracts 2026-09-09 17:17:05 +08:00
ouyangxq c5cd39becc Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/overseas-warehouse/api/dispatches.ts
#	src/modules/overseas-warehouse/pages/DispatchesView.vue
2026-09-09 16:50:22 +08:00
ouyangxq 8f6d4fc03e 补齐财务费用原币与尾程录入 2026-09-09 16:37:36 +08:00
ouyangxq 51841fc608 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/ContainersView.vue
2026-09-09 15:42:42 +08:00
ouyangxq a869467efb 优化 2026-09-09 15:26:43 +08:00
ouyangxq a2515119b7 优化 2026-09-09 14:32:04 +08:00
ouyangxq f62a3175f7 优化 2026-09-09 13:54:43 +08:00
ouyangxq 08a1f1e5e2 优化 2026-09-09 13:51:14 +08:00
liuyihe e428002898 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-09 13:35:45 +08:00
liuyihe 28d97d2221 优化提交 2026-09-09 13:35:43 +08:00
ouyangxq 41924a438c 优化 2026-09-09 13:33:34 +08:00
ouyangxq 7c34b214af Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts
#	src/modules/container-loading/pages/ContainersView.test.ts
2026-09-09 13:02:53 +08:00
ouyangxq 2dd777d74a fix(admin): load outbound facts when access becomes ready 2026-09-09 13:00:45 +08:00
ouyangxq c83d03e775 优化 2026-09-09 12:50:39 +08:00
ouyangxq 4ccad3bc3c fix(admin): consume booking link actions when cached pages reactivate 2026-09-09 12:15:24 +08:00
ouyangxq d6a69ea157 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-09 12:13:29 +08:00
ouyangxq 7b75f40e28 优化 2026-09-09 11:55:10 +08:00
ouyangxq fa3a199e78 fix(admin): integrate workbench contracts and dropship review states 2026-09-09 11:50:42 +08:00
ouyangxq 87fc08b06e fix(ocean-booking): make customer optional through release 2026-09-09 11:21:13 +08:00
ouyangxq 89d237e129 fix(delivery): version status commands and manage three document categories 2026-09-09 11:17:12 +08:00
ouyangxq 239733a33e fix(container): refresh workbench after empty return 2026-09-09 10:53:37 +08:00
ouyangxq 229489271d 优化 2026-09-09 10:26:17 +08:00
ouyangxq 1249263b94 优化 2026-09-09 10:18:12 +08:00
ouyangxq e643e43828 fix(container): hide provider framework errors 2026-09-09 01:20:39 +08:00
ouyangxq 64340036a3 fix: satisfy container fee frontend contracts 2026-09-09 00:49:13 +08:00
ouyangxq cf8727a809 feat: confirm container inbound plans 2026-09-09 00:44:07 +08:00
ouyangxq 695db084e2 feat: preview container inbound plans 2026-09-08 23:57:12 +08:00
ouyangxq 5068c13c37 fix: keep container fee entries visible 2026-09-08 23:23:47 +08:00
ouyangxq 43604b951d fix: keep container overview mounted 2026-09-08 22:35:07 +08:00
ouyangxq 9a61f57f9e fix: scope overseas container fees 2026-09-08 22:30:04 +08:00
ouyangxq 653ce1c511 feat: separate container fee workbenches 2026-09-08 22:03:03 +08:00
ouyangxq 9570e0fd18 perf: use container finance aggregate 2026-09-08 21:09:25 +08:00
ouyangxq f25839c4b5 perf: defer container workbench data loading 2026-09-08 20:55:30 +08:00
liuyihe d55cfb3737 merge origin/dev and resolve frontend conflicts 2026-09-08 20:33:45 +08:00
liuyihe c3ae936caa 优化 2026-09-08 20:27:47 +08:00
ouyangxq 8910e88563 feat: split domestic and overseas container workbenches 2026-09-08 19:57:42 +08:00
ouyangxq 6b43583cad test(admin): stabilize merged source contracts 2026-09-08 18:45:00 +08:00
ouyangxq 2f571c6eda Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/loading-lists/api/platformLoadingLists.test.ts
2026-09-08 18:37:23 +08:00
ouyangxq 296a8246ad 优化 2026-09-08 18:16:33 +08:00
ouyangxq 34f0ec4980 fix(overseas): label private delivery fee 2026-09-08 18:02:56 +08:00
ouyangxq cf612ce11e fix(overseas): show inventory business reference 2026-09-08 17:56:17 +08:00
ouyangxq 44420919f6 feat: 支持客户专属头程报价 2026-09-08 17:43:04 +08:00
ouyangxq dd9b8f7a63 feat: 完善头程审核与计费信息 2026-09-08 17:38:09 +08:00
ouyangxq 7ba81007a8 feat: support dropship pallet loading 2026-09-08 17:24:49 +08:00
ouyangxq 2dc357e55b Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-08 17:03:10 +08:00
ouyangxq cb7bb90eb6 fix(overseas): keep inbound actions on one line 2026-09-08 16:04:53 +08:00
ouyangxq cbc5521958 fix(overseas): separate receiving and FBA warehouses 2026-09-08 14:15:28 +08:00
ouyangxq fbdb7b6c01 优化 2026-09-08 14:07:39 +08:00
ouyangxq ce7422a506 fix(overseas): clarify last-mile search fields 2026-09-08 13:08:54 +08:00
ouyangxq 2b987bdc3d fix(overseas): align all-container workbench 2026-09-08 11:50:32 +08:00
ouyangxq 8d8df2ab81 fix(overseas): keep receiving warehouse action visible 2026-09-08 11:45:30 +08:00
ouyangxq 463b054a0a feat(overseas): manage last-mile order controls 2026-09-08 10:54:36 +08:00
ouyangxq 74d883a443 feat: add tail order controls and terminal drayage UI 2026-09-07 23:58:18 +08:00
ouyangxq 36d34b7d1d fix(overseas): keep tail-leg business codes on one line 2026-09-07 21:06:39 +08:00
ouyangxq d4dc81a932 fix(overseas): preserve container progress results 2026-09-07 20:45:31 +08:00
ouyangxq e3a37d6ad0 fix(overseas): show outbound pallet details 2026-09-07 20:29:31 +08:00
ouyangxq 86ba48c415 fix(overseas): harden receiving warehouse change 2026-09-07 20:25:14 +08:00
ouyangxq 36278517dd 优化 2026-09-07 19:59:34 +08:00
ouyangxq fdaf98499f fix: optimize table overflow and action widths 2026-09-07 19:04:00 +08:00
ouyangxq dbd51b7fc8 fix: block unsafe container plan actions 2026-09-07 16:36:21 +08:00
ouyangxq 8ccfb9eea4 优化 2026-09-07 15:34:48 +08:00
ouyangxq d488e9ab40 优化 2026-09-07 15:07:39 +08:00
ouyangxq 189d1eae11 feat: add inbound hold evidence controls 2026-09-07 12:21:44 +08:00
ouyangxq 60f430ced8 perf: deduplicate order product image requests 2026-09-07 11:39:55 +08:00
ouyangxq 3b15c4b597 feat: add inbound hold controls 2026-09-07 11:20:43 +08:00
ouyangxq 116f5dc494 test(overseas): cover list search interactions 2026-09-07 11:15:37 +08:00
ouyangxq 33e72927f9 fix(overseas): stabilize last-mile search tabs 2026-09-07 11:01:00 +08:00
ouyangxq 657dd7c6f5 fix(finance): group secondary account actions 2026-09-07 10:11:23 +08:00
ouyangxq eda8dba8bc 优化 2026-09-07 10:04:08 +08:00
liuyihe b31c37951f 优化 2026-09-06 18:11:35 +08:00
liuyihe 12e63b1dcb Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-06 15:53:50 +08:00
liuyihe 8029c235e4 优化 2026-09-06 15:53:48 +08:00
ouyangxq d310f374b4 fix: hide internal procurement operator ids 2026-09-05 23:40:38 +08:00
ouyangxq fedfdd2db9 fix: load overall overseas pallet summary 2026-09-05 23:06:47 +08:00
ouyangxq ccf16b823b feat: show overseas funding and pallet summaries 2026-09-05 22:26:37 +08:00
ouyangxq 92b24cc9a6 feat(finance): manage customer credit limits 2026-09-05 11:54:55 +08:00
ouyangxq fb4cab6a0e fix(finance): avoid duplicate account detail loads 2026-09-05 09:14:06 +08:00
ouyangxq 68fca1bd9f feat(finance): manage customer wallet balances 2026-09-05 08:34:40 +08:00
ouyangxq 48731b227b 优化 2026-09-04 18:47:43 +08:00
ouyangxq 39ade43a72 fix: localize overseas inventory ledger 2026-09-04 17:46:15 +08:00
ouyangxq bc6629d400 fix: align loading and weight acceptance 2026-09-04 16:28:44 +08:00
ouyangxq 871d0a56b9 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
2026-09-04 16:21:32 +08:00
ouyangxq 6a88d21f10 fix: compact smart loading text 2026-09-04 15:54:36 +08:00
ouyangxq ac96249f7d 优化 2026-09-04 15:19:48 +08:00
ouyangxq 3858475c5c Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-04 15:06:41 +08:00
ouyangxq 0f47212477 优化 2026-09-04 15:06:39 +08:00
liuyihe 1bc58284a0 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-04 14:50:17 +08:00
liuyihe bd63c356a6 优化 2026-09-04 14:50:15 +08:00
ouyangxq 420f4a6c66 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-04 14:49:38 +08:00
ouyangxq 6292cc4130 优化 2026-09-04 14:49:36 +08:00
liuyihe 3e59898b09 优化 2026-09-04 14:48:47 +08:00
ouyangxq 2fc84d249c 优化 2026-09-04 14:43:34 +08:00
ouyangxq e4ba4cbb2c Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-04 14:41:31 +08:00
ouyangxq 44eb8d17f2 优化 2026-09-04 14:41:29 +08:00
liuyihe 7db1d12aed 优化 2026-09-04 14:38:08 +08:00
liuyihe 01925fc134 优化 2026-09-04 14:36:11 +08:00
liuyihe 86fab2c24e Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-04 14:31:11 +08:00
liuyihe dd47f3841c 优化 2026-09-04 14:31:09 +08:00
ouyangxq 688eddfa77 优化 2026-09-04 14:17:36 +08:00
ouyangxq 8460cbe123 优化 2026-09-04 14:05:10 +08:00
ouyangxq ea035628aa 优化 2026-09-04 14:04:57 +08:00
ouyangxq 7d29f3d7ea docs: align application scope and merge contracts 2026-09-04 12:41:58 +08:00
ouyangxq 864a3c3cc7 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
#	src/modules/domestic-containers/pages/DomesticContainersView.vue
2026-09-04 12:35:34 +08:00
ouyangxq 4eda56acf1 优化 2026-09-04 12:18:39 +08:00
ouyangxq 72520225d9 Merge remote-tracking branch 'origin/release/test' into release/test 2026-09-04 12:03:17 +08:00
ouyangxq 249499cf14 fix: harden domestic container forms 2026-09-04 12:02:38 +08:00
liuyihe 093c89c058 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-04 11:50:35 +08:00
liuyihe d5662230d5 优化 2026-09-04 11:50:34 +08:00
liuyihe e390b88b6c 优化 2026-09-04 11:50:22 +08:00
ouyangxq 3cd4f4f7b3 优化 2026-09-04 11:35:24 +08:00
ouyangxq 7f7c84d404 优化 2026-09-04 10:51:31 +08:00
ouyangxq 7451a713e5 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/container-loading/pages/GlobalSmartLoadingView.vue
#	src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue
2026-09-04 10:37:12 +08:00
ouyangxq 2435aac81d 优化 2026-09-04 10:12:41 +08:00
ouyangxq 2d14568caf perf(admin): streamline smart loading workbench 2026-09-04 09:57:39 +08:00
ouyangxq 475b2a08e2 修复智能装柜订单状态多选 2026-09-03 22:15:25 +08:00
ouyangxq 38b435a7ac 优化 2026-09-03 21:52:28 +08:00
ouyangxq de4b719a52 优化 2026-09-03 21:50:28 +08:00
ouyangxq 1db4f01e95 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 21:25:58 +08:00
ouyangxq c20ee82def 优化 2026-09-03 21:25:56 +08:00
ouyangxq bc44cbac46 fix: preserve confirmed fee guard after test sync 2026-09-03 21:23:35 +08:00
ouyangxq 2a185c8c9e Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 21:17:55 +08:00
liuyihe d5ebe6c7e8 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 21:09:42 +08:00
liuyihe a825060a45 优化 2026-09-03 21:09:40 +08:00
ouyangxq 60fcc58e30 fix: guard IAM status rendering 2026-09-03 20:30:58 +08:00
ouyangxq d111694185 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 20:28:49 +08:00
ouyangxq 3ac2dc807f 优化 2026-09-03 20:24:54 +08:00
ouyangxq e94ae9b2b7 fix: guard supplier status rendering 2026-09-03 20:20:21 +08:00
ouyangxq 3fd7768e28 fix: complete admin input hints 2026-09-03 19:29:38 +08:00
ouyangxq d90a5db1c4 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-03 18:22:05 +08:00
ouyangxq 15beb497c1 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 18:05:40 +08:00
ouyangxq e2b0102fdb 优化 2026-09-03 18:05:38 +08:00
liuyihe 8f77ad333b Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 17:49:03 +08:00
liuyihe 4ae5598c2d 优化 2026-09-03 17:49:01 +08:00
ouyangxq 393d32bcd9 fix(admin): polish phase five overseas pages 2026-09-03 17:17:11 +08:00
ouyangxq a9bfa69e67 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 17:14:42 +08:00
ouyangxq 7633700e66 优化 2026-09-03 16:48:25 +08:00
ouyangxq 749bcce438 test: align unified order compact column contract 2026-09-03 15:23:55 +08:00
ouyangxq f81bf8576a Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 15:13:41 +08:00
liuyihe afb534d4ee 优化 2026-09-03 15:10:26 +08:00
liuyihe 65a470cdac Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 15:05:32 +08:00
liuyihe 664d4844c3 优化 2026-09-03 15:05:30 +08:00
ouyangxq 3cde5b5881 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 15:00:59 +08:00
ouyangxq 764b4cc2e6 优化 2026-09-03 15:00:58 +08:00
liuyihe 0bf4f5781a Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 14:30:41 +08:00
liuyihe 677a666d9b 优化 2026-09-03 14:30:39 +08:00
ouyangxq a25b4ed0f7 优化 2026-09-03 14:12:42 +08:00
ouyangxq f50ea81cbc Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 13:49:42 +08:00
ouyangxq 31dc0ec0ef 优化 2026-09-03 13:49:40 +08:00
ouyangxq 52b1fce8c1 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 12:02:00 +08:00
ouyangxq 7175f04609 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 12:00:52 +08:00
liuyihe 50ca6a6269 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 12:00:08 +08:00
liuyihe ce1075b9b8 优化 2026-09-03 12:00:07 +08:00
ouyangxq aeafb9a6f1 优化 2026-09-03 11:54:20 +08:00
ouyangxq 3f397a87a2 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 11:53:57 +08:00
ouyangxq d4873aa627 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 11:47:57 +08:00
ouyangxq d8b59ecca5 优化 2026-09-03 11:47:32 +08:00
ouyangxq f50debc5f7 同步测量与入仓工作区测试契约 2026-09-03 11:41:45 +08:00
ouyangxq a09016835c Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 11:41:24 +08:00
liuyihe dd146a09b1 优化 2026-09-03 11:39:22 +08:00
ouyangxq 7ec9add043 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-03 11:34:59 +08:00
ouyangxq 246cbc115e Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-09-03 11:13:38 +08:00
ouyangxq 2f7c122c44 优化 2026-09-03 11:13:28 +08:00
ouyangxq b7985f4a6d Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-03 11:02:21 +08:00
liuyihe 4f65948347 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-09-03 10:46:15 +08:00
liuyihe 176f815519 提交 2026-09-03 10:46:07 +08:00
ouyangxq 9b3664c532 优化 2026-09-03 10:41:09 +08:00
ouyangxq bbe8b73cd4 feat(downloads): expose pending export retry 2026-09-03 10:39:55 +08:00
ouyangxq 3c68ef4ddf 优化 2026-09-03 10:27:31 +08:00
ouyangxq 6270573095 优化 2026-09-02 20:49:19 +08:00
ouyangxq 9377a4ead3 Merge remote-tracking branch 'origin/release/test' into dev 2026-09-02 20:32:42 +08:00
ouyangxq f0801c2774 docs: register finance allocation routes 2026-09-02 20:32:42 +08:00
ouyangxq 913a8df7f3 修正财务客户名称展示 2026-09-02 19:42:27 +08:00
ouyangxq f281268efe 优化 2026-09-02 18:03:45 +08:00
ouyangxq 490fa4e2e5 完善柜级费用分摊与财务来源展示 2026-09-02 17:07:03 +08:00
ouyangxq d4b0462a5a test(overseas): restore phase five acceptance baseline 2026-09-02 15:43:11 +08:00
ouyangxq e31ba2c5a5 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/overseas-warehouse/pages/DispatchesView.vue
#	src/modules/overseas-warehouse/pages/OutboundsView.vue
#	src/modules/overseas-warehouse/pages/PalletDetailView.vue
2026-09-02 15:23:05 +08:00
ouyangxq 602674573c 优化 2026-09-02 15:15:47 +08:00
ouyangxq b3e6971ea8 优化 2026-09-02 14:50:52 +08:00
ouyangxq b5b4b41b99 Merge remote-tracking branch 'origin/release/test' into dev
# Conflicts:
#	src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue
#	src/modules/overseas-warehouse/pages/DispatchesView.vue
#	src/modules/overseas-warehouse/pages/OutboundsView.vue
2026-09-02 14:47:03 +08:00
ouyangxq 637b202559 优化 2026-09-02 14:39:34 +08:00
ouyangxq fa84291551 fix(dispatch): allow transfer before departure 2026-09-02 14:02:04 +08:00
ouyangxq d49bb5a655 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 14:01:40 +08:00
ouyangxq f75e08c1bb Merge remote-tracking branch 'origin/release/test' into dev 2026-09-02 14:01:30 +08:00
ouyangxq 0708b4a262 test(admin): align preload workflow contract 2026-09-02 12:19:48 +08:00
ouyangxq 776c0e2ebb 优化 2026-09-02 11:59:59 +08:00
liuyihe eecf6d8f8a Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-09-02 10:43:45 +08:00
liuyihe 28acf1174a 优化 2026-09-02 10:43:43 +08:00
ouyangxq 490b685f3a Merge remote-tracking branch 'origin/release/test' into dev 2026-09-02 10:34:07 +08:00
ouyangxq bbb9c40c50 feat(admin): edit domestic order declarations 2026-09-02 10:24:07 +08:00
ouyangxq 132d0c22d7 test(admin): cover optional legal-person qualification 2026-09-02 10:12:52 +08:00
ouyangxq 4cdaa1d1ad 优化 2026-09-02 10:05:49 +08:00
ouyangxq b6b43b6b0a 优化 2026-09-02 10:04:47 +08:00
ouyangxq 19eea79836 优化 2026-09-02 10:04:24 +08:00
ouyangxq a14e39893c 优化 2026-09-01 21:09:39 +08:00
ouyangxq 2f26760135 fix(admin): open measurement detail after review 2026-09-01 20:49:04 +08:00
ouyangxq 73b229bb95 优化 2026-09-01 20:41:30 +08:00
ouyangxq a9570a4dae 优化 2026-09-01 20:18:13 +08:00
ouyangxq 29b937989a 优化 2026-09-01 20:07:40 +08:00
ouyangxq a5d4b7d54a test: cover phase 5 overseas admin flows 2026-09-01 19:57:36 +08:00
ouyangxq 440cae9c6d fix: send canonical login identifier 2026-09-01 19:39:17 +08:00
ouyangxq 4ad8537113 test(admin): cover booking-scoped trucking 2026-09-01 16:35:40 +08:00
ouyangxq 5706645032 优化 2026-09-01 16:18:56 +08:00
ouyangxq 9fcea1ca1c refactor(admin): scope truck orders to bookings 2026-09-01 16:14:16 +08:00
ouyangxq adb077a714 优化 2026-09-01 15:46:46 +08:00
ouyangxq c59608ff2c 优化 2026-09-01 15:43:19 +08:00
ouyangxq 0b676ddb74 feat(admin): allow late container number entry 2026-09-01 15:24:37 +08:00
ouyangxq 519ce1dc96 优化 2026-09-01 14:46:47 +08:00
ouyangxq 76604da31b 优化 2026-09-01 14:27:22 +08:00
ouyangxq e42d765f5c feat: complete phase 5 overseas fulfillment admin 2026-09-01 14:23:53 +08:00
ouyangxq b4c29e2226 fix(admin): compact query toolbar fields 2026-09-01 11:53:54 +08:00
ouyangxq 041452f2ff fix(admin): reuse active truck appointments 2026-09-01 11:31:15 +08:00
ouyangxq e3ecba7133 fix(admin): align loading history and pallet lookup 2026-09-01 11:10:22 +08:00
ouyangxq 4711bcc4e6 fix(admin): preserve tail-leg references 2026-09-01 11:03:45 +08:00
ouyangxq 0cf4a93f9c feat(admin): simplify truck payable currency 2026-09-01 10:36:49 +08:00
ouyangxq 62a016a2f8 优化 2026-09-01 10:29:17 +08:00
ouyangxq 96467b9468 Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-01 10:21:57 +08:00
ouyangxq be687f8ded Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev 2026-09-01 10:18:48 +08:00
ouyangxq 8161066657 fix(admin): label supplier payment ledger types 2026-09-01 10:14:38 +08:00
liuyihe c0d6a4eb47 优化 2026-09-01 10:00:05 +08:00
ouyangxq 66c6dfdadc feat(admin): remove customer from truck appointments 2026-08-31 21:05:03 +08:00
ouyangxq 49babd731a test(admin): harden booking document workflows 2026-08-31 21:05:03 +08:00
ouyangxq 623dfce255 fix(admin): clarify finance operating funds 2026-08-31 20:37:08 +08:00
ouyangxq 4d5fd5cfe6 fix(admin): preserve booking load trace details 2026-08-31 20:14:26 +08:00
ouyangxq 09579ea285 fix(admin): align booking files with upload contract 2026-08-31 19:59:17 +08:00
ouyangxq a759eb1b6b feat(admin): bind booking documents and truck containers 2026-08-31 19:54:37 +08:00
ouyangxq 0f4f635686 优化 2026-08-31 18:55:15 +08:00
ouyangxq c7ba96457a fix(admin): replace completed preload workspace tab 2026-08-31 17:05:13 +08:00
ouyangxq 3cae7e4eb5 fix(admin): freeze released booking selectors 2026-08-31 16:56:20 +08:00
ouyangxq 11f8b7856e 优化 2026-08-31 16:44:38 +08:00
ouyangxq 83374ba6cc fix(admin): keep co-load validation action scoped 2026-08-31 16:37:36 +08:00
ouyangxq 3add5dc5b8 fix(admin): resolve preload detail lifecycle 2026-08-31 15:58:38 +08:00
ouyangxq 1a81a5a2b6 fix(admin): validate co-load routes by business code 2026-08-31 15:50:05 +08:00
ouyangxq afbee83b51 style(admin): align list typography with orders 2026-08-31 15:36:02 +08:00
ouyangxq cd290b8dc6 feat(admin): validate and cancel co-load groups 2026-08-31 14:54:57 +08:00
ouyangxq dc1027804e fix(admin): save dirty ocean booking before release 2026-08-31 14:52:38 +08:00
ouyangxq 4bf937ffd3 fix(admin): move preload menu under domestic warehouse 2026-08-31 14:52:01 +08:00
liuyihe d0770517dc 优化 2026-08-31 14:42:28 +08:00
ouyangxq 66ea73e766 fix(admin): expose canonical booking link from container detail 2026-08-31 12:40:01 +08:00
ouyangxq 6ec0a0a028 feat(admin): streamline container identity workflows 2026-08-31 12:12:51 +08:00
liuyihe f7328e7c60 优化 2026-08-31 10:58:12 +08:00
ouyangxq 50c3a7e1ba 优化 2026-08-31 10:48:23 +08:00
ouyangxq 0b9d328bdf test(admin): align inbound relationship dialog consumers 2026-08-31 10:45:53 +08:00
ouyangxq 1548d444c6 优化 2026-08-31 10:31:53 +08:00
ouyangxq b4d5fb1170 优化 2026-08-31 10:17:06 +08:00
ouyangxq 0e828cd44a 优化 2026-08-31 10:09:17 +08:00
liuyihe 462d02efd5 优化 2026-08-30 18:50:16 +08:00
ouyangxq abdea48e8d refactor(admin): use dialog for inbound relationships 2026-08-30 18:21:12 +08:00
ouyangxq 8c1dc50cf3 fix(admin): clarify unassigned overseas warehouse 2026-08-30 18:15:50 +08:00
ouyangxq 2e313172d1 fix(admin): contain relationship drawer body 2026-08-30 18:12:16 +08:00
ouyangxq 06eb745f8b fix(admin): contain inbound relationship drawer status 2026-08-30 18:05:37 +08:00
ouyangxq 0c002c1ca1 fix(admin): load inbound relationships on drawer mount 2026-08-30 18:01:28 +08:00
ouyangxq cee0464e69 feat(admin): show inbound business relationships 2026-08-30 17:46:45 +08:00
ouyangxq ae52141952 优化 2026-08-30 17:43:03 +08:00
liuyihe 9d3862b215 优化 2026-08-30 16:40:03 +08:00
ouyangxq de1b956d43 fix(admin): align coload groups with inbound fragments 2026-08-30 15:49:01 +08:00
ouyangxq c92f3ce2b9 fix(admin): enforce coload groups in global loading 2026-08-29 11:41:57 +08:00
ouyangxq 1aa2dfcdff feat(admin): support indivisible coload groups 2026-08-29 10:57:01 +08:00
ouyangxq aee562d998 fix(admin): expose container customs downloads 2026-08-29 10:19:35 +08:00
ouyangxq 5e617f0c16 fix(admin): raise bounded API upload limit 2026-08-28 22:12:36 +08:00
ouyangxq de81a16ceb 优化 2026-08-28 17:10:16 +08:00
ouyangxq 813763a02a 优化 2026-08-28 16:54:02 +08:00
liuyihe ea1d06448b 恢复尾程订单合托关联展示 2026-08-28 16:30:33 +08:00
liuyihe f7b8159d30 移除尾程订单合托标记功能 2026-08-28 15:47:13 +08:00
liuyihe 0044c01ed0 修正合托关联展示和取消条件 2026-08-28 15:32:18 +08:00
liuyihe d4fc74e601 确保尾程列表显示合托字段 2026-08-28 15:24:13 +08:00
liuyihe f6cd60ec61 完善尾程订单合托关联与取消操作 2026-08-28 15:15:31 +08:00
liuyihe 94a5dfedf6 调整尾程订单合托批量标记 2026-08-28 14:52:30 +08:00
liuyihe 3525e434a4 优化 2026-08-28 14:44:53 +08:00
liuyihe 632fc0bdfa 解决海外仓前端提交冲突 2026-08-28 14:41:40 +08:00
liuyihe ae48f46c9f 优化 2026-08-28 14:33:57 +08:00
ouyangxq 94f003153d 优化 2026-08-28 13:58:52 +08:00
ouyangxq 251645d2d8 优化 2026-08-28 13:54:44 +08:00
ouyangxq 67a1278554 优化 2026-08-28 10:03:37 +08:00
ouyangxq d45e2b4358 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into release/test 2026-08-28 09:19:26 +08:00
ouyangxq 4687f17d09 优化 2026-08-28 09:19:24 +08:00
wujinliang 8f447e58f4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-27 21:48:31 +08:00
wujinliang b47323edf3 1 2026-08-27 21:48:15 +08:00
ouyangxq 44d05e77cb 优化 2026-08-27 17:49:05 +08:00
ouyangxq 929f50dc55 优化 2026-08-27 17:16:38 +08:00
ouyangxq 0181656b4f 优化 2026-08-27 16:42:32 +08:00
ouyangxq a90d5fbe39 优化 2026-08-27 16:34:32 +08:00
ouyangxq 690f386bff fix(admin): unblock tailleg inbound planning 2026-08-27 15:56:13 +08:00
ouyangxq 00045a9764 优化 2026-08-27 15:02:41 +08:00
ouyangxq 24575223a0 优化 2026-08-27 13:50:41 +08:00
ouyangxq 550d8add97 优化 2026-08-27 11:38:59 +08:00
ouyangxq 82f0479928 优化 2026-08-27 11:08:03 +08:00
ouyangxq 506d310211 优化 2026-08-27 10:44:12 +08:00
ouyangxq f7afcb6cdc feat(admin): filter booking agents by carrier 2026-08-27 10:33:43 +08:00
ouyangxq 3e3c7e237a fix(admin): align supplier carrier master data 2026-08-27 10:12:20 +08:00
ouyangxq 29bb641524 chore: remove tracked dependency cache 2026-08-26 20:51:27 +08:00
ouyangxq d92af5559e Merge remote-tracking branch 'origin/dev' into release/test 2026-08-26 20:50:24 +08:00
ouyangxq 38031b0fa0 优化 2026-08-26 20:39:55 +08:00
wujinliang 9eec68f7c5 1 2026-08-26 18:13:12 +08:00
ouyangxq 7b50e92baf 优化 2026-08-26 17:29:11 +08:00
ouyangxq 64f4e8ee34 1 2026-08-26 17:25:14 +08:00
ouyangxq 0ce42a01e2 Merge branch 'release/test' of http://git.buggoglobal.com/html/admin-web into dev 2026-08-26 17:24:57 +08:00
ouyangxq a546ee4c3b Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into dev 2026-08-26 15:59:05 +08:00
ouyangxq 6d4748c62a 优化 2026-08-26 15:59:01 +08:00
liuyihe 525c56fdb1 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
# Conflicts:
#	src/modules/overseas-warehouse/pages/OutboundsView.vue
2026-08-26 15:53:45 +08:00
liuyihe fac3153472 优化 2026-08-26 15:48:34 +08:00
ouyangxq b907881740 fix(admin): prevent duplicate actual carrier nodes 2026-08-26 15:41:08 +08:00
wujinliang a67f6300f8 1 2026-08-26 15:32:22 +08:00
ouyangxq b8948fdff0 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-26 14:49:19 +08:00
ouyangxq 2563ad7be3 优化 2026-08-26 14:48:48 +08:00
wujinliang 627b9602d1 1 2026-08-26 14:33:13 +08:00
ouyangxq 5daaf1a5fa 优化 2026-08-26 13:58:39 +08:00
ouyangxq a3d04eaad8 fix(admin): consolidate container exception history 2026-08-26 13:48:55 +08:00
ouyangxq d67544af5e fix(admin): refresh container document versions 2026-08-26 13:06:00 +08:00
ouyangxq 9585c21f14 fix(admin): render container documents as table 2026-08-26 13:00:51 +08:00
ouyangxq 4a7a5ae5b6 feat(admin): complete customs from container detail 2026-08-26 12:46:46 +08:00
ouyangxq 4088e36f74 fix(admin): compact container departure gates 2026-08-26 12:36:01 +08:00
ouyangxq e8c41bca2d feat(admin): confirm container departure from detail 2026-08-26 12:04:50 +08:00
ouyangxq 8307510fc3 fix(admin): load picker warehouses from domestic master 2026-08-26 11:42:46 +08:00
ouyangxq 011195012b Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-26 11:11:16 +08:00
ouyangxq f6d1febea8 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-26 11:10:49 +08:00
ouyangxq c38e538a95 优化 2026-08-26 11:10:48 +08:00
liuyihe d03f7b3f5d 优化 2026-08-26 11:10:22 +08:00
ouyangxq 53c92d4d37 优化 2026-08-26 09:14:04 +08:00
wujinliang 57de615717 1 2026-08-25 22:08:59 +08:00
liuyihe dcbb6d249f Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-25 20:57:52 +08:00
liuyihe 966fbd1880 优化 2026-08-25 20:57:51 +08:00
ouyangxq 68b0f8bdf9 test(admin): align booking link regression facts 2026-08-25 20:34:52 +08:00
ouyangxq 10ff84497f fix(admin): stabilize released booking association 2026-08-25 20:18:59 +08:00
ouyangxq c3ea54dcd4 优化 2026-08-25 19:56:37 +08:00
ouyangxq ab168ee4f4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-25 17:55:19 +08:00
wujinliang f702d1b988 1 2026-08-25 17:43:00 +08:00
ouyangxq 3545ffa522 优化 2026-08-25 16:40:44 +08:00
ouyangxq d5a23a7c65 优化 2026-08-25 15:08:31 +08:00
liuyihe 26fcec530f Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-25 14:50:29 +08:00
liuyihe 842cb078fc 优化 2026-08-25 14:50:27 +08:00
ouyangxq 11580f9f83 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-25 14:48:31 +08:00
wujinliang f4dea3f705 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-25 12:51:44 +08:00
wujinliang 8b3aae399a 1 2026-08-25 12:51:32 +08:00
ouyangxq c73cee3962 fix(deploy): bind admin development port locally 2026-08-25 11:39:12 +08:00
liuyihe bcdce8ef6c Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-25 10:34:28 +08:00
liuyihe 787ab1c90c 优化 2026-08-25 10:34:27 +08:00
ouyangxq 5641545fd5 优化 2026-08-25 09:52:45 +08:00
ouyangxq e9b6979a82 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-24 19:23:52 +08:00
ouyangxq 1f3e14a6cd 修复智能装柜入仓码重复展示 2026-08-24 18:07:38 +08:00
ouyangxq 10f3e5789a 优化 2026-08-24 17:15:52 +08:00
ouyangxq 37ec787bc1 fix(phase4): harden development acceptance adapters 2026-08-24 16:57:25 +08:00
ouyangxq 964480a617 feat(phase4): automate admin integration acceptance 2026-08-24 15:41:36 +08:00
ouyangxq 91db777cb8 fix(ci): preserve admin compose project identity 2026-08-24 14:44:38 +08:00
ouyangxq f6b5ad89a1 fix(ci): use authorized frontend base image namespace 2026-08-24 14:37:55 +08:00
ouyangxq 7033903e67 fix(ci): use internal frontend base images 2026-08-24 14:21:43 +08:00
ouyangxq f211d12cda fix(ci): deploy admin frontend on target node 2026-08-24 14:00:55 +08:00
liuyihe 806afac6a8 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-22 12:24:18 +08:00
liuyihe 866ea58cab 优化 2026-08-22 12:24:15 +08:00
ouyangxq 25eedfe7c7 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-22 11:47:26 +08:00
ouyangxq bff9ccbfb6 优化 2026-08-22 10:02:13 +08:00
ouyangxq a4f2a9b8a7 优化 2026-08-22 09:56:34 +08:00
liuyihe 76c71a3e5e Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-21 18:16:43 +08:00
liuyihe 0e67cb9d65 优化 2026-08-21 18:16:42 +08:00
ouyangxq 456e7e56a8 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-21 17:59:51 +08:00
liuyihe b77428b453 优化 2026-08-21 17:52:07 +08:00
liuyihe c71753c989 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-21 17:20:41 +08:00
liuyihe c972ad408a 优化 2026-08-21 17:20:40 +08:00
ouyangxq 53deccc0c4 优化 2026-08-21 17:19:54 +08:00
ouyangxq 19683f8759 优化 2026-08-21 17:07:37 +08:00
ouyangxq 6ecd340ea8 优化 2026-08-21 16:28:59 +08:00
ouyangxq 0a51b6f197 Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-21 16:04:26 +08:00
ouyangxq d018daa4d8 优化 2026-08-21 16:04:24 +08:00
liuyihe 465be7c8f3 合并 admin web 冲突并同步库存接口测试 2026-08-21 11:28:39 +08:00
liuyihe 6e9bb6e00c 优化 2026-08-21 11:20:09 +08:00
ouyangxq 3c4db20ebb 优化 2026-08-21 10:38:31 +08:00
ouyangxq 0428b8af75 优化 2026-08-20 19:55:26 +08:00
ouyangxq e75a0eb463 优化 2026-08-20 19:20:10 +08:00
ouyangxq 05a37b1cc4 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-20 19:11:23 +08:00
ouyangxq 506c5a84cf 优化 2026-08-20 19:10:19 +08:00
liuyihe 595c998214 Merge remote-tracking branch 'origin/dev' into dev 2026-08-20 18:27:39 +08:00
liuyihe 9ac4495632 合并海外仓入库预约与真实接口改动 2026-08-20 18:27:30 +08:00
ouyangxq 86618ced6d Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into dev 2026-08-20 18:23:39 +08:00
ouyangxq 659ddf0614 优化 2026-08-20 18:17:36 +08:00
ouyangxq c91c87784e 优化 2026-08-20 18:05:27 +08:00
liuyihe c4cd4b42b3 优化提交 2026-08-20 18:04:38 +08:00
ouyangxq 80947cd9ce 优化 2026-08-20 18:02:52 +08:00
ouyangxq b0d527b016 优化 2026-08-20 18:02:24 +08:00
ouyangxq c93eea89c8 优化 2026-08-20 16:13:55 +08:00
ouyangxq 4ae5e7c672 优化 2026-08-20 14:43:10 +08:00
ouyangxq 4df6a5f571 优化 2026-08-20 13:39:18 +08:00
ouyangxq 3c229f807c 优化 2026-08-20 09:49:37 +08:00
ouyangxq 508f76924e 优化 2026-08-20 09:10:36 +08:00
ouyangxq 033b5df6b6 优化 2026-08-19 17:08:45 +08:00
ouyangxq 65cd9ecefa 优化 2026-08-19 16:45:51 +08:00
ouyangxq d1de19dc82 优化 2026-08-19 10:00:50 +08:00
ouyangxq 0cd13f0b4d 优化 2026-08-19 09:42:12 +08:00
ouyangxq 80cfca5056 优化 2026-08-19 09:14:35 +08:00
ouyangxq c188482c24 优化 2026-08-18 17:40:40 +08:00
ouyangxq 6ff77e92b2 优化 2026-08-18 16:39:44 +08:00
ouyangxq 21249d1dde 优化 2026-08-18 16:09:43 +08:00
ouyangxq f9ae2d94cb 优化 2026-08-18 11:41:14 +08:00
ouyangxq 114d5bacc7 优化 2026-08-18 11:33:01 +08:00
ouyangxq 834382e04b 优化 2026-08-18 09:46:34 +08:00
ouyangxq dd87d4d916 优化 2026-08-18 09:37:07 +08:00
ouyangxq 8046f9b9ee 优化 2026-08-18 09:09:34 +08:00
ouyangxq dcbca0bf85 优化 2026-08-17 20:01:51 +08:00
ouyangxq 8f53c81196 优化 2026-08-17 19:57:32 +08:00
ouyangxq ee4cd35927 优化 2026-08-17 19:52:04 +08:00
ouyangxq d618683467 优化 2026-08-17 19:13:25 +08:00
ouyangxq 28f2cd4cc5 优化 2026-08-17 18:43:12 +08:00
ouyangxq 822b285312 优化 2026-08-17 18:32:41 +08:00
ouyangxq d14d0dcb61 优化 2026-08-17 18:04:08 +08:00
ouyangxq ec3dbb3fe4 优化 2026-08-17 18:00:42 +08:00
ouyangxq c7334b2cf8 优化 2026-08-17 17:49:10 +08:00
ouyangxq 911a3620e1 优化 2026-08-17 16:58:35 +08:00
ouyangxq d95c90bd1a 优化 2026-08-17 16:45:46 +08:00
ouyangxq 4a9438315c 优化 2026-08-17 13:24:55 +08:00
ouyangxq 6a0c0b9229 优化 2026-08-17 12:31:27 +08:00
ouyangxq c6fa22c1b5 优化 2026-08-17 11:23:31 +08:00
ouyangxq f41eaa05a3 优化 2026-08-17 10:54:11 +08:00
ouyangxq 835d1d0216 feat: connect plan-level preload confirmation 2026-08-15 17:04:00 +08:00
ouyangxq fc80d09faa 优化 2026-08-15 12:33:29 +08:00
ouyangxq e214672779 优化 2026-08-15 12:12:38 +08:00
ouyangxq e000f8bed2 优化 2026-08-15 11:15:00 +08:00
ouyangxq 4573b3df1c 优化 2026-08-15 11:12:22 +08:00
ouyangxq fb165b6347 优化 2026-08-15 11:06:04 +08:00
ouyangxq ebbb4bc459 优化 2026-08-15 10:35:58 +08:00
ouyangxq 88124ce5b6 优化 2026-08-15 10:27:18 +08:00
ouyangxq 764ded0e1c 优化 2026-08-15 10:09:07 +08:00
ouyangxq 58a73f85e8 优化 2026-08-15 10:05:01 +08:00
ouyangxq c464f47fb9 优化 2026-08-15 10:03:03 +08:00
ouyangxq fc8480426d 优化 2026-08-15 09:57:49 +08:00
ouyangxq a26e2e6c3e 优化 2026-08-15 09:47:23 +08:00
ouyangxq 086b4304c6 feat(admin): add simulated container booking links 2026-08-14 20:59:19 +08:00
ouyangxq 1540b80c89 优化 2026-08-14 19:59:32 +08:00
ouyangxq f7594ebd39 优化 2026-08-14 19:09:54 +08:00
ouyangxq 4dec54feef 优化 2026-08-14 19:02:34 +08:00
ouyangxq 44ee2be246 优化 2026-08-14 18:50:11 +08:00
ouyangxq 93e0294ec0 修复开发部署 SSH 指纹读取 2026-08-14 17:54:53 +08:00
ouyangxq 8e8d78dd0d 支持开发环境远程部署 2026-08-14 17:45:43 +08:00
ouyangxq 010204da57 优化 2026-08-14 17:38:06 +08:00
ouyangxq d1b4a4d3f3 优化 2026-08-14 16:06:30 +08:00
ouyangxq 1c1c48f8d3 优化 2026-08-14 14:50:44 +08:00
ouyangxq a81af4a664 优化 2026-08-14 14:27:03 +08:00
ouyangxq 724848feb6 优化 2026-08-14 13:03:11 +08:00
ouyangxq f1721b2780 优化 2026-08-14 12:50:50 +08:00
ouyangxq 590a457dcd 优化 2026-08-14 12:50:42 +08:00
ouyangxq 87c6d2c4ca 优化 2026-08-14 10:09:51 +08:00
ouyangxq 3598dd0a80 优化 2026-08-14 09:14:55 +08:00
ouyangxq b9cfdcf42a 优化 2026-08-13 20:00:38 +08:00
ouyangxq 5489bff92f fix: refresh cached inbound task lists 2026-08-13 19:38:20 +08:00
ouyangxq 69a0093d99 优化 2026-08-13 19:23:48 +08:00
ouyangxq df1471dc30 优化 2026-08-13 19:19:34 +08:00
ouyangxq 0f0ae612ca 优化 2026-08-13 19:00:37 +08:00
ouyangxq 41038e1edd 优化 2026-08-13 17:43:04 +08:00
ouyangxq a1ed15f055 优化 2026-08-13 17:24:43 +08:00
ouyangxq b21fd625e4 优化 2026-08-13 17:14:51 +08:00
ouyangxq 8831218c2e 优化 2026-08-13 15:59:08 +08:00
ouyangxq 59836202d8 优化 2026-08-13 15:51:32 +08:00
ouyangxq 33b47f4d2e 优化 2026-08-13 14:55:59 +08:00
ouyangxq da20613aa0 优化 2026-08-13 14:37:59 +08:00
ouyangxq d0394210f6 优化 2026-08-13 13:40:18 +08:00
ouyangxq 8e3b4a80df 优化 2026-08-13 12:37:49 +08:00
ouyangxq 8a8c4fa190 优化 2026-08-13 12:22:41 +08:00
ouyangxq 835ac0ad12 优化 2026-08-13 11:57:44 +08:00
ouyangxq 7beca3f985 优化 2026-08-12 20:36:17 +08:00
ouyangxq 791c3b89a7 wdwx 2026-08-12 20:06:32 +08:00
ouyangxq de85d4c2c0 优化 2026-08-12 19:56:43 +08:00
ouyangxq 46407cb765 优化 2026-08-12 19:42:24 +08:00
ouyangxq 9bb51e7a01 优化 2026-08-12 19:30:20 +08:00
ouyangxq 49b21ff22a fix(admin): type booking warehouse selection 2026-08-12 19:27:55 +08:00
ouyangxq f13b27c797 优化 2026-08-12 19:04:48 +08:00
ouyangxq 971ac91a23 fix(admin): validate explicit booking container limits 2026-08-12 18:55:18 +08:00
ouyangxq 1be13c3bf5 优化 2026-08-12 18:52:22 +08:00
ouyangxq 2c5f53ec00 优化 2026-08-12 18:50:03 +08:00
ouyangxq 41a131a33b feat(admin): adapt channel booking and smart loading contracts 2026-08-12 18:43:54 +08:00
ouyangxq 9ebd0b2295 优化 2026-08-12 18:24:05 +08:00
ouyangxq 4c12831ee0 优化 2026-08-12 18:16:44 +08:00
ouyangxq 9d9b8943b8 优化 2026-08-12 18:00:26 +08:00
ouyangxq 2a889a7d91 优化 2026-08-12 17:45:49 +08:00
ouyangxq 5b441626e9 优化 2026-08-12 17:34:06 +08:00
ouyangxq d2a246b122 优化 2026-08-12 17:29:35 +08:00
ouyangxq 16c5ed6301 优化 2026-08-12 16:58:52 +08:00
ouyangxq a44fd5f235 优化 2026-08-12 16:40:09 +08:00
ouyangxq d8bbfe05f3 优化 2026-08-12 16:31:46 +08:00
ouyangxq c15d21b986 优化 2026-08-12 15:23:18 +08:00
ouyangxq a01d2bbc9a 优化 2026-08-12 15:10:37 +08:00
ouyangxq 1ee5a2dec7 优化 2026-08-12 14:57:02 +08:00
ouyangxq 975e0057e3 优化 2026-08-12 14:08:33 +08:00
ouyangxq 4fb8b85c13 优化 2026-08-12 12:35:32 +08:00
ouyangxq 11e085e30f 优化 2026-08-12 12:19:02 +08:00
ouyangxq f9ff9086d5 优化 2026-08-12 10:59:49 +08:00
ouyangxq fb542e0b6f 优化 2026-08-12 09:25:55 +08:00
ouyangxq ffb2116741 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-11 20:39:36 +08:00
ouyangxq 23fc845dcf 优化 2026-08-11 20:39:34 +08:00
liuyihe 991d0f639c Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-11 20:11:01 +08:00
liuyihe 917ffa73c7 修改提交 2026-08-11 20:11:00 +08:00
ouyangxq 75f9c51bea test(auth): verify replay uses rotated access token 2026-08-11 20:07:23 +08:00
ouyangxq 7ece30bffa 优化 2026-08-11 20:02:38 +08:00
ouyangxq 41618847b7 优化 2026-08-11 19:59:15 +08:00
ouyangxq c690a30422 优化 2026-08-11 19:35:28 +08:00
ouyangxq 886c7d3529 优化 2026-08-11 18:48:17 +08:00
ouyangxq 6622815d6c 优化 2026-08-11 18:43:56 +08:00
ouyangxq 6456278ec8 优化 2026-08-11 18:17:05 +08:00
ouyangxq 61d9673f15 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-11 18:06:37 +08:00
ouyangxq 03e1cd0c80 优化优化 2026-08-11 18:06:35 +08:00
liuyihe acc594655e 智能装柜查询优化 2026-08-11 14:36:08 +08:00
liuyihe ef35a1be2e 装柜优化 2026-08-11 14:23:46 +08:00
ouyangxq 8a8c5a3faa Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-11 13:55:26 +08:00
ouyangxq d6b2ea0938 优化 2026-08-11 13:55:23 +08:00
liuyihe 5633fb97a9 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-11 11:59:39 +08:00
liuyihe 347200a8c6 修改 2026-08-11 11:52:30 +08:00
ouyangxq e3757cc9f9 优化 2026-08-11 11:41:47 +08:00
ouyangxq af30c0c16c fix(admin): guard outbound dispatch lookup 2026-08-11 11:36:42 +08:00
ouyangxq 17d483bf21 feat(admin): register phase3 service center routes 2026-08-11 11:30:30 +08:00
ouyangxq c8eb2d75cd feat(admin): add phase3 service center pages 2026-08-11 11:26:31 +08:00
ouyangxq 9b994040b8 fix(admin): complete container loading workflows 2026-08-11 11:15:44 +08:00
ouyangxq 76f5a1debf Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-08-11 10:46:05 +08:00
ouyangxq b7b220f6e0 优化 2026-08-11 10:45:59 +08:00
liuyihe 852207efee 编辑按钮 2026-08-11 10:35:39 +08:00
ouyangxq 5da1b7548b 优化 2026-08-11 09:25:48 +08:00
ouyangxq d967355272 feat(admin): show single-remainder fulfillment aggregate 2026-08-11 09:22:22 +08:00
liuyihe c5295abb49 merge latest release/test and preserve loading edit behavior 2026-08-10 20:58:26 +08:00
liuyihe a19a8f03f5 优化 2026-08-10 20:53:48 +08:00
761 changed files with 79992 additions and 7329 deletions
+3 -1
View File
@@ -3,6 +3,8 @@
dist
node_modules
node_modules.broken-by-path
.env.local
.env
.env.*
!.env.example
npm-debug.log*
pnpm-debug.log*
+10 -1
View File
@@ -1,4 +1,13 @@
VITE_API_BASE_URL=/api
API_PROXY_TARGET=http://8.163.30.128:9000
# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。
API_PROXY_TARGET=http://localhost:9000
# Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead.
VITE_CLIENT_WEB_URL=http://localhost:5174
# Test container deployment (Jenkins environment or untracked .env).
# API_UPSTREAM: unified Gateway origin, no /api suffix.
# TEST_NETWORK: shared test Docker network.
API_UPSTREAM=http://gateway:9000
TEST_NETWORK=firstleg-test-network
IMAGE_TAG=
CLIENT_WEB_URL=
+2 -1
View File
@@ -7,15 +7,16 @@ dist/
!.env.example
.playwright-cli/
.idea/
.impeccable/
.codex-temp/
.codex-tmp/
.logs/
outputs/
reports/
components.d.ts
auto-imports.d.ts
*.log
.DS_Store
Thumbs.db
+98 -79
View File
@@ -1,103 +1,121 @@
# Admin Web Instructions
## 项目定位
## 快速读取顺序
- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。
- 当前范围包括工作台、客户管理、头程订单、内部用户、角色管理和菜单权限。
- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
## 技术基线
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
- 使用 Vue 3、TypeScript、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue。
- TypeScript 保持严格模式;不要使用 `any` 绕过业务模型,优先从 `@chongchong/shared` 复用跨项目类型。
- 使用现有 `@` 别名引用 `src/`。保持 ES Module 和现有无分号代码风格。
- Element Plus 组件使用现有自动导入配置;`ref`、`computed` 等 Vue API 必须显式导入。`src/auto-imports.d.ts` 与 `src/components.d.ts` 属于生成声明,不手工维护业务内容。
## 项目定位与技术基线
- 本项目是虫虫出海内部运营后台,仅服务平台员工;不承载企业客户自助、官网、小程序、独立 APP 或 PDA 作业。
- 使用 Vue 3、TypeScript 严格模式、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue;保持 ES Module、现有无分号风格和 `@` 对 `src/` 的别名。
- 不用 `any` 绕过业务模型。模块专属类型留在模块,本应用多模块稳定复用的类型放 `src/shared/`;服务端契约以 Controller/OpenAPI 为准。
- Element Plus 使用现有自动导入;Vue API 显式导入。`src/auto-imports.d.ts`、`src/components.d.ts` 是生成声明,不手工维护业务内容。
- 不更换框架、路由、状态管理、组件库或构建工具,除非任务明确要求并完成影响评估。
## 目录与职责
## 目录与依赖方向
- `src/app/`:应用装配层,包含布局、根路由、全局守卫和启动入口;不承载具体业务或被业务模块反向依赖。
- `src/core/`:认证会话、访问控制、导航配置、未保存工作保护和路由安全工具等全应用基础能力;可以被 `app` 和业务模块使用,但不得依赖业务模块。
- `src/modules/`:按客户、仓库、头程订单、主数据、下载中心、IAM 等稳定业务领域组织页面、API、类型、组件和路由。新增业务页面必须进入对应模块,不再建立根级 `views/`。
- `src/shared/`:与具体业务无关的后台公共 HTTP、组件和工具。模块专属能力先留在模块内部,只有被无关模块真实复用时才上移。
- `src/prototype/`:未纳入当前迭代的原型页面和原型路由;正式模块不得依赖原型代码。
- `src/modules/*/pages/`:路由级页面和业务编排;复杂展示、表单、抽屉和交互下沉到同模块组件或 composable。
- `src/modules/*/routes.ts`:模块路由片段;由 `src/app/router/index.ts` 统一装配。新增模块时同步维护 `src/core/navigation/adminNavigation.ts`。
- `src/core/*/` 与 `src/modules/*/stores/`:前者只放全应用基础状态,后者放领域状态;仅页面局部使用的状态留在页面或 composable。
- `src/modules/*/api/`:领域接口适配;公共 Axios 客户端位于 `src/shared/api/http.ts`,页面与组件不得散落创建 Axios 实例。
- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。
- 模块专属类型放在对应模块;本应用多个模块稳定复用的前端类型和工具放到应用内 `shared/`。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
- `src/styles/`:设计令牌和全局样式;优先复用现有 `--cc-*` 变量和组件样式。
| 目录 | 职责 |
|---|---|
| `src/app/` | 应用装配、布局、根路由、全局守卫和启动;不承载业务 |
| `src/core/` | 认证、访问控制、导航、路由安全等全应用能力;不得依赖业务模块 |
| `src/modules/` | 稳定业务领域的页面、API、类型、组件和模块路由;新增业务页面不放根级 `views/` |
| `src/shared/` | 无业务归属的 HTTP、组件和工具;只有真实跨模块复用才上移 |
| `src/prototype/` | 未纳入当前迭代的原型实现;正式模块不得依赖 |
| `src/styles/` | 设计令牌和全局样式,优先复用 `--cc-*` 变量 |
依赖方向保持为 `app -> modules/core/shared`、`modules -> core/shared`、`core -> shared`、`shared -> 无上层依赖`。模块间确需调用时只能引用对方根入口公开的 API 或契约,不得引用其他模块的页面、内部组件或内部目录。
- 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。
- 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。
- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
## 模块快速查询
## 模块快速路由
进入模块后按 `routes.ts → pages/ → api/、types/、stores/ 或 composables/` 查询;没有对应目录时再查相邻代码,不先扫描全部 `src/`。
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
| 业务 | 模块入口 | 联查模块或服务端 |
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 客户、企业审核 | `modules/customers/` | `iam/`、`file/` |
| 平台用户与 RBAC | `modules/iam/`、`core/access/` | `auth/`、`iam/` |
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
| 订单列表与详情编排 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/` |
| 入库、测量和计费 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` |
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
| 预装与装柜清单 | `modules/loading-lists/` | `firstleg/loading/`、`file/`、`warehouse/` |
| 海运订舱与船期 | `modules/ocean-bookings/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/` |
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/` |
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/` |
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
## 一期/二期/三期 Admin 快速索引
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
按阶段处理需求时,先核对仓库根目录对应阶段文档,再按“模块目录 → `routes.ts`/页面 → `api`/类型”定位;阶段范围高于原型中的全量菜单。模块代码均从 `src/` 起算。本表只描述 Admin 页面、路由和前端契约,不替代后端模块指引。
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
| 阶段 | Admin 范围与入口 | 稳定边界 |
## 海外模块内部快速索引(海外仓全流程)
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 一期:头程订单闭环 | `modules/auth/` 登录;`customers/` 客户与企业审核;`iam/` 内部用户、角色、权限;`warehouses/` 平台收货仓;`master-data/` 快递公司、往来单位;`first-leg-orders/` 头程订单;`dashboard/` 工作台 | 企业审核归客户列表,平台 RBAC 仅维护平台员工与平台权限资源 |
| 二期:到仓与头程计费 | `modules/inbounds/` 入库预报、收货、测量/OCR、审核、差异与费用快照;`warehouse-resources/pallets/` 托盘;`warehouse-resources/locations/` 库位;`fba-pricing/`、`dimension-config/` 计费规则与尺寸配置;`unified-orders/` 订单工作流编排 | 页面范围停在 `WAIT_SPLIT + APPROVED + CONFIRMED`;海外仓阶梯费用页面是否开放需单独确认;客户侧可见字段按授权结果映射,内部备注、文件和原始证据不得进入 Client 契约 |
| 三期:国内出仓 | `unified-orders/` 订单编排;`container-loading/` 货柜与排柜;`loading-lists/` 预装/装柜清单;`ocean-bookings/` 订舱船期;`trucking/` 拖车 | 页面、权限和 API 以稳定服务端契约发布;完整 WMS、海外仓及原型未纳入能力不属于本范围 |
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
阶段文档入口(路径相对本文件所在目录):一期读 `../../docs/一期/README.md` 与闭环设计;二期读 `../../docs/二期/README.md` 和主流程;三期读 `../../docs/三期/README.md`、需求确认表、开工门禁、开发规划和页面清单。进度、排期或验收状态问题再读取相应进度表和验收报告。
## 业务、安全与共享装配
## 业务与安全约束
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
- 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
- 当前产品层级只有平台和客户(企业)两级;Admin 不把“租户”作为用户可见概念,不新增租户选择器、目标租户文案或依赖租户上下文才能使用的页面流程。多租户产品能力后续单独设计。
- 平台仓库是公共主数据,不与客户绑定。托盘、库位等仓储资源页面必须能够独立查询全部客户和全部仓库,选择客户不得缩小、清空或禁用仓库候选。
- 后端兼容字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。
## 开发验收身份
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
- 认证统一经过 `src/core/auth/auth.ts` 和 `src/shared/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。
- 当前登录和部分数据仍可能是模拟实现。接入 `baiyue-firstleg` Gateway 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。
- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。
- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。
- Admin 维护客户专属数据时,必须通过平台客户列表显式选择目标客户,不得从本地存储推断客户。平台仓库等公共主数据不要求先选择客户;切换客户时不得保留上一客户的编辑、详情或导入上下文。
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
- 视觉验收由人工操作真实页面并截图,AI 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
## UI 与交互
## 命令与质量门禁
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
- 路由级页面标题和说明统一由 `AppLayout` 顶部 `page-heading` 根据路由 `meta.title`、`meta.description` 展示。标准列表页正文不得重复渲染 `PageToolbar`、页面标题或说明,只保留查询区与表格区。`QueryToolbar` 只承载筛选项及其内置查询、重置按钮,不把新增、导入、导出或批量操作放入其 `actions` 插槽;重新执行查询即刷新当前结果,标准列表页任何位置都不得再提供独立刷新按钮。新增、导入、导出和批量操作统一放入 `DataTable` 的 `toolbar-actions` 插槽并靠表格工具栏右侧展示。仅没有查询组件的实时工作台或详情页在业务确实需要主动重载时,才可保留刷新;仅详情页或工作台在路由明确设置 `showPageHeading: false` 时,才可在正文使用独立详情头。
- 标准列表页的查询面板与表格面板使用 `ListPageLayout` 保持单一 `16px` 间距,不再叠加页面级 margin、空白操作区或额外容器间距。`DataTable` 必须位于连续的 `surface-panel table-panel` 中,使右侧业务按钮、列设置、表头和分页属于同一表格面板;不得使用 `toolbar-start` 重复显示列表标题、数量或说明,数量统一放在 `pagination-left`。当工具栏只有右侧操作和列设置时使用紧凑高度,不保留空白的左侧标题区域。
- 操作按钮统一使用 Lucide 图标和固定语义:详情/查看 `Eye`、编辑 `Pencil`、更多 `MoreHorizontal`、新增 `Plus`、删除 `Trash2`、启停 `Power`、刷新 `RefreshCw`、导入 `FileUp`、导出/下载 `Download`、返回 `ArrowLeft`、保存 `Save`、复制 `Copy`。表格高频操作使用“图标 + 文案”,更多操作使用仅图标按钮并提供 `aria-label` 与悬停提示;同一语义不得在不同页面混用其他图标、纯文字按钮或手绘 SVG。危险操作继续使用明确文案、危险色和确认流程,不能只显示图标。
- 表格单行操作不超过 3 个时全部直接展示“图标 + 文案”;从第 4 个操作开始才使用仅图标的 `MoreHorizontal` 更多菜单,并优先直接保留详情、编辑、下载等高频操作。进入更多菜单的启停、删除等危险操作仍须显示明确文案和危险语义,并沿用原确认流程;不得为了布局方便把仅有 1-3 个操作提前折叠到更多菜单。
- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。
- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
- 新页面至少适配现有 320px 最小宽度策略,并检查侧栏、表格、抽屉和表单在窄屏下的退化方式。
- 国内仓、客户管理密度默认值统一维护在 `src/styles/adminDensityTokens.css`,通用按钮/页签/描述表/弹框规则统一维护在 `adminDensity.css`;页面只选择公共类与语义参数、保留业务布局,不复制通用外观或在页面重新定义 `--admin-*` 默认值。修改前沿实际引用读取模块 CSS,接管后删除冲突旧声明。
- 修改密度样式或接入消费者时更新并运行 `src/styles/adminDensity.test.ts` 维护合同;不得为通过检查删除权限、业务提交或错误处理断言。未接入菜单通过显式密度类隔离。
## 公共前端组件复用
- 开发或重构页面前,先检查 `src/shared/components/`,不得在业务页面重复实现已有的查询栏、数据表格、批量导入、弹窗、抽屉、加载错误、状态和复制交互。公共组件无法满足时,先判断能力是否具有跨模块复用价值:通用能力扩展公共组件并补测试;纯业务特例留在所属模块,不用页面级 CSS 覆盖破坏公共组件契约。
- 查询与筛选统一使用 `src/shared/components/QueryToolbar.vue`。通过 `fields` 配置 `input`、`select`、`date`、`daterange`,使用 `v-model` 管理查询模型,并监听 `search`、`reset`;额外操作放入 `actions` 插槽。不要在每个列表页重新编写筛选容器、查询/重置按钮和移动端换行样式。
- 业务数据列表统一使用 `src/shared/components/DataTable.vue`。通过 `columns` 和命名插槽定义列,使用 `v-model:page`、`v-model:page-size` 及 `total` 接入分页;无需分页时可省略 `total`。不得重复拼装 `el-table` 与分页器;“操作”列仍须遵守仓库根规则,放在第一列,固定时固定在左侧。
- 标准 XLSX 批量导入/上传统一使用 `src/shared/components/BatchImportDialog.vue`。页面只维护弹窗开关,传入标题、实体名称、模板文件名、`downloadTemplate` 和 `importFile`,并在 `success` 后刷新列表;文件格式和大小校验、模板下载、提交状态、接口错误、整批校验结果、CSV 错误报告及响应式样式由组件负责。不要在页面内重复创建文件输入框、上传状态、错误报告逻辑或导入弹窗 CSS。
- 普通模态框和侧滑编辑/详情优先使用 `src/shared/components/AppDialog.vue`、`AppDrawer.vue`,复用提交锁定、加载态、底部按钮和窄屏约束;仅当业务交互与其契约明显不同时直接使用 Element Plus 原始容器。
- 列表或详情加载失败优先组合 `src/shared/components/LoadErrorState.vue` 与 `ApiErrorMeta.vue`,保留重试入口及 `requestId`、`traceId`;状态展示和复制操作分别优先使用 `StatusBadge.vue`、`CopyButton.vue`。
- 公共组件的使用示例优先参考已接入页面:查询和表格见 `modules/master-data/express-companies/pages/ExpressCompaniesView.vue`,批量导入见该页面及 `modules/master-data/counterparties/pages/PartnersView.vue`,抽屉见 `modules/downloads/pages/DownloadCenterView.vue`。扩展公共组件时同步检查所有现有消费者,并至少运行组件测试和直接受影响页面测试。
## 本地命令与质量门禁
在 `apps/admin-web` 目录执行:
在 `apps/admin-web` 执行:
```bash
pnpm install
@@ -107,6 +125,7 @@ pnpm typecheck
pnpm build
```
- 日常变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`,后者也会执行类型检查。
- 当前项目未声明 lint 脚本,不要声称 lint 已通过。若任务新增或变更质量脚本,应同时更新 `package.json`、`README.md` 和本文件。
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目的 `package.json` 与锁文件。
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
+6 -3
View File
@@ -1,5 +1,8 @@
# 构建 Vue 静态文件
FROM node:22-alpine AS build
# 构建 Vue 静态文件。CI 通过构建参数切换到内网基础镜像,
# 本地开发仍可使用 Docker Hub 默认值。
ARG NODE_IMAGE=node:22-alpine
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NODE_IMAGE} AS build
WORKDIR /app
@@ -24,7 +27,7 @@ ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
RUN pnpm build
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
FROM nginx:1.27-alpine
FROM ${NGINX_IMAGE}
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
Vendored
+41 -22
View File
@@ -1,43 +1,62 @@
pipeline {
agent any
agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" }
options {
timestamps()
disableConcurrentBuilds()
}
stages {
stage('确认提交版本') {
stage('检查测试发布参数') {
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
sh 'echo "Jenkins 已拉取 release/test 分支代码"'
sh 'git log -1 --oneline'
}
}
stage('构建并部署测试前端') {
steps {
sh '''
set -eu
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --remove-orphans
script {
if (env.JOB_BASE_NAME?.endsWith('-dev')) {
error('此流水线仅支持 test 和本轮授权的 prod 灰度')
}
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
sh '''set -eu
export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}"
export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}"
: "${CLIENT_WEB_URL:?CLIENT_WEB_URL 未设置}"
docker network inspect "$TEST_NETWORK" >/dev/null
docker compose -f docker-compose.test.yml config --quiet
'''
}
}
stage('部署检查') {
stage('构建并部署测试前端') {
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
sh '''
set -eu
docker compose -f docker-compose.test.yml ps
sh '''set -eu
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
'''
}
}
stage('发布临时生产灰度') {
when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
lock(resource: 'firstleg-production-admin-web-deploy') {
withCredentials([
usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
sh 'sh scripts/deploy-production.sh'
}
}
}
}
}
post {
failure {
sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true'
script {
if (!env.JOB_BASE_NAME?.endsWith('-prod')) {
sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true'
}
}
}
}
}
+24 -5
View File
@@ -10,7 +10,7 @@ pnpm install
pnpm dev
```
生产构建:
发布资源构建:
```bash
pnpm typecheck
@@ -23,6 +23,14 @@ pnpm build
pnpm test
```
## 发布后的页面资源恢复
`src/core/routing/navigationRecovery.ts` 统一处理路由懒加载、Vite JS/CSS 预加载和 Vue 异步组件的资源加载失败。无未保存编辑时自动重新加载目标页面,保留路径、查询参数和页签;有未保存编辑时先确认,取消后继续编辑。普通接口错误和业务运行错误不触发刷新。
刷新记录写入当前标签页的 `sessionStorage`,30 秒内最多自动刷新一次,跨路由成功、重定向和整页刷新仍然保留,防止循环刷新。存储不可用或恢复后仍失败时提示手动刷新。`nginx/default.conf.template` 已对 HTML 禁用缓存,并让缺失的 `/assets/` 文件返回真实 404;使用额外 CDN 时也应保持 HTML 每次重新验证,参见 [Vite 资源加载错误处理](https://vite.dev/guide/build#load-error-handling)。
该机制随新版前端生效,已经打开且尚未加载本次改动的旧标签页需要先刷新一次。发布验收可在旧版页面保持打开的情况下发布新版,再打开尚未访问过的页面或异步弹窗,确认自动恢复以及未保存编辑的取消/确认行为。
## 源码结构
- `src/app/`:应用壳层、根路由和启动装配。
@@ -35,7 +43,7 @@ pnpm test
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。一期 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。订单与客户 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
@@ -45,12 +53,19 @@ pnpm test
```dotenv
VITE_API_BASE_URL=/api
API_PROXY_TARGET=http://8.163.30.128:9000
API_PROXY_TARGET=http://localhost:9000
VITE_CLIENT_WEB_URL=http://localhost:5174
```
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时设置容器环境变量 `CLIENT_WEB_URL`,例如 `http://8.163.30.128:9002`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发才使用 `VITE_CLIENT_WEB_URL` 作为兜底,当前 Client 默认端口为 `5174`。
全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。
唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。
容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
@@ -59,3 +74,7 @@ API_PROXY_TARGET=http://8.163.30.128:9000
真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。
登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。
同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
TEST 入口同时转发两个独立 PDA 静态应用:`/pda-domestic/` 到共享网络中的 `pda-domestic-h5-test:80`,`/pda-overseas/` 到 `pda-overseas-h5-test:80`。各自构建、镜像和静态文件由对应 PDA 项目维护,Admin 不打包 PDA 源码。请求时解析 Docker DNS,PDA 未启动不会阻止 Admin 启动。国内 H5 的 `/gateway-api/` 在此转换为同一 `API_UPSTREAM` 的 `/api/`,海外 H5 直接使用既有 `/api/`;所有业务认证与仓绑定仍由统一 Gateway 和服务端校验。发布时先启动 PDA 容器,再验收两个路径的 HTML、资产、登录、权限与缺失资产 404。H5 通过不能替代 Android 真机扫码验收。
+23
View File
@@ -0,0 +1,23 @@
name: firstleg-prod-admin-web
services:
admin-web:
image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}"
container_name: firstleg-prod-admin-web
restart: unless-stopped
ports:
- "127.0.0.1:${HOST_PORT:-29011}:80"
environment:
CLIENT_WEB_URL: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
API_UPSTREAM: "http://gateway:9000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
- backend
networks:
backend:
external: true
name: firstleg-production-network
+20 -9
View File
@@ -1,18 +1,29 @@
name: firstleg-test-admin-web
services:
admin-web:
build:
context: .
dockerfile: Dockerfile
image: admin-web-test:local
args:
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: admin-web-test
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
ports:
- "${HOST_PORT:-9001}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
- "127.0.0.1:${HOST_PORT:-9001}:80"
environment:
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
API_UPSTREAM: "http://host.docker.internal:9000"
CLIENT_WEB_URL: "${CLIENT_WEB_URL:?CLIENT_WEB_URL is required}"
API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}"
networks:
- test-backend
networks:
test-backend:
external: true
name: "${TEST_NETWORK:-firstleg-test-network}"
+2
View File
@@ -13,3 +13,5 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+41 -2
View File
@@ -6,6 +6,21 @@ server {
root /usr/share/nginx/html;
index index.html;
# Resolve independent PDA containers at request time; their restart must not block Admin startup.
resolver 127.0.0.11 valid=10s ipv6=off;
location = /pda-domestic { return 308 /pda-domestic/; }
location = /pda-overseas { return 308 /pda-overseas/; }
location ^~ /pda-domestic/ {
set $pda_domestic_origin http://pda-domestic-h5-test:80;
proxy_pass $pda_domestic_origin$request_uri;
proxy_set_header Host $proxy_host;
}
location ^~ /pda-overseas/ {
set $pda_overseas_origin http://pda-overseas-h5-test:80;
proxy_pass $pda_overseas_origin$request_uri;
proxy_set_header Host $proxy_host;
}
# Docker 容器运行正常的检查地址,供 Jenkins 使用。
location = /healthz {
default_type text/plain;
@@ -21,15 +36,39 @@ server {
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
location /api/ {
client_max_body_size 12m;
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
location /openapi/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Domestic PDA keeps its H5 module-safe prefix; authorization still belongs to the same Gateway.
location /gateway-api/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM}/api/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# HTML must be revalidated on every visit so a new deployment cannot keep
# referencing hashed chunks that were removed with the previous image.
location = /index.html {
+2
View File
@@ -8,12 +8,14 @@
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
"preview": "vite preview --port 4173"
},
"dependencies": {
"@chongchong/shared": "link:./shared",
"@lucide/vue": "^1.27.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"sortablejs": "^1.15.7",
+3
View File
@@ -17,6 +17,9 @@ importers:
axios:
specifier: ^1.18.1
version: 1.18.1(debug@4.4.3)
dayjs:
specifier: ^1.11.21
version: 1.11.21
element-plus:
specifier: ^2.14.3
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
set +x
: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}"
: "${TARGET_SSH_KEY:?}"
: "${TARGET_SSH_USER:?}"
: "${TARGET_SSH_HOST:?}"
: "${TARGET_SSH_KNOWN_HOSTS:?}"
: "${BUILD_REGISTRY_USER:?}"
: "${BUILD_REGISTRY_PASSWORD:?}"
: "${REGISTRY_USER:?}"
: "${REGISTRY_PASSWORD:?}"
case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac
# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters.
printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$'
printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$'
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
IMAGE_TAG=$(git rev-parse --short=12 HEAD)
registry=${PRODUCTION_IMAGE_REPOSITORY%%/*}
image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG"
TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST"
remote_dir=/data/firstleg/deploy/gray-admin-web
run_ssh() {
ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@"
}
run_scp() {
scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@"
}
export DOCKER_CONFIG
DOCKER_CONFIG=$(mktemp -d)
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
trap cleanup EXIT HUP INT TERM
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
--build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" .
docker push "$image"
run_ssh install -d -m 750 "$remote_dir"
run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml"
# The remote env file contains only image coordinates and public web origin, never credentials.
printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \
"$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'"
# Registry credentials exist only in an isolated temporary Docker config, removed after pull.
printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$'
run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh"
printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image"
run_ssh sh -s -- "$remote_dir" <<'REMOTE'
set -eu
cd "$1"
docker network inspect firstleg-production-network >/dev/null
docker compose --env-file release.env -f compose.yml config --quiet
docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120
docker compose --env-file release.env -f compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
REMOTE
@@ -0,0 +1,37 @@
import { spawnSync } from 'node:child_process'
function option(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : ''
}
function normalizeOrigin(value, name) {
if (!value) throw new Error(`缺少 ${name}`)
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/') {
throw new Error(`${name} 必须是无账号、无路径的 HTTP(S) origin`)
}
return url.origin
}
const environment = option('--environment')
if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test')
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway')
const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD')
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], {
cwd: process.cwd(),
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
ADMIN_PHASE4_LIVE: '1',
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
ADMIN_ACCEPTANCE_PASSWORD: password,
ADMIN_TEST_GATEWAY: gateway,
VITE_API_BASE_URL: `${gateway}/api`,
},
})
if (result.error) throw result.error
process.exit(result.status ?? 1)
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8')
const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8')
test('test runner requires an explicit test environment and unified gateway', () => {
assert.match(runner, /--environment/)
assert.match(runner, /environment !== 'test'/)
assert.match(runner, /--gateway/)
assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/)
assert.match(runner, /shell: process\.platform === 'win32'/)
assert.match(runner, /if \(result\.error\) throw result\.error/)
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
})
test('live suite uses production auth clients and domain adapters', () => {
assert.match(suite, /useAuthStore/)
assert.match(suite, /refreshAuthenticatedSession/)
assert.match(suite, /createQuote/)
assert.match(suite, /createUsTruckOrder/)
assert.match(suite, /createLocalLooseboardOrder/)
assert.match(suite, /listLastMileOrders/)
assert.match(suite, /listDispatchCandidates/)
assert.match(suite, /listCarrierScheduleQueries/)
})
test('live suite owns marker resources and redacts credentials from trace reports', () => {
assert.match(suite, /ADMIN-FRONT-P4-/)
assert.match(suite, /password\|token\|authorization/i)
assert.match(suite, /createdResources/)
assert.match(suite, /coverageGaps/)
})
+395
View File
@@ -0,0 +1,395 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import { mkdirSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { defineComponent, nextTick } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { http, ApiResponseError } from '@/shared/api/http'
import { refreshAuthenticatedSession } from '@/shared/api/http'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
import {
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getQuote, getVehicle, listExchangeRates,
listQuoteWarehouseOptions, listQuotes, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
} from '@/modules/overseas-master-data/api/overseasMasterData'
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateUsTruckOrder,
} from '@/modules/overseas-business/api/platformOverseasBusiness'
import { listLastMileOrders, getLastMileOrder } from '@/modules/overseas-warehouse/api/lastMileOrders'
import { listInboundOrders } from '@/modules/overseas-warehouse/api/inboundOrders'
import { getInboundPlan } from '@/modules/overseas-warehouse/api/inboundPlans'
import { listDispatchCandidates, listDispatchDrafts, listDispatches } from '@/modules/overseas-warehouse/api/dispatches'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '@/modules/overseas-warehouse/api/inventory'
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '@/modules/overseas-warehouse/api/pallets'
import { listRelabelOrders } from '@/modules/overseas-warehouse/api/relabelOrders'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '@/modules/port-schedules/api/platformCarrierScheduleQueries'
import OverseasQuotesView from '@/modules/overseas-master-data/pages/OverseasQuotesView.vue'
import PurchaseOrdersView from '@/modules/overseas-business/pages/PurchaseOrdersView.vue'
import TransportOrdersView from '@/modules/overseas-business/pages/TransportOrdersView.vue'
import LastMileOrdersView from '@/modules/overseas-warehouse/pages/LastMileOrdersView.vue'
import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersView.vue'
import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue'
import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue'
import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue'
import OutboundsView from '@/modules/overseas-warehouse/pages/OutboundsView.vue'
import DeliveryOrdersView from '@/modules/overseas-warehouse/pages/DeliveryOrdersView.vue'
import ExceptionOrdersView from '@/modules/overseas-warehouse/pages/ExceptionOrdersView.vue'
import CustomerVisitsView from '@/modules/overseas-warehouse/pages/CustomerVisitsView.vue'
import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/InventoryTransactionsView.vue'
import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
type Region = 'unified'
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
interface CoverageGap { area: string; reason: string }
const enabled = process.env.ADMIN_PHASE4_LIVE === '1'
const marker = `ADMIN-FRONT-P4-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`
const traces: TraceStep[] = []
const coverageGaps: CoverageGap[] = []
const createdResources: Record<string, unknown> = {}
const wrappers: VueWrapper[] = []
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, /password|token|authorization/i.test(key) ? '[REDACTED]' : redact(item)]))
}
function parsed(value: unknown) {
if (typeof value !== 'string') return value
try { return JSON.parse(value) } catch { return value }
}
function envelope(value: unknown) {
const body = parsed(value) as { code?: unknown; requestId?: unknown; traceId?: unknown } | null
return body && typeof body === 'object' ? body : null
}
function installTracingAdapter(client: AxiosInstance, region: Region) {
const nodeAdapter = axios.getAdapter('http')
client.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
try {
const response = await nodeAdapter(config)
const body = envelope(response.data)
traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: response.status, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) })
return response
} catch (error) {
if (axios.isAxiosError(error)) {
const body = envelope(error.response?.data)
traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: error.response?.status || 0, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) })
}
throw error
}
}
}
function gap(area: string, reason: string) { coverageGaps.push({ area, reason }) }
function numericId(value: unknown) { return /^[1-9]\d*$/.test(String(value ?? '')) }
function expectPage(value: { records: unknown[]; page: number; size: number; total: number; pages: number }) {
expect(Array.isArray(value.records)).toBe(true); expect(value.page).toBeGreaterThanOrEqual(1); expect(value.size).toBeGreaterThan(0); expect(value.total).toBeGreaterThanOrEqual(value.records.length); expect(value.pages).toBeGreaterThanOrEqual(0)
}
async function expectConflict(action: () => Promise<unknown>) {
await expect(action()).rejects.toSatisfy((error: unknown) => {
if (error instanceof ApiResponseError) return error.code.endsWith('409')
if (!axios.isAxiosError(error)) return false
const response = parsed(error.response?.data) as { code?: unknown } | undefined
return error.response?.status === 409 || String(response?.code || '').endsWith('409')
})
}
function futureDate(days: number) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) }
async function masterDataLifecycle() {
const options = await listQuoteWarehouseOptions()
const quotePage = await listQuotes({ page: 1, size: 20 })
expectPage(quotePage)
if (options.length) {
const quote = await createQuote({ name: `${marker} 报价`, warehouseId: options[0].warehouseId, providerName: `${marker} Provider`, currency: 'USD', effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', versionCode: marker.slice(-32), warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: marker }], transitPrices: [], remark: marker })
const id = quote.summary.id
createdResources.quote = id
let detail = await getQuote(id)
const stale = detail.summary.recordVersion
detail = await updateQuote(id, { recordVersion: stale, name: `${marker} 报价-编辑`, providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: `${marker} edited` })
await expectConflict(() => updateQuote(id, { recordVersion: stale, name: 'STALE', providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: marker }))
detail = await changeQuoteStatus(id, 'ACTIVE', detail.summary.recordVersion)
expect(detail.summary.status).toBe('ACTIVE')
expect((await exportQuotes({ keyword: marker })).size).toBeGreaterThan(0)
detail = await changeQuoteStatus(id, 'INACTIVE', detail.summary.recordVersion)
expect(detail.summary.status).toBe('INACTIVE')
} else gap('海外报价', 'warehouse-options 为空,未猜测仓库 ID,跳过报价写生命周期')
let exchangeMonth = ''
for (let index = 0; index < 24 && !exchangeMonth; index += 1) {
const month = `${2097 + Math.floor(index / 12)}-${String(index % 12 + 1).padStart(2, '0')}`
const page = await listExchangeRates({ page: 1, size: 20, monthFrom: month, monthTo: month })
if (!page.total) exchangeMonth = month
}
if (!exchangeMonth) throw new Error('安全汇率月份池已占满')
let rate = await createExchangeRate({ month: exchangeMonth, usdToCnyAverage: 7.123456, remark: marker })
createdResources.exchangeRate = exchangeMonth
const staleRate = rate.recordVersion
rate = await updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.234567, remark: `${marker} edited` })
await expectConflict(() => updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.9, remark: 'STALE' }))
rate = await getExchangeRate(exchangeMonth)
rate = await changeExchangeRateStatus(exchangeMonth, 'INACTIVE', rate.recordVersion, `${marker} cleanup`)
expect(rate.status).toBe('INACTIVE')
expect((await exportExchangeRates({ monthFrom: exchangeMonth, monthTo: exchangeMonth })).size).toBeGreaterThan(0)
let vehicle = await createVehicle({ plateNumber: `QA-${marker.slice(-18)}`, model: 'Phase4 Front QA', fuelConsumptionPer100Km: 18.5, mileageKm: 100, driverOrDispatchCompany: marker, phone: '+1-213-555-0188', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: marker })
createdResources.vehicle = vehicle.id
const staleVehicle = vehicle.recordVersion
vehicle = await updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'Phase4 Front QA Edited', fuelConsumptionPer100Km: 18.75, mileageKm: 101, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: `${marker} edited` })
await expectConflict(() => updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'STALE', fuelConsumptionPer100Km: 19, mileageKm: 102, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: 'STALE' }))
vehicle = await getVehicle(vehicle.id)
vehicle = await changeVehicleStatus(vehicle.id, 'INACTIVE', vehicle.recordVersion)
expect(vehicle.status).toBe('INACTIVE')
expect((await exportVehicles({ keyword: vehicle.plateNumber })).size).toBeGreaterThan(0)
expectPage(await listVehicles({ page: 1, size: 20, keyword: marker }))
}
async function cleanupTransport(path: string, customerId: string, version: number) {
await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
}
async function overseasBusinessLifecycle() {
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
const active = customers.find(item => numericId(item.id))
if (!active) { gap('卡车/散板', 'IAM 无 ACTIVE 客户'); return }
const customerId = String(active.id)
expect(Array.isArray(await listOverseasPurchaseCustomerOptions())).toBe(true)
expect(Array.isArray(await listOverseasPurchaseWarehouseOptions())).toBe(true)
expectPage(await listOverseasPurchases({ page: 1, size: 20 }))
const common = { origin: `${marker}-ORIGIN`, originAddress: 'Long Beach', destination: `${marker}-DEST`, destinationAddress: 'Ontario', containerType: '40HQ', packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, currency: 'USD', contactName: '四期前端验收', contactInfo: '13800000000', requestedDate: futureDate(7), remark: marker }
let truck = await createUsTruckOrder(customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, idempotencyKey: `${marker}-truck` })
createdResources.truck = truck.orderNo
try {
truck = await getUsTruckOrder(truck.orderNo, customerId)
const stale = truck.version
truck = await updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: `${marker}-DEST-EDIT`, version: stale })
await expectConflict(() => updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: 'STALE', version: stale }))
} finally {
truck = await getUsTruckOrder(truck.orderNo, customerId)
if (truck.status === 'PENDING_REVIEW') await cleanupTransport(`/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(truck.orderNo)}`, customerId, truck.version)
}
expectPage(await listUsTruckOrders({ page: 1, size: 20, customerId }))
let loose = await createLocalLooseboardOrder(customerId, { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', ...common, idempotencyKey: `${marker}-looseboard` })
createdResources.looseboard = loose.orderNo
try {
loose = await getLocalLooseboardOrder(loose.orderNo, customerId)
const stale = loose.version
loose = await updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: `${marker}-DEST-EDIT`, version: stale })
await expectConflict(() => updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: 'STALE', version: stale }))
} finally {
loose = await getLocalLooseboardOrder(loose.orderNo, customerId)
if (loose.status === 'PENDING_ACCEPTANCE') await cleanupTransport(`/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(loose.orderNo)}`, customerId, loose.version)
}
expectPage(await listLocalLooseboardOrders({ page: 1, size: 20, customerId }))
}
async function overseasWarehouseCoverage() {
const lastMile = await listLastMileOrders({ page: 1, size: 20 }); expectPage(lastMile)
if (lastMile.records[0]) await getLastMileOrder(String(lastMile.records[0].id))
const inbound = await listInboundOrders({ page: 1, size: 20 }); expectPage(inbound)
if (inbound.records[0]) await getInboundPlan(inbound.records[0].planNo, inbound.records[0].customerId)
expectPage(await listDispatches({ page: 1, size: 20 }))
expect(Array.isArray(await listDispatchCandidates({}))).toBe(true)
expect(Array.isArray(await listDispatchDrafts())).toBe(true)
const inventoryQuery = { page: 1, size: 20 }
const balances = await listInventoryBalances(inventoryQuery); expectPage(balances)
await getInventorySummary(inventoryQuery)
await listInventoryLedger({ page: 1, size: 20 })
if (balances.records[0]) await listInventoryPositions(String(balances.records[0].id), 1, 20)
expectPage(await listRelabelOrders({ page: 1, size: 20 }))
const warehouses = (await listCustomerWarehouses()).records.filter(item => item.status === 'ACTIVE')
if (!warehouses.length) { gap('公共托盘', '实际海外仓为空,跳过前端托盘写生命周期'); return }
const warehouse = warehouses[0]
const payload = { palletCode: `QA-${marker.slice(-20)}`, palletType: 'STANDARD', fbaWarehouseCode: 'ONT8', capacityCbm: 2.5, unitPrice: 10, status: 'IDLE', remark: marker }
const palletId = await createPallet(warehouse.id, payload)
createdResources.pallet = palletId
try {
const pallet = await getPallet(warehouse.id, palletId)
await updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 2.75, remark: `${marker} edited`, version: pallet.version })
await expectConflict(() => updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 3, remark: 'STALE', version: pallet.version }))
expectPage(await listPallets(warehouse.id, { page: 1, size: 20, keyword: payload.palletCode }))
}
finally {
const current = await getPallet(warehouse.id, palletId)
if (current.status !== 'SCRAPPED') await updatePallet(warehouse.id, palletId, { ...payload, status: 'SCRAPPED', remark: `${marker} cleanup`, version: current.version })
}
gap('FBA预约/智能预排/换标新增', '没有本轮 marker 入仓计划和托盘候选;为避免操作历史数据,本轮只自动覆盖列表、详情、候选和草稿查询')
}
async function carrierCoverage() {
const page = await listCarrierScheduleQueries({ page: 1, size: 20 })
expectPage(page)
if (page.records[0]) await getCarrierScheduleQuery(page.records[0].query.id)
else gap('船节点人工补录', '环境无真实查询任务;未创建或修改历史任务')
}
async function waitForTrace(method: string, path: string, start: number) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
if (traces.slice(start).some(item => item.method === method && item.path.startsWith(path))) return
await new Promise(resolve => setTimeout(resolve, 50))
}
throw new Error(`页面未发出 ${method} ${path}`)
}
async function mountPage(component: Parameters<typeof mount>[0], method: string, path: string, props?: Record<string, unknown>) {
const start = traces.length
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '<div />' }) }] })
await router.push('/'); await router.isReady()
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [ElementPlus, router, setActivePinia(createPinia())] } })
wrappers.push(wrapper)
await waitForTrace(method, path, start)
await nextTick()
expect(wrapper.text()).not.toContain('服务暂时不可用')
return wrapper
}
async function exerciseListSearch(
wrapper: VueWrapper,
path: string,
model: Record<string, unknown>,
expectedParams: Record<string, unknown>,
) {
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.exists()).toBe(true)
toolbar.vm.$emit('update:modelValue', { ...model })
await nextTick()
let start = traces.length
toolbar.vm.$emit('search', { ...model })
await waitForTrace('GET', path, start)
const searchRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
expect(searchRequests).toHaveLength(1)
expect(searchRequests[0]?.httpStatus).toBe(200)
expect(searchRequests[0]?.params).toMatchObject(expectedParams)
expect(wrapper.text()).not.toContain('加载失败')
start = traces.length
;(toolbar.vm as unknown as { reset(): void }).reset()
await waitForTrace('GET', path, start)
const resetRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
expect(resetRequests).toHaveLength(1)
expect(resetRequests[0]?.httpStatus).toBe(200)
const resetParams = resetRequests[0]?.params as Record<string, unknown> | undefined
for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true)
}
async function pageRequestCoverage() {
const quotes = await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
await exerciseListSearch(quotes, '/platform/v1/last-leg/master-data/overseas-quotes',
{ keyword: `${marker}-NO-MATCH`, warehouseId: '', providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' },
{ keyword: `${marker}-NO-MATCH`, providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' })
const purchases = await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
await exerciseListSearch(purchases, '/platform/v1/last-leg/overseas-procurements',
{ orderNo: `${marker}-NO-MATCH`, customerId: '', purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` },
{ orderNo: `${marker}-NO-MATCH`, purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` })
const trucks = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
await exerciseListSearch(trucks, '/platform/v1/last-leg/us-truck-orders',
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
const looseboards = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
await exerciseListSearch(looseboards, '/platform/v1/last-leg/local-looseboard-orders',
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
const lastMile = await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
await exerciseListSearch(lastMile, '/platform/v1/last-leg/orders',
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2098-01-01', '2098-01-02'] },
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, currentStatus: 'DELIVERED', abnormal: true, detained: false, createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
let start = traces.length
await lastMile.findAll('.el-radio-button').find(item => item.text() === '待分配')!.trigger('click')
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
start = traces.length
await lastMile.findAll('.el-radio-button').find(item => item.text().startsWith('柜子'))!.trigger('click')
await waitForTrace('GET', '/platform/v1/last-leg/orders/container-summaries', start)
start = traces.length
await lastMile.findAll('.el-radio-button').find(item => item.text() === '全部')!.trigger('click')
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
const inbounds = await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
await exerciseListSearch(inbounds, '/platform/v1/last-leg/inbound-plans',
{ orderNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouse: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', inboundDate: ['2098-01-01', '2098-01-02'] },
{ planNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouseCode: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
const inventory = await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
await exerciseListSearch(inventory, '/platform/v1/last-leg/inventory/balances',
{ warehouseId: '', owner: `${marker}-OWNER`, sku: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, location: `${marker}-LOCATION`, status: 'AVAILABLE' },
{ customerName: `${marker}-OWNER`, skuCode: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, warehouseLocationCode: `${marker}-LOCATION`, inventoryStatus: 'AVAILABLE' })
const dispatches = await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
await exerciseListSearch(dispatches, '/platform/v1/last-leg/dispatches',
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` },
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` })
const relabels = await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
await exerciseListSearch(relabels, '/platform/v1/last-leg/relabel-applications',
{ relabelNo: `${marker}-RELABEL`, sku: `${marker}-SKU`, status: 'CANCELLED' },
{ relabelNo: `${marker}-RELABEL`, skuCode: `${marker}-SKU`, status: 'CANCELLED' })
const outbounds = await mountPage(OutboundsView, 'GET', '/platform/v1/last-leg/outbound-orders')
await exerciseListSearch(outbounds, '/platform/v1/last-leg/outbound-orders',
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' },
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' })
const deliveries = await mountPage(DeliveryOrdersView, 'GET', '/platform/v1/last-leg/delivery-orders')
await exerciseListSearch(deliveries, '/platform/v1/last-leg/delivery-orders',
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, warehouseId: '', status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedAt: ['2098-01-01', '2098-01-02'] },
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedFrom: '2098-01-01T00:00:00', plannedTo: '2098-01-02T23:59:59' })
const exceptions = await mountPage(ExceptionOrdersView, 'GET', '/platform/v1/last-leg/exception-orders')
await exerciseListSearch(exceptions, '/platform/v1/last-leg/exception-orders',
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' },
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' })
const visits = await mountPage(CustomerVisitsView, 'GET', '/platform/v1/last-leg/customer-visits')
await exerciseListSearch(visits, '/platform/v1/last-leg/customer-visits',
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedAt: ['2098-01-01', '2098-01-02'] },
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedFrom: '2098-01-01T00:00:00', completedTo: '2098-01-02T23:59:59' })
const ledger = await mountPage(InventoryTransactionsView, 'GET', '/platform/v1/last-leg/inventory/ledger')
await exerciseListSearch(ledger, '/platform/v1/last-leg/inventory/ledger',
{ occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` },
{ customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' })
const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets')
await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets',
{ warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' },
{ keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' })
}
describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => {
beforeAll(async () => {
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
installTracingAdapter(http, 'unified')
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(pinia)
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
await refreshAuthenticatedSession()
await listVehicles({ page: 1, size: 1 })
expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true)
expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true)
})
it('runs marker-owned adapter lifecycles and real page requests', async () => {
await masterDataLifecycle()
await overseasBusinessLifecycle()
await overseasWarehouseCoverage()
await carrierCoverage()
await pageRequestCoverage()
expect(traces.length).toBeGreaterThan(20)
})
afterAll(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
const reportDir = resolve(process.cwd(), 'reports')
mkdirSync(reportDir, { recursive: true })
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
})
})
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
set +x
export DOCKER_CONFIG
DOCKER_CONFIG=$(mktemp -d)
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
trap cleanup EXIT HUP INT TERM
docker login -u "$1" --password-stdin "$2"
docker pull "$3"
+7 -7
View File
@@ -13,18 +13,18 @@
- 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。
- 避免建立宽泛的万能类型或引入重量级依赖。
## 一期/二期/三期共享模型索引
## 订单与客户/入库测量与计费/国内货柜履约共享模型索引
- 一期共享消费者:`customers`、`iam`、`warehouses`、`master-data`、`first-leg-orders`;稳定导出集中在 `src/index.ts`(用户、导航、响应、仓库摘要、订单状态)。
- 二期共享消费者:`inbounds`、`warehouse-resources`、`fba-pricing`、`dimension-config`、`unified-orders`;单模块使用的测量、费用、托盘、库位类型留在模块内,只有真实跨模块复用才新增导出。
- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Admin 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。订单、柜次、证据和报关的单模块类型仍留在所属模块,不在 shared 预留猜测字段。
- 订单与客户共享消费者:`customers`、`iam`、`warehouses`、`master-data`、`first-leg-orders`;稳定导出集中在 `src/index.ts`(用户、导航、响应、仓库摘要、订单状态)。
- 入库测量与计费共享消费者:`inbounds`、`warehouse-resources`、`fba-pricing`、`dimension-config`、`unified-orders`;单模块使用的测量、费用、托盘、库位类型留在模块内,只有真实跨模块复用才新增导出。
- 国内货柜履约仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Admin 模块复用时才新增共享导出;先读 `../../../docs/国内货柜履约/国内货柜履约开工门禁.md`。订单、柜次、证据和报关的单模块类型仍留在所属模块,不在 shared 预留猜测字段。
定位规则:先在 `src/index.ts` 确认公共导出,再搜索 `src/` 消费者;后端字段或状态有疑问时回到对应 Controller/DTO/OpenAPI,不在 shared 中猜测业务规则。
阶段依据(路径相对本文件所在目录):一期 `../../../docs/一期/`,二期 `../../../docs/二期/`,三期 `../../../docs/三期/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
阶段依据(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/`,入库测量与计费 `../../../docs/入库测量与计费/`,国内货柜履约 `../../../docs/国内货柜履约/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
## 兼容性与验证
## 契约与验证
- 修改导出名称、字段类型、枚举值或常量语义前,搜索当前应用内全部消费者。
- 优先采用兼容性新增;破坏性修改必须同步更新当前应用消费者。
- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。
- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
describe('Admin Element Plus locale', () => {
it('provides simplified Chinese globally', () => {
const source = readFileSync(join(process.cwd(), 'src/App.vue'), 'utf8')
expect(zhCn.name).toBe('zh-cn')
expect(source).toContain("import zhCn from 'element-plus/es/locale/lang/zh-cn'")
expect(source).toContain('<ElConfigProvider :locale="zhCn">')
expect(source).toMatch(/<ElConfigProvider[\s\S]*<RouterView \/>[\s\S]*<\/ElConfigProvider>/)
})
})
+9 -3
View File
@@ -1,4 +1,10 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<template>
<ElConfigProvider :locale="zhCn">
<RouterView />
</ElConfigProvider>
</template>
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const pages = [
['src/modules/container-loading/pages/ContainersView.vue', "key: 'cabinetNo'"],
['src/modules/loading-lists/components/LoadingListsView.vue', "key: 'preloadNo'"],
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', "prop: 'bookingNo'"],
['src/modules/port-schedules/pages/PortSchedulesView.vue', "prop: 'schedule.scheduleNo'"],
['src/modules/trucking/pages/TruckAppointmentsView.vue', "key: 'appointmentNo'"],
['src/modules/customs/pages/CustomsCasesView.vue', "prop: 'customsNo'"],
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', "prop: 'inboundCode'"],
] as const
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8')
}
describe('domestic outbound list responsive contracts', () => {
it.each(pages)('%s keeps the operation column first and fixed left', (path, firstBusinessColumn) => {
const content = source(path)
const firstAction = content.indexOf("label: '操作'")
expect(firstAction).toBeGreaterThan(-1)
expect(firstAction).toBeLessThan(content.indexOf(firstBusinessColumn, firstAction))
const actionObjects = [...content.matchAll(/\{[^{}]*label: '操作'[^{}]*\}/g)].map((match) => match[0])
expect(actionObjects.length).toBeGreaterThan(0)
for (const column of actionObjects) expect(column).toContain("fixed: 'left'")
})
it.each(pages)('%s gives every business column a stable width constraint', (path) => {
const content = source(path)
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
.map((match) => match[0])
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'"))
expect(columns.length).toBeGreaterThan(1)
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
})
it('keeps shared 320px degradation rules for filters, actions and pagination', () => {
const queryToolbar = source('src/shared/components/QueryToolbar.vue')
const dataTable = source('src/shared/components/DataTable.vue')
const listLayout = source('src/shared/components/ListPageLayout.vue')
expect(queryToolbar).toContain('@media (max-width: 720px)')
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
expect(dataTable).toContain('@media (max-width: 720px)')
expect(dataTable).toContain('flex-direction: column')
expect(dataTable).toContain('.data-table__page-buttons { flex-wrap: wrap; }')
expect(listLayout).toContain('min-width: 0')
})
})
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const pages = [
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', "{{ row.status === 'DRAFT' ? '编辑' : '查看' }}", '@retry="load"'],
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '@click="openDetail(row as TruckAppointmentResource)">详情</el-button>', '@retry="load"'],
] as const
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8')
}
describe('domestic outbound list state contracts', () => {
it.each(pages)('%s exposes loading, error, empty, translated status, detail action and retry states', (path, loading, error, empty, status, detailAction, retry) => {
const content = source(path)
expect(content).toContain(loading)
expect(content).toContain(error)
expect(content).toContain(empty)
expect(content).toContain(status)
expect(content).toContain(detailAction)
expect(content).toContain(retry)
})
it('keeps list component state when route details return to the cached workspace list', () => {
const layout = source('src/app/layouts/AppLayout.vue')
const oceanDetail = source('src/modules/ocean-bookings/pages/OceanBookingDetailView.vue')
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
})
})
+55 -11
View File
@@ -1,4 +1,6 @@
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenu from './AdminSidebarMenu.vue'
@@ -19,10 +21,12 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
]))
const stubs = {
'el-menu': {
props: ['defaultActive', 'collapse'],
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
},
'el-menu': defineComponent({
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
emits: ['select'],
methods: { updateActiveIndex() {} },
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)" @click="$emit(\'select\', \'/warehouses\')"><slot /></nav>',
}),
'el-sub-menu': {
props: ['index'],
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
@@ -34,23 +38,37 @@ const stubs = {
}
describe('AdminSidebarMenu', () => {
function routerPlugin() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<p />' } },
{ path: '/warehouses', component: { template: '<p />' } },
],
})
}
it('renders the visible phase-one modules and forwards the active route', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
expect(wrapper.get('nav').attributes('data-openeds')).toBe('[]')
const groupTitles = wrapper.findAll('[data-kind="group"] header').map((node) => node.text())
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
expect(groupTitles).toContain('客户管理')
expect(groupTitles).toContain('供应商中心')
expect(groupTitles).not.toContain('供应商中心')
expect(groupTitles).toContain('通用')
expect(groupTitles).toContain('系统管理')
expect(groupTitles).not.toContain('预约管理')
expect(groupTitles).not.toContain('财务管理')
expect(paths).not.toContain('/finance/customer-accounts')
expect(paths).toContain('/dashboard')
expect(paths).toContain('/orders')
expect(paths).toContain('/master-data/express-companies')
expect(paths).not.toContain('/master-data/express-companies')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
@@ -61,7 +79,7 @@ describe('AdminSidebarMenu', () => {
it('forwards desktop collapse state to Element Plus', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/', collapsed: true },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
@@ -71,7 +89,7 @@ describe('AdminSidebarMenu', () => {
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
const wrapper = mount(AdminSidebarMenu, {
props: { items, activePath: '/', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.text()).toContain('菜单权限')
@@ -99,10 +117,36 @@ describe('AdminSidebarMenu', () => {
}]
const wrapper = mount(AdminSidebarMenu, {
props: { items, activePath: '/tracking', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking')
})
it('recreates the menu when asynchronously loaded groups change', async () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
global: { stubs, plugins: [routerPlugin()] },
})
const initialMenu = wrapper.get('nav').element
await wrapper.setProps({ items: visibleNavigation })
expect(wrapper.get('nav').element).not.toBe(initialMenu)
expect(wrapper.text()).toContain('系统管理')
})
it('navigates explicitly when a leaf menu item is selected', async () => {
const router = routerPlugin()
await router.push('/')
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/', collapsed: false },
global: { stubs, plugins: [router] },
})
await wrapper.get('nav').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/warehouses')
})
})
+30 -3
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter, type NavigationFailure } from 'vue-router'
import type { MenuInstance } from 'element-plus'
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
@@ -8,6 +10,8 @@ const props = defineProps<{
activePath: string
collapsed: boolean
}>()
const router = useRouter()
const menuRef = ref<MenuInstance>()
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
return nodes.flatMap((node) => node.children?.length
@@ -15,16 +19,39 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
: [])
}
const defaultOpeneds = computed(() => getGroupKeys(props.items))
const groupKeys = computed(() => getGroupKeys(props.items))
const defaultOpeneds: string[] = []
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
async function restoreActivePath() {
await nextTick()
menuRef.value?.updateActiveIndex(props.activePath)
}
async function navigate(path: string) {
if (!path.startsWith('/') || path === props.activePath) return
let failure: NavigationFailure | void
try {
failure = await router.push(path)
} catch {
await restoreActivePath()
return
}
if (failure) await restoreActivePath()
}
watch(() => props.activePath, restoreActivePath)
</script>
<template>
<el-menu
ref="menuRef"
:key="menuInstanceKey"
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
:default-openeds="defaultOpeneds"
router
@select="navigate"
>
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
</el-menu>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -32,7 +33,7 @@ async function confirmDiscard(tabIds: readonly string[]) {
await ElMessageBox.confirm(
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
'放弃未保存的修改?',
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning' },
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
return true
} catch {
+84 -7
View File
@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import AppLayout from './AppLayout.vue'
@@ -26,7 +26,7 @@ async function mountLayout(path = '/') {
const auth = useAuthStore()
auth.token = 'token'
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
auth.permissions = ['platform:warehouse:list']
auth.permissions = ['platform:warehouse:list', 'platform:notification:task:list', 'platform:notification:template:list']
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -39,18 +39,95 @@ async function mountLayout(path = '/') {
await router.push(path)
await router.isReady()
const wrapper = mount(AppLayout, {
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-dropdown': { template: '<div><slot /><slot name="dropdown" /></div>' }, 'el-dropdown-menu': { template: '<div><slot /></div>' }, 'el-dropdown-item': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
})
return { wrapper, router }
}
describe('AppLayout', () => {
beforeEach(() => vi.mocked(ElMessageBox.confirm).mockReset())
function mockViewport(isMobile: boolean) {
const mediaQuery = {
matches: isMobile,
media: '(max-width: 720px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList
vi.stubGlobal('matchMedia', vi.fn(() => mediaQuery))
}
it('shows the authenticated identity without a role preview switcher', async () => {
describe('AppLayout', () => {
beforeEach(() => {
window.localStorage.clear()
vi.mocked(ElMessageBox.confirm).mockReset()
mockViewport(false)
})
afterEach(() => vi.unstubAllGlobals())
it('reports the desktop navigation expanded and collapsed states', async () => {
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
expect(menuButton.attributes('aria-label')).toBe('收起导航')
expect(menuButton.attributes('aria-expanded')).toBe('true')
await menuButton.trigger('click')
expect(menuButton.attributes('aria-label')).toBe('展开导航')
expect(menuButton.attributes('aria-expanded')).toBe('false')
})
it('reports the mobile menu closed and open states', async () => {
mockViewport(true)
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
expect(menuButton.attributes('aria-label')).toBe('打开菜单')
expect(menuButton.attributes('aria-expanded')).toBe('false')
await menuButton.trigger('click')
expect(menuButton.attributes('aria-label')).toBe('关闭菜单')
expect(menuButton.attributes('aria-expanded')).toBe('true')
})
it('closes the mobile menu when returning to the desktop breakpoint and removes the listener', async () => {
mockViewport(true)
const { wrapper } = await mountLayout()
const menuButton = wrapper.get('.shell-menu-button')
const mediaQuery = vi.mocked(window.matchMedia).mock.results[0]?.value as MediaQueryList
const changeHandler = vi.mocked(mediaQuery.addEventListener).mock.calls[0]?.[1] as ((event: MediaQueryListEvent) => void)
await menuButton.trigger('click')
expect(menuButton.attributes('aria-expanded')).toBe('true')
changeHandler({ matches: false } as MediaQueryListEvent)
await wrapper.vm.$nextTick()
expect(menuButton.attributes('aria-label')).toBe('收起导航')
expect(menuButton.attributes('aria-expanded')).toBe('true')
wrapper.unmount()
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler)
})
it('shows the authenticated identity and the localhost full-access control', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.text()).not.toContain('权限预览')
const accessToggle = wrapper.get('[role="switch"]')
expect(accessToggle.text()).toContain('本地全权限')
expect(accessToggle.text()).toContain('已关闭')
expect(accessToggle.attributes('aria-checked')).toBe('false')
await accessToggle.trigger('click')
expect(accessToggle.text()).toContain('已开启')
expect(accessToggle.attributes('aria-checked')).toBe('true')
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
expect(wrapper.text()).toContain('我的消息')
const notificationTrigger = wrapper.get('.notification-trigger')
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
})
it('hides the shell heading when the route opts out', async () => {
+74 -8
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { LogOut, Menu } from '@lucide/vue'
import { Bell, FileText, LogOut, Mail, Menu, ShieldCheck } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
@@ -20,12 +21,27 @@ const auth = useAuthStore()
const access = useAccessPreviewStore()
const unsaved = useUnsavedWorkStore()
const workspace = useWorkspaceTabsStore()
const mobileViewportQuery = '(max-width: 720px)'
const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const isMobileViewport = ref(false)
let mobileViewportMedia: MediaQueryList | undefined
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
const menuButtonLabel = computed(() => {
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
return collapsed.value ? '展开导航' : '收起导航'
})
const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
access.can('platform:notification:view')
|| access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
access.initializeAfterLogin()
workspace.initialize(auth.user?.id || auth.user?.account || '')
@@ -42,7 +58,7 @@ async function pruneInaccessibleTabs() {
await ElMessageBox.alert(
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
'权限已变化',
{ confirmButtonText: '知道了', type: 'warning' },
{ confirmButtonText: '知道了', type: 'warning', customClass: 'admin-density-confirm' },
).catch(() => undefined)
}
const previousActiveId = workspace.activeId
@@ -61,6 +77,7 @@ async function handleLogout() {
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
confirmButtonText: '退出',
customClass: 'admin-density-confirm',
cancelButtonText: '取消',
})
} catch {
@@ -85,13 +102,27 @@ async function handleLogout() {
}
function handleMenuToggle() {
if (window.matchMedia('(max-width: 720px)').matches) {
if (isMobileViewport.value) {
mobileMenuOpen.value = !mobileMenuOpen.value
return
}
collapsed.value = !collapsed.value
}
function syncMobileViewport(event: MediaQueryList | MediaQueryListEvent) {
isMobileViewport.value = event.matches
if (!event.matches) mobileMenuOpen.value = false
}
function openNotificationPage(path: string) { void router.push(path) }
onMounted(() => {
mobileViewportMedia = window.matchMedia(mobileViewportQuery)
syncMobileViewport(mobileViewportMedia)
mobileViewportMedia.addEventListener('change', syncMobileViewport)
})
onBeforeUnmount(() => mobileViewportMedia?.removeEventListener('change', syncMobileViewport))
watch(() => route.fullPath, () => {
mobileMenuOpen.value = false
syncCurrentRoute()
@@ -123,12 +154,37 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<button
class="shell-menu-button"
type="button"
:aria-label="mobileMenuOpen ? '关闭菜单' : collapsed ? '展开导航' : '收起导航'"
:aria-expanded="mobileMenuOpen || !collapsed"
:aria-label="menuButtonLabel"
:aria-expanded="menuExpanded"
@click="handleMenuToggle"
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div v-if="showPageHeading" class="page-heading">
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
</div>
<div class="header-user">
<button
v-if="access.localFullAccessAvailable"
class="local-full-access-toggle"
type="button"
role="switch"
:aria-checked="access.localFullAccessEnabled"
:title="access.localFullAccessEnabled ? '关闭后恢复服务端权限' : '开启后显示全部前端页面和按钮'"
@click="access.toggleLocalFullAccess"
>
<ShieldCheck :size="17" />
<span>本地全权限</span>
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
</button>
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心">
<Bell :size="18" /><span>消息中心</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
@@ -141,7 +197,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<component
:is="Component"
v-if="renderedRoute.meta.keepAlive !== false"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
/>
</KeepAlive>
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
@@ -153,4 +209,14 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
.notification-trigger > svg { flex: 0 0 auto; }
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
</style>
+58 -11
View File
@@ -23,7 +23,6 @@ describe('admin router access metadata', () => {
it('protects phase-two inbound operations and master data with deployed permissions', () => {
const expected = [
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'],
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
['locations', '/master-data/locations', 'platform:location:list'],
['pallets', '/master-data/pallets', 'platform:pallet:list'],
@@ -37,7 +36,7 @@ describe('admin router access metadata', () => {
})
it('does not register removed duplicate pricing and FBA placeholder pages', () => {
const removedRouteNames = ['fba-customer-rules', 'general-fba', 'general-voyage']
const removedRouteNames = ['inbound-order-detail', 'fba-customer-rules', 'general-fba', 'general-voyage']
for (const name of removedRouteNames) {
expect(router.getRoutes().some((route) => route.name === name)).toBe(false)
@@ -51,16 +50,27 @@ describe('admin router access metadata', () => {
expect(route?.meta.permission).toBe('platform:express:list')
})
it('registers the global smart-loading route before the single-container deep link', () => {
const route = router.getRoutes().find((item) => item.name === 'global-smart-loading-workbench')
const moduleChildren = router.options.routes.find((item) => item.path === '/')?.children || []
const globalIndex = moduleChildren.findIndex((item) => item.name === 'global-smart-loading-workbench')
const detailIndex = moduleChildren.findIndex((item) => item.name === 'smart-loading-workbench')
it('keeps smart loading as an order dialog while retaining the single-container deep link', () => {
expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false)
const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench')
expect(detail?.path).toBe('/container-loading/domestic/:containerId/smart-loading')
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
})
expect(route?.path).toBe('/container-loading/smart-loading')
expect(route?.meta.permission).toBe('platform:firstleg:smart-loading:list')
expect(globalIndex).toBeGreaterThanOrEqual(0)
expect(detailIndex).toBeGreaterThan(globalIndex)
it('registers the retained phase-three service center routes with stable permissions', () => {
const expected = [
['ocean-bookings', '/ocean-bookings', 'platform:firstleg:ocean-booking:list'],
['trucking', '/trucking', 'platform:firstleg:truck-appointment:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
expect(router.getRoutes().find((item) => item.name === 'port-schedules')).toBeUndefined()
expect(router.getRoutes().find((item) => item.name === 'outbound-dispatches')).toBeUndefined()
})
it('protects counterparty records with a stable master-data permission', () => {
@@ -69,12 +79,49 @@ describe('admin router access metadata', () => {
expect(route?.meta.permission).toBe('platform:counterparty:list')
})
it('registers the confirmed phase-four routes with stable list permissions', () => {
const expected = [
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:overseas-warehouse:inbound-order:list'],
['overseas-dispatches', '/overseas-warehouse/dispatches', 'platform:overseas-warehouse:dispatch:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:overseas-warehouse:pallet:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:overseas-warehouse:inventory:list'],
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase:list'],
['overseas-business-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
['overseas-business-looseboards', '/overseas-business/looseboards', 'platform:overseas-business:looseboard:list'],
['overseas-master-data-quotes', '/overseas-master-data/quotes', 'platform:overseas-master-data:quote:list'],
['overseas-master-data-exchange-rates', '/overseas-master-data/exchange-rates', 'platform:overseas-master-data:exchange-rate:list'],
['overseas-master-data-vehicles', '/overseas-master-data/vehicles', 'platform:overseas-master-data:vehicle:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
})
it('protects the download center with its stable list permission', () => {
const route = router.getRoutes().find((item) => item.name === 'download-center')
expect(route?.path).toBe('/downloads')
expect(route?.meta.permission).toBe('platform:download:list')
})
it('registers message-center routes with the frozen IAM permissions', () => {
const expected = [
['notification-inbox', '/notifications', 'platform:notification:view'],
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
expect(router.getRoutes().find((item) => item.name === 'my-notifications')).toBeUndefined()
})
it('redirects the removed enterprise review route to the protected customer list', () => {
const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews')
+20 -3
View File
@@ -1,10 +1,10 @@
import '@/styles/adminDensity.css'
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AppLayout from '@/app/layouts/AppLayout.vue'
import { decideRouteAccess } from '@/core/routing/accessDecision'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { downloadRoutes } from '@/modules/downloads/routes'
import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes'
import { inboundRoutes } from '@/modules/inbounds/routes'
import { unifiedOrderRoutes } from '@/modules/unified-orders/routes'
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
@@ -24,6 +24,15 @@ import { truckingRoutes } from '@/modules/trucking/routes'
import { feeItemRoutes } from '@/modules/fee-items/routes'
import { containerLoadingRoutes } from '@/modules/container-loading/routes'
import { loadingListRoutes } from '@/modules/loading-lists/routes'
import { financeRoutes } from '@/modules/finance/routes'
import { notificationRoutes } from '@/modules/notifications/routes'
import { domesticContainerRoutes } from '@/modules/domestic-containers/routes'
import { supplierRoutes } from '@/modules/suppliers/routes'
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
const router = createRouter({
history: createWebHistory(),
@@ -51,10 +60,16 @@ const router = createRouter({
...truckingRoutes,
...containerLoadingRoutes,
...loadingListRoutes,
...financeRoutes,
...notificationRoutes,
...domesticContainerRoutes,
...supplierRoutes,
...overseasWarehouseRoutes,
...overseasBusinessRoutes,
...overseasMasterDataRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
...firstLegOrderRoutes,
...inboundRoutes,
...fbaPricingRoutes,
...warehouseResourceRoutes,
@@ -62,6 +77,8 @@ const router = createRouter({
...masterDataRoutes,
...downloadRoutes,
...iamRoutes,
...pdaReleaseRoutes,
...sailingCalendarRoutes,
{
path: '403',
name: 'forbidden',
@@ -87,7 +104,7 @@ router.beforeEach(async (to, from) => {
await ElMessageBox.confirm(
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
'放弃未保存的修改?',
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning' },
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
unsaved.removeWorkspaces([fromId])
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
+30 -2
View File
@@ -1,10 +1,13 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { isLocalFullAccessHost, useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
describe('server access store', () => {
beforeEach(() => setActivePinia(createPinia()))
beforeEach(() => {
window.localStorage.clear()
setActivePinia(createPinia())
})
it('uses only the authenticated permission snapshot', () => {
const auth = useAuthStore()
@@ -24,4 +27,29 @@ describe('server access store', () => {
expect(access.filteredNavigation).toHaveLength(1)
expect(access.firstAccessiblePath).toBe('/dashboard')
})
it('allows every frontend permission only while the localhost control is enabled', () => {
const access = useAccessPreviewStore()
expect(access.localFullAccessAvailable).toBe(true)
expect(access.can('platform:anything:create')).toBe(false)
access.setLocalFullAccessEnabled(true)
expect(access.can('platform:anything:create')).toBe(true)
expect(access.permissionCodes.has('platform:anything:edit')).toBe(true)
expect(access.filteredNavigation.length).toBeGreaterThan(1)
expect(window.localStorage.getItem('baiyue-admin-local-full-access')).toBe('true')
access.setLocalFullAccessEnabled(false)
expect(access.can('platform:anything:create')).toBe(false)
expect(window.localStorage.getItem('baiyue-admin-local-full-access')).toBeNull()
})
it('recognizes only local preview hosts', () => {
expect(isLocalFullAccessHost('localhost')).toBe(true)
expect(isLocalFullAccessHost('127.0.0.1')).toBe(true)
expect(isLocalFullAccessHost('::1')).toBe(true)
expect(isLocalFullAccessHost('admin.example.com')).toBe(false)
})
})
+44 -2
View File
@@ -7,11 +7,38 @@ import {
} from '@/core/navigation/adminNavigation'
import { useAuthStore } from '@/core/auth/auth'
const LOCAL_FULL_ACCESS_STORAGE_KEY = 'baiyue-admin-local-full-access'
class LocalFullAccessPermissionSet extends Set<string> {
override has(_permissionCode: string): boolean {
return true
}
}
export function isLocalFullAccessHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
}
export const useAccessPreviewStore = defineStore('access-preview', () => {
const auth = useAuthStore()
const initialized = ref(false)
const permissionCodes = computed<ReadonlySet<string>>(() => auth.permissionCodes)
const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|'))
const localFullAccessAvailable = import.meta.env.DEV
&& typeof window !== 'undefined'
&& isLocalFullAccessHost(window.location.hostname)
const localFullAccessEnabled = ref(
localFullAccessAvailable
&& window.localStorage.getItem(LOCAL_FULL_ACCESS_STORAGE_KEY) === 'true',
)
const permissionCodes = computed<ReadonlySet<string>>(() => (
localFullAccessEnabled.value
? new LocalFullAccessPermissionSet(auth.permissionCodes)
: auth.permissionCodes
))
const permissionSignature = computed(() => (
localFullAccessEnabled.value
? '__LOCAL_FULL_ACCESS__'
: [...permissionCodes.value].sort().join('|')
))
const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value))
const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null)
@@ -23,18 +50,33 @@ export const useAccessPreviewStore = defineStore('access-preview', () => {
return permissionCodes.value.has(permissionCode)
}
function setLocalFullAccessEnabled(enabled: boolean) {
if (!localFullAccessAvailable) return
localFullAccessEnabled.value = enabled
if (enabled) window.localStorage.setItem(LOCAL_FULL_ACCESS_STORAGE_KEY, 'true')
else window.localStorage.removeItem(LOCAL_FULL_ACCESS_STORAGE_KEY)
}
function toggleLocalFullAccess() {
setLocalFullAccessEnabled(!localFullAccessEnabled.value)
}
function reset() {
initialized.value = false
}
return {
initialized,
localFullAccessAvailable,
localFullAccessEnabled,
permissionCodes,
permissionSignature,
filteredNavigation,
firstAccessiblePath,
initializeAfterLogin,
can,
setLocalFullAccessEnabled,
toggleLocalFullAccess,
reset,
}
})
+181 -17
View File
@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
@@ -64,6 +64,20 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
}
}
function unifiedLoginResponse() {
return {
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken: token(),
expiresIn: 1800,
refreshToken: 'refresh-domestic',
refreshExpiresIn: 604800,
},
}
}
describe('admin auth session', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', createMemoryStorage())
@@ -73,6 +87,45 @@ describe('admin auth session', () => {
mocks.refreshHandler = null
})
afterEach(() => vi.unstubAllGlobals())
it('accepts the unified access and refresh token response', async () => {
mocks.post.mockResolvedValue(unifiedLoginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('platform_admin', 'password')
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
loginId: 'platform_admin',
password: 'password',
})
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-domestic')
})
it('accepts serialized long lifetimes from the real authentication response', async () => {
mocks.post.mockResolvedValue({
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken: token(),
expiresIn: '7200',
refreshToken: 'refresh-serialized',
refreshExpiresIn: '604800',
},
})
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('platform_admin', 'password')
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-serialized')
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
})
it('loads permissions from the current authorization endpoint when the JWT only contains identity', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
@@ -127,29 +180,43 @@ describe('admin auth session', () => {
expect(auth.isAuthenticated).toBe(true)
})
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
it('reuses a valid persisted session without blocking reload on refresh', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
const refreshedToken = token({
token_version: 2,
})
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect([...auth.permissionCodes]).toEqual([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
expect(mocks.post).not.toHaveBeenCalled()
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]')
})
it('reuses a session rotated by another tab without refreshing it again', async () => {
const previousToken = token({ token_version: 1 })
const rotatedToken = token({ token_version: 2 })
localStorage.setItem('cc-admin-token', previousToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
localStorage.setItem('cc-admin-token', rotatedToken)
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
return callback()
}),
},
})
const auth = useAuthStore()
await expect(auth.refreshSession()).resolves.toBeUndefined()
expect(mocks.post).not.toHaveBeenCalled()
expect(auth.token).toBe(rotatedToken)
expect(auth.refreshToken).toBe('refresh-rotated')
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
})
it('does not let an in-flight refresh restore a session after logout', async () => {
@@ -174,4 +241,101 @@ describe('admin auth session', () => {
expect(auth.refreshToken).toBe('')
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => {
const previous = token()
const rotated = token({ token_version: 2 })
localStorage.setItem('cc-admin-token', previous)
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } })
.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated'))
.mockResolvedValueOnce({ code: '200', data: null })
const auth = useAuthStore()
await expect(auth.logout()).resolves.toBeUndefined()
expect(mocks.post).toHaveBeenCalledTimes(3)
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' })
expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, {
headers: { Authorization: `Bearer ${rotated}` },
})
expect(auth.isAuthenticated).toBe(false)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled()
})
it('revokes a persisted refresh session when startup already removed its expired access token', async () => {
localStorage.setItem('cc-admin-token', token({ exp: 1 }))
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
const rotated = token()
mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated'))
.mockResolvedValueOnce({ code: '200', data: null })
const auth = useAuthStore()
expect(auth.token).toBe('')
await auth.logout()
expect(mocks.post).toHaveBeenCalledTimes(2)
expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' })
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, {
headers: { Authorization: `Bearer ${rotated}` },
})
expect(auth.isAuthenticated).toBe(false)
})
it('leaves a subsequent login intact while finishing revocation of the previous session', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
let finishRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
const started = new Promise<void>(resolve => {
mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } })
.mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) })
})
const auth = useAuthStore()
const loggingOut = auth.logout()
// Attach immediately so a regression rejecting early does not become an unhandled rejection.
const result = loggingOut.then(() => null, error => error)
await Promise.race([started, loggingOut.catch(() => undefined)])
expect(finishRefresh).toBeTypeOf('function')
const newAccess = token({ user_id: '1000002', username: 'second' })
mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login'))
.mockResolvedValueOnce({ code: '200', data: null })
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
await auth.login('second', 'password')
const revocationAccess = token({ token_version: 3 })
finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only'))
expect(await result).toBeNull()
expect(auth.token).toBe(newAccess)
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login')
expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, {
headers: { Authorization: `Bearer ${revocationAccess}` },
})
})
it('does not refresh or retry an ambiguous logout transport failure', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
const failure = { isAxiosError: true, response: { status: 503 } }
mocks.post.mockRejectedValue(failure)
const auth = useAuthStore()
await expect(auth.logout()).rejects.toBe(failure)
expect(mocks.post).toHaveBeenCalledTimes(1)
expect(auth.isAuthenticated).toBe(false)
})
it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
const unauthorized = { isAxiosError: true, response: { status: 401 } }
mocks.post.mockRejectedValueOnce(unauthorized)
if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse())
mocks.post.mockRejectedValueOnce(unauthorized)
const auth = useAuthStore()
await expect(auth.logout()).rejects.toBe(unauthorized)
expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
})
})
+46 -20
View File
@@ -1,5 +1,6 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { isAxiosError } from 'axios'
import type { CurrentUser } from '@chongchong/shared'
import {
http,
@@ -20,14 +21,24 @@ const REFRESH_LOCK_NAME = 'cc-admin-auth-refresh'
interface LoginResponse {
tokenType: string
accessToken: string
expiresIn: number
expiresIn: number | string
refreshToken: string
refreshExpiresIn: number
refreshExpiresIn: number | string
}
class ExpiredAccessTokenError extends Error {}
class SupersededSessionError extends Error {}
function parseLifetimeSeconds(value: number | string, field: string) {
const normalized = typeof value === 'number'
? value
: /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN
if (!Number.isSafeInteger(normalized) || normalized < 0) {
throw new Error(`登录响应中的 ${field} 格式无效`)
}
return normalized
}
function sessionFromToken(token: string) {
const claims = decodeJwtClaims(token)
if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端')
@@ -147,9 +158,29 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
})
async function revoke(access: string, refresh: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/auth/logout', { refreshToken: refresh }, { headers: { Authorization: `Bearer ${access}` } },
)
unwrapApiResponse(response)
}
if (accessToken) {
try {
await revoke(accessToken, refreshTokenToRevoke)
return
} catch (error) {
if (!isAxiosError(error) || error.response?.status !== 401) throw error
}
}
// Logout has already cleared local state. Refresh only this captured pair
// for revocation; never apply it to this tab or overwrite a subsequent login.
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh', { refreshToken: refreshTokenToRevoke },
)
const rotated = unwrapApiResponse<LoginResponse>(response)
sessionFromToken(rotated.accessToken)
if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据')
await revoke(rotated.accessToken, rotated.refreshToken)
}
async function login(account: string, password: string) {
@@ -157,7 +188,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ username: account, password },
{ loginId: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration)
@@ -184,10 +215,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
observedRefreshToken,
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
reuseCurrentSession: async () => {
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
await applySession({ tokenType: 'Bearer', accessToken: storedAccessToken, expiresIn: 0, refreshToken: storedRefreshToken, refreshExpiresIn: 0 }, expectedGeneration)
applyStoredSession()
ensureCurrentGeneration(expectedGeneration)
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
},
refresh: async (currentRefreshToken) => {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
@@ -206,15 +236,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
async function restoreSession() {
if (isAuthenticated.value) {
if (!refreshToken.value) return true
try {
await refreshSession()
return true
} catch {
return false
}
}
// The constructor has already validated a persisted access token. Reusing
// a still-valid token lets the app mount immediately; refresh only remains
// necessary after the access token has expired and the constructor has
// retained the persisted refresh token for recovery.
if (isAuthenticated.value) return true
if (!refreshToken.value) return false
try {
await refreshSession()
@@ -240,7 +266,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
const accessToken = token.value
const tokenToRevoke = refreshToken.value
clearSession()
if (accessToken && tokenToRevoke) {
if (tokenToRevoke) {
await revokeTokenPair(accessToken, tokenToRevoke)
}
}
+22 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { coordinateCrossTabRefresh } from './crossTabRefresh'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
afterEach(() => vi.unstubAllGlobals())
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
const reuseCurrentSession = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: {
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
currentRefreshToken = 'refresh-new'
return callback()
}),
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
it('performs the refresh when the token has not changed', async () => {
const refresh = vi.fn(async () => 'access-new')
vi.stubGlobal('navigator', {
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
})
await expect(coordinateCrossTabRefresh({
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
expect(refresh).toHaveBeenCalledWith('refresh-old')
})
it('aborts a refresh lock that cannot be acquired', async () => {
vi.stubGlobal('navigator', {
locks: {
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
})),
},
})
await expect(coordinateCrossTabRefresh({
lockName: 'admin-refresh',
lockTimeoutMs: 5,
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: vi.fn(),
refresh: vi.fn(),
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
})
})
+20 -1
View File
@@ -1,11 +1,21 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
lockTimeoutMs?: number
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export class CrossTabRefreshTimeoutError extends Error {
constructor() {
super('登录状态刷新等待超时,请重新登录')
this.name = 'CrossTabRefreshTimeoutError'
}
}
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
throw error
}
}
return navigator.locks.request(options.lockName, run)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
try {
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
} catch (error) {
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
+136 -75
View File
@@ -8,72 +8,33 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'首页数据看板',
'客户管理',
'国内仓',
'海外派送',
'服务中心',
'供应商中心',
'货柜管理',
'海外业务',
'海外仓库存',
'财务管理',
'销售管理',
'通用',
'系统管理',
])
expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([
'客户列表',
'海外仓阶梯费用',
])
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
'订单列表',
'订单测量',
'测量数据审核',
'库位管理',
'托盘管理',
])
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
'供应商列表',
'快递公司',
'往来单位',
])
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'货柜列表',
'智能装柜',
'预装清单',
'装柜清单',
])
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'入库单',
'换标单',
'出库单',
'托盘管理',
'箱号管理',
'产品库',
'退货库存',
'暂扣库存',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
'产品信息',
'费用项目',
'仓库管理',
'船期价格表',
'车辆列表',
'下载中心',
])
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
'租户管理',
'品牌设置',
'菜单权限',
it('offers the common container menu to overseas-only operators', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
})
it('matches the latest prototype sidebar using only implemented menu entries', () => {
// 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56).
const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : []))
const visible = filterAdminNavigation(adminNavigation, allPermissions)
expect(visible.map(node => ({ title: node.title, children: node.children?.map(child => child.title) ?? [] }))).toEqual([
{ title: '首页数据看板', children: [] },
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(11)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
it('assigns every page a unique path and keeps real permissions unique', () => {
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
const leaves = getNavigationLeaves(adminNavigation)
const paths = leaves.map((item) => item.path)
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
@@ -84,23 +45,100 @@ describe('adminNavigation', () => {
expect(paths).not.toContain('/inbounds')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
expect(paths).toContain('/container-loading/smart-loading')
expect(paths).toContain('/container-loading/domestic')
expect(paths).toContain('/container-loading/overseas')
expect(paths).not.toContain('/container-loading/smart-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
expect(paths).not.toContain('/port-schedules')
expect(paths).not.toContain('/customs')
expect(paths).not.toContain('/outbound-dispatches')
expect(paths).not.toContain('/prototype/container-management/container-list')
expect(paths).not.toContain('/prototype/container-management/preload-list')
expect(paths).not.toContain('/prototype/container-management/manifest')
expect(paths).not.toContain('/prototype/general/fee-items')
expect(paths).toContain('/prototype/finance/profit-report')
expect(paths).toEqual(expect.arrayContaining([
'/finance/customer-accounts',
'/finance/customer-arap',
'/finance/receivable-statistics',
'/finance/customer-statements',
'/finance/settlement-receivables',
'/finance/settlement-payables',
]))
expect(paths).not.toEqual(expect.arrayContaining(['/finance/bills', '/finance/receipts', '/finance/costs']))
expect(paths).toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
expect(new Set(permissions).size).toBe(permissions.length)
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
expect(new Set(duplicatedPermissions)).toEqual(new Set())
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
it('shows the five formal finance pages plus receivable statistics with real backend permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:finance:customer-account:list',
'platform:finance:customer-arap:list',
'platform:finance:receivable-statistics:list',
'platform:finance:customer-statement:list',
'platform:finance:settlement-ar:list',
'platform:finance:settlement-ap:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path).filter((path) => path.startsWith('/finance/'))).toEqual([
'/finance/customer-accounts',
'/finance/customer-arap',
'/finance/receivable-statistics',
'/finance/customer-statements',
'/finance/settlement-receivables',
'/finance/settlement-payables',
])
})
it('shows only the confirmed phase-four pages with their list permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:relabel-order:list',
'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:inventory:list',
'platform:overseas-business:purchase:list',
'platform:overseas-business:truck:list',
'platform:overseas-business:looseboard:list',
'platform:overseas-master-data:exchange-rate:list',
'platform:overseas-master-data:quote:list',
'platform:overseas-master-data:vehicle:list',
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toEqual(expect.arrayContaining([
'/overseas-warehouse/last-mile-orders',
'/overseas-warehouse/inbound-orders',
'/overseas-warehouse/dispatches',
'/overseas-warehouse/outbounds',
'/overseas-warehouse/delivery-orders',
'/overseas-warehouse/exception-orders',
'/overseas-warehouse/relabel-orders',
'/overseas-warehouse/pallets',
'/overseas-warehouse/inventory',
'/overseas-business/purchases',
'/overseas-business/trucks',
'/overseas-business/looseboards',
'/overseas-master-data/exchange-rates',
'/overseas-master-data/quotes',
'/overseas-master-data/vehicles',
]))
expect(paths).not.toContain('/overseas-warehouse/cartons')
expect(paths).not.toContain('/overseas-warehouse/products')
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
})
it('filters protected pages without mutating the source tree', () => {
const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
@@ -108,6 +146,7 @@ describe('adminNavigation', () => {
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toContain('/system/permissions')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/notifications/messages')
expect(paths).not.toContain('/customers')
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '系统管理'])
expect(filtered).not.toBe(adminNavigation)
@@ -124,12 +163,10 @@ describe('adminNavigation', () => {
]))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内头程', '通用'])
expect(paths).toEqual([
'/dashboard',
'/orders',
'/master-data/express-companies',
'/master-data/counterparties',
'/warehouses',
])
expect(paths).toContain('/dashboard')
@@ -149,20 +186,44 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('shows each formal container workflow entry only with its matching permission', () => {
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
})
it('shows split container workbenches without a standalone smart-loading menu', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
'platform:firstleg:smart-loading:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
'platform:firstleg:terminal-drayage:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
'/container-loading/smart-loading',
'/loading-plans',
'/container-loading/domestic',
'/loading-lists',
'/loading-plans',
])
})
it('shows only the prototype-backed phase-three service center entries', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:ocean-booking:list',
'platform:firstleg:port-schedule:list',
'platform:firstleg:truck-appointment:list',
'platform:firstleg:customs:list',
'platform:firstleg:outbound-dispatch:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/ocean-bookings',
'/trucking',
])
})
+89 -66
View File
@@ -1,7 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
BarChart3,
CalendarDays,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -15,6 +15,21 @@ import {
Users,
Warehouse as WarehouseIcon,
} from '@lucide/vue'
import {
PrototypeContainerIcon,
PrototypeCustomerServiceIcon,
PrototypeCustomerIcon,
PrototypeDashboardIcon,
PrototypeDeliveryIcon,
PrototypeDomesticWarehouseIcon,
PrototypeFinanceIcon,
PrototypeGeneralIcon,
PrototypeOverseasBusinessIcon,
PrototypeOverseasWarehouseIcon,
PrototypeSalesIcon,
PrototypeSystemIcon,
PrototypeVehicleIcon,
} from './prototypeMenuIcons'
export interface AdminNavigationNode {
key: string
@@ -36,12 +51,11 @@ export interface AdminNavigationLeaf extends AdminNavigationNode {
}
export const adminNavigation: AdminNavigationNode[] = [
{ key: 'dashboard', title: '首页数据看板', icon: BarChart3, path: '/dashboard' },
{ key: 'dashboard', title: '首页数据看板', icon: PrototypeDashboardIcon, path: '/dashboard' },
{
key: 'customers',
title: '客户管理',
icon: Building2,
iconColor: '#52c41a',
icon: PrototypeCustomerIcon,
children: [
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/customers/overseas-storage-fees', permission: 'platform:overseas-storage-fee:list' },
@@ -49,21 +63,22 @@ export const adminNavigation: AdminNavigationNode[] = [
},
{
key: 'domestic-warehouse',
title: '国内仓',
icon: WarehouseIcon,
iconColor: '#52c41a',
title: '国内头程',
icon: PrototypeDomesticWarehouseIcon,
children: [
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure', hidden: true },
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
{ key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
],
},
{
key: 'overseas-delivery',
title: '海外派送',
icon: Truck,
icon: PrototypeDeliveryIcon,
hidden: true,
children: [
{ key: 'outbound-dispatch', title: '派送单列表', icon: Truck, path: '/prototype/overseas-delivery/dispatch-list' },
@@ -73,80 +88,85 @@ export const adminNavigation: AdminNavigationNode[] = [
],
},
{
key: 'service-center',
title: '服务中心',
icon: ClipboardList,
key: 'container-management',
title: '货柜管理',
icon: PrototypeContainerIcon,
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
],
},
{
key: 'supplier-center',
title: '供应商中心',
icon: ContactRound,
children: [
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/prototype/supplier-center/supplier-list', hidden: true },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
],
},
{
key: 'container-management',
title: '货柜管理',
icon: WarehouseIcon,
children: [
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
{ key: 'container-smart-loading', title: '智能装柜', icon: ClipboardList, path: '/container-loading/smart-loading', permission: 'platform:firstleg:smart-loading:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true },
],
},
{
key: 'overseas-inventory',
title: '海外尾程',
icon: PrototypeOverseasWarehouseIcon,
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
],
},
{
key: 'vehicle-management',
title: '车辆管理',
icon: PrototypeVehicleIcon,
children: [
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
{ key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
},
{
key: 'overseas-business',
title: '海外业务',
icon: Building2,
hidden: true,
icon: PrototypeOverseasBusinessIcon,
children: [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/prototype/overseas-business/purchase-orders' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/prototype/overseas-business/local-truck' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/prototype/overseas-business/local-looseboard' },
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
],
},
{
key: 'overseas-inventory',
title: '海外仓库存',
icon: WarehouseIcon,
hidden: true,
key: 'customer-service',
title: '客户服务',
icon: PrototypeCustomerServiceIcon,
children: [
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/prototype/fba-returns/inbound-orders' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/prototype/fba-returns/relabel-orders' },
{ key: 'fba-outbound-order', title: '出库单', icon: ClipboardList, path: '/prototype/fba-returns/outbound-orders' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/fba-returns/pallet-management' },
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/prototype/fba-returns/carton-management' },
{ key: 'fba-product', title: '产品库', icon: Database, path: '/prototype/fba-returns/product-catalog' },
{ key: 'fba-return-stock', title: '退货库存', icon: WarehouseIcon, path: '/prototype/fba-returns/return-stock' },
{ key: 'overseas-held-stock', title: '暂扣库存', icon: WarehouseIcon, path: '/prototype/overseas-inventory/stock-list' },
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
],
},
{
key: 'finance',
title: '财务管理',
icon: CircleDollarSign,
hidden: true,
icon: PrototypeFinanceIcon,
children: [
{ key: 'customer-arap', title: '客户应收应付', icon: Database, path: '/prototype/finance/customer-arap' },
{ key: 'customer-recon-stmt', title: '客户对账单', icon: FileCheck2, path: '/prototype/finance/customer-reconciliation' },
{ key: 'settlement-ar', title: '结算单应收', icon: FileCheck2, path: '/prototype/finance/settlement-ar' },
{ key: 'settlement-ap', title: '结算单应付', icon: FileCheck2, path: '/prototype/finance/settlement-ap' },
{ key: 'finance-cost', title: '费用分析', icon: Database, path: '/prototype/finance/cost-analysis' },
{ key: 'profit', title: '利润表', icon: Database, path: '/prototype/finance/profit-report' },
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
{ key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:customer-statement:list' },
{ key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:settlement-ar:list' },
{ key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:settlement-ap:list' },
],
},
{
key: 'sales',
title: '销售管理',
icon: Users,
icon: PrototypeSalesIcon,
hidden: true,
children: [
{ key: 'sales-visits', title: '拜访记录', icon: Users, path: '/prototype/sales/visit-records' },
@@ -156,29 +176,32 @@ export const adminNavigation: AdminNavigationNode[] = [
{
key: 'general',
title: '通用',
icon: Database,
iconColor: '#722ed1',
icon: PrototypeGeneralIcon,
children: [
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' },
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: true },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
],
},
{
key: 'system',
title: '系统管理',
icon: Settings,
iconColor: '#8c8c8c',
icon: PrototypeSystemIcon,
children: [
{ key: 'internal-users', title: '用户管理', icon: Users, path: '/system/users', permission: 'platform:user:list' },
{ key: 'roles', title: '角色权限', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' },
{ key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true },
{ key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true },
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
{ key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true },
],
},
]
@@ -0,0 +1,32 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import {
PrototypeCustomerIcon,
PrototypeDashboardIcon,
PrototypeFinanceIcon,
} from './prototypeMenuIcons'
describe('prototype menu icons', () => {
it('renders the prototype shapes at the requested size', () => {
const wrapper = mount(PrototypeDashboardIcon, { props: { size: 22 } })
expect(wrapper.attributes()).toMatchObject({
width: '22',
height: '22',
viewBox: '0 0 24 24',
'aria-hidden': 'true',
})
expect(wrapper.findAll('rect')).toHaveLength(4)
expect(wrapper.findAll('rect').map((node) => node.attributes('fill'))).toEqual([
'#1677ff',
'#1677ff',
'#1677ff',
'#1677ff',
])
})
it('preserves the original menu colors', () => {
expect(mount(PrototypeCustomerIcon).find('path').attributes('fill')).toBe('#52c41a')
expect(mount(PrototypeFinanceIcon).find('circle').attributes('stroke')).toBe('#f5a623')
})
})
+154
View File
@@ -0,0 +1,154 @@
import { defineComponent, h, type Component, type SVGAttributes } from 'vue'
type SvgChild = ReturnType<typeof h>
function createPrototypeMenuIcon(name: string, children: SvgChild[]): Component {
return defineComponent({
name,
inheritAttrs: false,
props: {
size: { type: [Number, String], default: 20 },
},
setup(props, { attrs }) {
return () => h('svg', {
...attrs,
width: props.size,
height: props.size,
viewBox: '0 0 24 24',
fill: 'none',
'aria-hidden': 'true',
} satisfies SVGAttributes, children)
},
})
}
function createOrderDetailIcon(name: string, children: SvgChild[]): Component {
return defineComponent({
name,
inheritAttrs: false,
props: { size: { type: [Number, String], default: 20 } },
setup(props, { attrs }) {
return () => h('svg', {
...attrs,
width: props.size,
height: props.size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': 1.8,
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true',
} satisfies SVGAttributes, children)
},
})
}
export const OrderBasicInfoIcon = createOrderDetailIcon('OrderBasicInfoIcon', [
h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }),
h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }),
])
export const OrderCargoInfoIcon = createOrderDetailIcon('OrderCargoInfoIcon', [
h('path', { d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z' }),
h('path', { d: 'm4 7.5 8 4.5 8-4.5M12 12v9' }),
h('path', { d: 'M8 5.25 16 9.75' }),
])
export const OrderFeeInfoIcon = createOrderDetailIcon('OrderFeeInfoIcon', [
h('rect', { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
h('path', { d: 'M3 9h18M7 14h4' }),
])
export const OrderHistoryIcon = createOrderDetailIcon('OrderHistoryIcon', [
h('path', { d: 'M3 12a9 9 0 1 0 3-6.7' }),
h('path', { d: 'M3 4v5h5M12 7v5l3 2' }),
])
export const OrderMeasurementIcon = createOrderDetailIcon('OrderMeasurementIcon', [
h('path', { d: 'm4 17 13-13 3 3L7 20H4v-3Z' }),
h('path', { d: 'm13 8 3 3M7 17l-1 1M10 14l-1 1M13 11l-1 1' }),
])
export const OrderOtherIcon = createOrderDetailIcon('OrderOtherIcon', [
h('path', { d: 'M6 3h9l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z' }),
h('path', { d: 'M14 3v5h5' }),
])
export const OrderFulfillmentIcon = createOrderDetailIcon('OrderFulfillmentIcon', [
h('path', { d: 'M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11' }),
h('path', { d: 'm7 11 1.5 1.5L11 10M7 17l1.5 1.5L11 16' }),
])
export const PrototypeDashboardIcon = createPrototypeMenuIcon('PrototypeDashboardIcon', [
h('rect', { x: 3, y: 3, width: 7, height: 8, rx: 1.5, fill: '#1677ff' }),
h('rect', { x: 14, y: 3, width: 7, height: 5, rx: 1.5, fill: '#1677ff', opacity: 0.6 }),
h('rect', { x: 3, y: 14, width: 7, height: 7, rx: 1.5, fill: '#1677ff', opacity: 0.3 }),
h('rect', { x: 14, y: 11, width: 7, height: 10, rx: 1.5, fill: '#1677ff', opacity: 0.8 }),
])
export const PrototypeCustomerIcon = createPrototypeMenuIcon('PrototypeCustomerIcon', [
h('path', { d: 'M3 21h7v-7H3v7zm0-9h7V3H3v9zm11 9h7v-5h-7v5zm0-9h7V3h-7v9z', fill: '#52c41a' }),
])
export const PrototypeDomesticWarehouseIcon = createPrototypeMenuIcon('PrototypeDomesticWarehouseIcon', [
h('path', { d: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('path', { d: 'M9 14l2 2 4-4', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }),
])
export const PrototypeOverseasWarehouseIcon = createPrototypeMenuIcon('PrototypeOverseasWarehouseIcon', [
h('path', { d: 'M4 12a8 8 0 112.34 5.6', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('path', { d: 'M4 12V7M4 12h5', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('circle', { cx: 12, cy: 12, r: 2.4, fill: '#eb2f96' }),
])
export const PrototypeDeliveryIcon = createPrototypeMenuIcon('PrototypeDeliveryIcon', [
h('path', { d: 'M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z', fill: '#1890ff' }),
])
export const PrototypeServiceIcon = createPrototypeMenuIcon('PrototypeServiceIcon', [
h('path', { d: 'M4 6h16v12H4z', stroke: '#722ed1', 'stroke-width': 2 }),
h('path', { d: 'M4 10h16M8 14h3', stroke: '#722ed1', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeSupplierIcon = createPrototypeMenuIcon('PrototypeSupplierIcon', [
h('path', { d: 'M3 21V9l9-6 9 6v12', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
h('path', { d: 'M9 21v-6h6v6', stroke: '#eb2f96', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
])
export const PrototypeContainerIcon = createPrototypeMenuIcon('PrototypeContainerIcon', [
h('rect', { x: 2, y: 3, width: 20, height: 18, rx: 1, stroke: '#52c41a', 'stroke-width': 2 }),
h('path', { d: 'M2 8h20M8 3v5M16 3v5', stroke: '#52c41a', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeOverseasBusinessIcon', [
h('circle', { cx: 12, cy: 12, r: 9, stroke: '#8c8c8c', 'stroke-width': 1.5 }),
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
])
export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [
h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }),
h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
])
export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [
h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
h('circle', { cx: 12, cy: 12, r: 2, fill: '#f5a623' }),
])
export const PrototypeSalesIcon = createPrototypeMenuIcon('PrototypeSalesIcon', [
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#1677ff', 'stroke-width': 2 }),
h('path', { d: 'M12 6v12M6 12h12', stroke: '#1677ff', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeGeneralIcon = createPrototypeMenuIcon('PrototypeGeneralIcon', [
h('rect', { x: 3, y: 3, width: 18, height: 18, rx: 2, stroke: '#722ed1', 'stroke-width': 2 }),
h('path', { d: 'M8 12h8M12 8v8', stroke: '#722ed1', 'stroke-width': 2, 'stroke-linecap': 'round' }),
])
export const PrototypeSystemIcon = createPrototypeMenuIcon('PrototypeSystemIcon', [
h('circle', { cx: 12, cy: 12, r: 3, fill: '#8c8c8c' }),
h('path', { d: 'M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
])
+183
View File
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest'
import {
createNavigationRecoveryHandler,
installAssetRecoveryListeners,
isStaleRouteAssetError,
recoverNavigationError,
reloadNavigationTarget,
type NavigationRecoveryDependencies,
} from './navigationRecovery'
function memoryStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
return {
storage: memoryStorage(),
now: () => 1_000,
hasUnsavedWork: () => false,
confirmDiscard: vi.fn(async () => true),
discardUnsavedWork: vi.fn(),
navigate: vi.fn(),
showError: vi.fn(),
...overrides,
} satisfies NavigationRecoveryDependencies
}
describe('navigationRecovery', () => {
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
'forces a full reload for an async component failure on the current document: %s', (target) => {
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
const history = { state: { position: 2 }, replaceState: vi.fn() }
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
expect(location.reload).toHaveBeenCalledOnce()
expect(location.assign).not.toHaveBeenCalled()
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
},
)
it('loads the intended new route through a document navigation', () => {
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
expect(location.reload).not.toHaveBeenCalled()
})
it.each([
'Failed to fetch dynamically imported module: /assets/page.js',
'Importing a module script failed',
'ChunkLoadError: Loading chunk 42 failed',
'Loading CSS chunk page failed',
'Unable to preload CSS for /assets/page-123.css',
])('recognizes stale deployed route assets: %s', (message) => {
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
})
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
const deps = dependencies()
await expect(recoverNavigationError(
new TypeError('Failed to fetch dynamically imported module'),
'/overseas-warehouse/inventory',
deps,
)).resolves.toBe(true)
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
expect(deps.showError).not.toHaveBeenCalled()
})
it('asks before discarding dirty work and stays put when the user cancels', async () => {
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
expect(deps.navigate).not.toHaveBeenCalled()
})
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
const storage = memoryStorage()
const deps = dependencies({ storage })
const error = new Error('ChunkLoadError: Loading chunk page failed')
await recoverNavigationError(error, '/system/users', deps)
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
const reloaded = dependencies({ storage })
await recoverNavigationError(error, '/system/redirect', reloaded)
expect(reloaded.navigate).not.toHaveBeenCalled()
reloaded.now = () => 31_000
await recoverNavigationError(error, '/system/users', reloaded)
expect(reloaded.navigate).toHaveBeenCalledOnce()
})
it('reports ordinary router failures without reloading the application', async () => {
const deps = dependencies()
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
})
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
const storage = memoryStorage()
storage[method] = () => { throw new Error('Storage unavailable') }
const deps = dependencies({ storage })
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
})
it('preserves the target query and hash after the user confirms discarding work', async () => {
const deps = dependencies({ hasUnsavedWork: () => true })
const target = '/orders?status=NEW&page=3#details'
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
expect(deps.navigate).toHaveBeenCalledWith(target)
})
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
let finishConfirmation!: (value: boolean) => void
const deps = dependencies({
hasUnsavedWork: () => true,
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
})
const recover = createNavigationRecoveryHandler(deps)
const cachedError = new Error('Importing a module script failed')
const first = recover(cachedError, '/orders')
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
await Promise.resolve()
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
finishConfirmation(false)
await Promise.all([first, duplicate])
expect(deps.navigate).not.toHaveBeenCalled()
deps.confirmDiscard = vi.fn(async () => true)
await recover(cachedError, '/orders')
expect(deps.navigate).toHaveBeenCalledOnce()
})
it('deduplicates the same error reported by Vite and the router', async () => {
const deps = dependencies()
const recover = createNavigationRecoveryHandler(deps)
const error = new Error('Importing a module script failed')
await recover(error, '/orders')
await recover(error, '/orders')
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).not.toHaveBeenCalled()
})
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
const deps = dependencies()
const recover = createNavigationRecoveryHandler(deps)
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
try {
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
payload: new Error('Cannot read properties of undefined'),
})
window.dispatchEvent(ordinary)
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
reason: new Error('Request failed with status code 500'),
})
window.dispatchEvent(apiError)
await Promise.resolve()
expect(deps.navigate).not.toHaveBeenCalled()
expect(apiError.defaultPrevented).toBe(false)
const error = new Error('Unable to preload CSS for /assets/page.css')
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
window.dispatchEvent(preload)
expect(preload.defaultPrevented).toBe(false)
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
window.dispatchEvent(rejection)
expect(rejection.defaultPrevented).toBe(true)
await recover(error, '/orders?page=2')
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
} finally {
removeListeners()
}
})
})
+139
View File
@@ -0,0 +1,139 @@
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
const RECOVERY_WINDOW_MS = 30_000
const staleAssetErrorPatterns = [
/failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/importing a module script failed/i,
/chunkloaderror/i,
/loading (css )?chunk [\w-]+ failed/i,
/unable to preload css for/i,
]
export interface NavigationRecoveryDependencies {
storage: Pick<Storage, 'getItem' | 'setItem'>
now: () => number
hasUnsavedWork: () => boolean
confirmDiscard: () => Promise<boolean>
discardUnsavedWork: () => void
navigate: (target: string) => void
showError: (message: string) => void
}
interface RecoveryAttempt {
target: string
attemptedAt: number
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
return typeof error === 'string' ? error : ''
}
export function isStaleRouteAssetError(error: unknown) {
const message = errorMessage(error)
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
}
export async function recoverNavigationError(
error: unknown,
target: string,
dependencies: NavigationRecoveryDependencies,
) {
if (!isStaleRouteAssetError(error)) {
dependencies.showError('页面切换失败,请重试')
return false
}
// Keep this marker across successful routing and full reloads. A redirect to
// another route must not bypass the cooldown either.
try {
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
let previous: RecoveryAttempt | null = null
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
if (previous && Number.isFinite(previous.attemptedAt)
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
return false
}
} catch {
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
return false
}
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
try {
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
target,
attemptedAt: dependencies.now(),
} satisfies RecoveryAttempt))
} catch {
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
return false
}
dependencies.discardUnsavedWork()
dependencies.navigate(target)
return true
}
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
let handledErrors = new WeakSet<object>()
let pending: Promise<boolean> | undefined
const guardedDependencies = {
...dependencies,
confirmDiscard: async () => {
const confirmed = await dependencies.confirmDiscard()
// Some importers cache the rejected promise/error. Cancelling must not
// prevent a later explicit retry from asking again.
if (!confirmed) handledErrors = new WeakSet<object>()
return confirmed
},
}
return (error: unknown, target: string): Promise<boolean> => {
if (typeof error === 'object' && error !== null) {
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
handledErrors.add(error)
}
if (pending) return pending
// Defer execution so simultaneous router, Vite and Vue errors share one
// confirmation/reload, even when there is no unsaved form.
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
.finally(() => { pending = undefined })
return pending
}
}
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
const current = new URL(browser.location.href)
const destination = new URL(target, current)
if (destination.pathname === current.pathname && destination.search === current.search) {
// Assigning a URL that only changes the fragment does not reload assets.
browser.history.replaceState(browser.history.state, '', destination.href)
browser.location.reload()
} else {
browser.location.assign(destination.href)
}
}
export function installAssetRecoveryListeners(
browser: Window,
target: () => string,
recover: (error: unknown, target: string) => Promise<boolean>,
) {
const onPreloadError = (event: Event) => {
const error = (event as Event & { payload?: unknown }).payload
if (isStaleRouteAssetError(error)) void recover(error, target())
// Let Vite reject the import: swallowing it would make Vue Router treat
// an undefined module as a successful navigation, including on cancel.
}
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
if (!isStaleRouteAssetError(event.reason)) return
event.preventDefault()
void recover(event.reason, target())
}
browser.addEventListener('vite:preloadError', onPreloadError)
browser.addEventListener('unhandledrejection', onUnhandledRejection)
return () => {
browser.removeEventListener('vite:preloadError', onPreloadError)
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
}
}
+69 -20
View File
@@ -1,27 +1,76 @@
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { test } from 'vitest'
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
const root = process.cwd()
const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8')
const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8')
const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8')
const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8')
describe('admin nginx deployment config', () => {
it('allows qualification uploads with multipart overhead through the API proxy', () => {
expect(config).toContain('client_max_body_size 12m;')
})
test('all business traffic uses one Gateway without rewriting API paths', () => {
assert.match(config, /location \/api\//)
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
assert.match(config, /client_max_body_size 55m;/)
assert.match(config, /proxy_set_header Host \$proxy_host;/)
assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
})
it('prevents the application shell from retaining stale chunk references', () => {
expect(config).toContain('location = /index.html')
expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"')
})
test('test deployment requires an explicit shared network, Gateway and version', () => {
for (const key of ['IMAGE_TAG']) {
assert.ok(compose.includes('${' + key + ':?'))
}
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
assert.match(compose, /external: true/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
assert.match(jenkins, /'java-builder' : 'java-test'/)
assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
assert.match(jenkins, /docker network inspect/)
assert.match(jenkins, /scripts\/deploy-production\.sh/)
assert.match(jenkins, /firstleg-production-ssh/)
it('serves hashed assets immutably and never falls back missing chunks to HTML', () => {
expect(config).toContain('location /assets/')
expect(config).toContain('try_files $uri =404;')
expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"')
})
})
it('keeps Vue history routes on the application shell without caching them', () => {
expect(config).toContain('try_files $uri $uri/ /index.html;')
expect(config).toContain('Cache-Control "no-cache"')
})
test('application shell is revalidated and missing chunks remain 404', () => {
assert.match(config, /location = \/index\.html/)
assert.match(config, /no-store/)
assert.match(config, /max-age=31536000, immutable/)
assert.match(config, /try_files \$uri =404;/)
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
})
test('PDA static apps keep independent paths and share the authenticated Gateway', () => {
assert.match(config, /resolver 127\.0\.0\.11 valid=10s ipv6=off;/)
for (const app of ['domestic', 'overseas']) {
assert.ok(config.includes(`location = /pda-${app} { return 308 /pda-${app}/; }`))
assert.ok(config.includes(`location ^~ /pda-${app}/ {`))
assert.ok(config.includes(`set $pda_${app}_origin http://pda-${app}-h5-test:80;`))
assert.ok(config.includes(`proxy_pass $pda_${app}_origin$request_uri;`))
}
assert.match(config, /location \/gateway-api\/\s*\{[^}]*client_max_body_size 55m;[^}]*proxy_pass \$\{API_UPSTREAM\}\/api\//)
assert.doesNotMatch(config, /proxy_pass.*(?:auth:|lastleg-service:|firstleg-service:)/)
})
test('container build excludes local environment values and waits for Nginx health', () => {
const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8')
assert.match(ignore, /^\.env$/m)
assert.match(ignore, /^\.env\.\*$/m)
assert.match(compose, /healthcheck:/)
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
})
test('gray production is isolated from TEST and routes to the single Gateway', () => {
const production = readFileSync(resolve(root, 'docker-compose.production.yml'), 'utf8')
assert.match(production, /name: firstleg-production-network/)
assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/)
assert.match(production, /127\.0\.0\.1:/)
assert.match(production, /healthcheck:/)
assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/)
assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/)
})
+45 -3
View File
@@ -1,11 +1,14 @@
import '@/styles/adminDensity.css'
import { computed, createApp } from 'vue'
import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import App from './App.vue'
import router from '@/app/router'
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
@@ -19,8 +22,6 @@ const auth = useAuthStore(pinia)
const unsaved = useUnsavedWorkStore(pinia)
const workspace = useWorkspaceTabsStore(pinia)
await auth.restoreSession()
registerAuthSessionExpiredHandler(async () => {
const currentRoute = router.currentRoute.value
if (currentRoute.name === 'login') return
@@ -36,6 +37,42 @@ registerAuthSessionExpiredHandler(async () => {
})
})
const recoverAssetError = createNavigationRecoveryHandler({
storage: {
getItem: (key) => window.sessionStorage.getItem(key),
setItem: (key, value) => window.sessionStorage.setItem(key, value),
},
now: Date.now,
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
confirmDiscard: async () => {
try {
await ElMessageBox.confirm(
'页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
'重新加载页面?',
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
)
return true
} catch {
return false
}
},
discardUnsavedWork: () => unsaved.clear(),
navigate: (target) => reloadNavigationTarget(window, target),
showError: (message) => ElMessage.error(message),
})
let pendingRoute: string | undefined
const recoveryTarget = () => pendingRoute
?? `${window.location.pathname}${window.location.search}${window.location.hash}`
router.beforeEach((to) => { pendingRoute = to.fullPath })
router.onError((error, to) => {
void recoverAssetError(error, to.fullPath)
if (pendingRoute === to.fullPath) pendingRoute = undefined
})
router.afterEach((to) => {
if (pendingRoute === to.fullPath) pendingRoute = undefined
})
installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError)
window.addEventListener('beforeunload', (event) => {
if (!unsaved.entries.some((entry) => entry.dirty)) return
event.preventDefault()
@@ -43,5 +80,10 @@ window.addEventListener('beforeunload', (event) => {
})
const app = createApp(App)
app.config.errorHandler = (error) => {
if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget())
else console.error(error)
}
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
await auth.restoreSession()
app.use(pinia).use(router).mount('#app')
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getPlatformAuditLog, listPlatformAuditLogs } from './platformAudit'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('platform audit api', () => {
beforeEach(() => vi.clearAllMocks())
it('queries the selected customer and preserves long ids as strings', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 1, pages: 1 } })
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: { accountId: '8', page: 1, size: 20 } })
expect(result.records[0].id).toBe('2082813911786024961')
expect(result.records[0].clientIp).toBe('127.0.0.1')
expect(result.total).toBe(1)
expect(result.pages).toBe(1)
})
it('preserves the server total when the current page has fewer records', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }],
page: 1, size: 20, total: 13, pages: 1,
} })
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
expect(result.records).toHaveLength(1)
expect(result.total).toBe(13)
expect(result.pages).toBe(1)
})
it('maps audit filters and omits empty query values', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listPlatformAuditLogs({
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
occurredFrom: '2026-08-01T00:00:00.000Z', occurredTo: '2026-08-13T23:59:59.999Z',
eventType: '', resourceType: undefined,
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: {
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
occurredFrom: '2026-08-01T00:00:00.000Z', occurredTo: '2026-08-13T23:59:59.999Z',
} })
})
it('loads one audit detail', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } })
const result = await getPlatformAuditLog('1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs/1')
expect(result.afterData).toBe('{"name":"new"}')
})
})
+107
View File
@@ -0,0 +1,107 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface PlatformAuditLogListItem {
id: string
eventId: string
eventType: string
resourceType: string
resourceId: string | null
description: string | null
accountId: string
operatorTenantId: string
userId: string
username: string | null
appCode: string | null
clientType: string | null
clientIp: string | null
requestId: string | null
traceId: string | null
serviceName: string | null
success: boolean
errorCode: string | null
errorMessage: string | null
occurredAt: string
createdAt: string
}
export interface PlatformAuditLogDetail extends PlatformAuditLogListItem {
httpMethod: string | null
requestUri: string | null
clientIp: string | null
durationMs: number | null
beforeData: string | null
afterData: string | null
}
export interface PlatformAuditLogPage {
records: PlatformAuditLogListItem[]
page: number
size: number
total: number
pages: number
}
export interface PlatformAuditLogQuery {
accountId: string
page?: number
size?: number
operatorUserId?: string
eventType?: string
resourceType?: string
resourceId?: string
success?: boolean
occurredFrom?: string
occurredTo?: string
keyword?: string
}
type RawId = string | number | null
type RawListItem = Omit<PlatformAuditLogListItem, 'id' | 'accountId' | 'operatorTenantId' | 'userId' | 'resourceId'> & {
id: RawId
accountId: RawId
operatorTenantId: RawId
userId: RawId
resourceId: RawId
}
type RawDetail = Omit<PlatformAuditLogDetail, keyof PlatformAuditLogListItem> & RawListItem
type RawPage = Omit<PlatformAuditLogPage, 'records' | 'page' | 'size' | 'total' | 'pages'> & {
records?: RawListItem[]
page: number | string
size: number | string
total: number | string
pages?: number | string
}
const basePath = '/platform/v1/audit/logs'
const id = (value: RawId) => value == null ? '' : String(value)
function normalizeListItem(item: RawListItem): PlatformAuditLogListItem {
return {
...item,
id: id(item.id),
accountId: id(item.accountId),
operatorTenantId: id(item.operatorTenantId),
userId: id(item.userId),
resourceId: item.resourceId == null ? null : String(item.resourceId),
}
}
export async function listPlatformAuditLogs(query: PlatformAuditLogQuery) {
const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== null && value !== undefined))
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(basePath, { params })
const data = unwrapApiResponse(response)
const size = Number(data.size || query.size || 20)
const total = Number(data.total || 0)
return {
records: (data.records || []).map(normalizeListItem),
page: Number(data.page || query.page || 1),
size,
total,
pages: Number(data.pages ?? (total > 0 ? Math.ceil(total / size) : 0)),
} satisfies PlatformAuditLogPage
}
export async function getPlatformAuditLog(idValue: string) {
const response = await http.get<ApiEnvelope<RawDetail>, ApiEnvelope<RawDetail>>(`${basePath}/${encodeURIComponent(idValue)}`)
return normalizeListItem(unwrapApiResponse(response)) as PlatformAuditLogDetail
}
@@ -0,0 +1,134 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
function mountPanel(permissions: string[]) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return mount(CustomerAuditLogsPanel, { props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [pinia, ElementPlus] } })
}
describe('CustomerAuditLogsPanel', () => {
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
it('queries logs with the current customer id', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20 })
expect(wrapper.text()).toContain('编辑客户')
expect(wrapper.text()).toContain('127.0.0.1')
expect(wrapper.text()).toContain('编辑客户档案')
expect(wrapper.text()).not.toContain('PLATFORM_TENANT_UPDATE')
expect(wrapper.text()).toContain('平台管理员')
expect(wrapper.text()).toContain('客户与权限服务')
})
it('shows inbound audit events in Chinese without wrapping technical codes into the table', async () => {
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, eventType: 'INBOUND_MEASURE_APPROVE', description: '测量审核通过', serviceName: 'firstleg-service' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(wrapper.text()).toContain('入库测量审核通过')
expect(wrapper.text()).toContain('头程履约服务')
expect(wrapper.text()).not.toContain('INBOUND_MEASURE_APPROVE')
})
it('shows the server total instead of the current page row count', async () => {
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 13, pages: 1 })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
expect(wrapper.text()).toContain('共 13 条客户操作记录')
})
it('applies keyword, result and local date range only after search', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { keyword: ' admin ', success: false, occurredRange: ['2026-08-01', '2026-08-13'] })
await wrapper.vm.$nextTick()
expect(listPlatformAuditLogs).not.toHaveBeenCalled()
toolbar.vm.$emit('search', { keyword: ' admin ', success: false, occurredRange: ['2026-08-01', '2026-08-13'] })
await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(listPlatformAuditLogs).toHaveBeenCalledWith({
accountId: '8', page: 1, size: 20, keyword: 'admin', success: false,
occurredFrom: new Date(2026, 7, 1, 0, 0, 0, 0).toISOString(),
occurredTo: new Date(2026, 7, 13, 23, 59, 59, 999).toISOString(),
})
})
it('resets to all logs and requests once on the first page', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { keyword: 'admin', success: true, occurredRange: ['2026-08-01', '2026-08-13'] })
toolbar.vm.$emit('search', { keyword: 'admin', success: true, occurredRange: ['2026-08-01', '2026-08-13'] })
await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
;(toolbar.vm as unknown as { reset: () => void }).reset()
await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(QueryToolbar).props('modelValue')).toEqual({ keyword: '', success: '', occurredRange: [] })
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
})
it('does not query without audit permission', async () => {
const wrapper = mountPanel([]); await flushPromises()
expect(listPlatformAuditLogs).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('暂无操作日志查看权限')
})
it('loads detail and formats json snapshots', async () => {
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
expect(wrapper.text()).toContain('旧名称')
expect(wrapper.text()).toContain('新名称')
expect(wrapper.text()).toContain('入库测量审核通过')
expect(wrapper.text()).toContain('入库测量')
expect(wrapper.text()).toContain('头程履约服务')
expect(wrapper.text()).toContain('更新')
expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
})
it('loads the selected audit page', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
wrapper.findComponent(QueryToolbar).vm.$emit('search', { keyword: 'tenant', success: true, occurredRange: [] })
await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
;(wrapper.vm as unknown as { page: number }).page = 2
await flushPromises()
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 2, size: 20, keyword: 'tenant', success: true, occurredFrom: undefined, occurredTo: undefined })
})
it('clears draft and applied filters when switching customers', async () => {
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { keyword: 'draft', success: false, occurredRange: [] })
toolbar.vm.$emit('search', { keyword: 'applied', success: true, occurredRange: [] })
await flushPromises()
vi.mocked(listPlatformAuditLogs).mockClear()
await wrapper.setProps({ customerId: '9' })
await flushPromises()
expect(wrapper.findComponent(QueryToolbar).props('modelValue')).toEqual({ keyword: '', success: '', occurredRange: [] })
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
})
})
@@ -0,0 +1,185 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { ref, watch } from 'vue'
import { CircleCheck, CircleX, ScrollText } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail, type PlatformAuditLogListItem, type PlatformAuditLogQuery } from '../api/platformAudit'
import { auditDescriptionLabel, auditEventLabel, auditHttpMethodLabel, auditOperatorLabel, auditResourceLabel, auditServiceLabel } from '../presentation'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
const rows = ref<PlatformAuditLogListItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<PlatformAuditLogDetail | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
const emptyFilters = (): QueryModel => ({ keyword: '', success: '', occurredRange: [] })
const filters = ref<QueryModel>(emptyFilters())
const appliedFilters = ref<QueryModel>(emptyFilters())
let requestSerial = 0
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '操作内容/操作人/资源或请求标识' },
{ key: 'success', type: 'select', label: '结果', width: 150, options: [{ label: '成功', value: true }, { label: '失败', value: false }] },
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'description', label: '操作内容', minWidth: 220, slotName: 'description' },
{ key: 'clientIp', label: 'IP 地址', width: 140, slotName: 'clientIp' },
{ key: 'eventType', label: '事件类型', minWidth: 180, slotName: 'eventType' },
{ key: 'username', label: '操作人', width: 130, slotName: 'username' },
{ key: 'serviceName', label: '来源服务', width: 145, slotName: 'serviceName' },
{ key: 'success', label: '结果', width: 90, slotName: 'success' },
{ key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' },
]
const display = (value?: string | null) => value?.trim() || '—'
const formatDate = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
function formatSnapshot(value?: string | null) {
if (!value?.trim()) return '无'
try { return JSON.stringify(JSON.parse(value), null, 2) }
catch { return value }
}
function localDayIso(value: string, end: boolean) {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return undefined
return new Date(year, month - 1, day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0, end ? 999 : 0).toISOString()
}
function toQuery(customerId: string): PlatformAuditLogQuery {
const range = Array.isArray(appliedFilters.value.occurredRange) ? appliedFilters.value.occurredRange.map(String) : []
const success = appliedFilters.value.success
return {
accountId: customerId,
page: page.value,
size: pageSize.value,
keyword: String(appliedFilters.value.keyword || '').trim() || undefined,
success: typeof success === 'boolean' ? success : undefined,
occurredFrom: range[0] ? localDayIso(range[0], false) : undefined,
occurredTo: range[1] ? localDayIso(range[1], true) : undefined,
}
}
async function load() {
const customerId = props.customerId.trim()
const serial = ++requestSerial
rows.value = []
total.value = 0
loadError.value = null
if (!customerId || !access.can('platform:audit:list')) return
loading.value = true
try {
const result = await listPlatformAuditLogs(toQuery(customerId))
if (serial !== requestSerial || customerId !== props.customerId.trim()) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (serial === requestSerial) loadError.value = getApiErrorDetails(error, '客户操作日志加载失败,请稍后重试')
} finally {
if (serial === requestSerial) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || '').trim(),
success: typeof model.success === 'boolean' ? model.success : '',
occurredRange: Array.isArray(model.occurredRange) ? model.occurredRange.map(String) : [],
}
if (page.value === 1) void load()
else page.value = 1
}
async function openDetail(row: PlatformAuditLogListItem) {
detailOpen.value = true
detailLoading.value = true
detail.value = null
detailError.value = null
try { detail.value = await getPlatformAuditLog(row.id) }
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
finally { detailLoading.value = false }
}
watch(() => props.customerId, () => {
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
if (page.value === 1) void load()
else page.value = 1
}, { immediate: true })
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void load()
})
</script>
<template>
<section class="customer-audit-panel admin-density">
<div class="panel-title"><div><h2>操作日志</h2><p>{{ customerName }}已归属当前客户的档案与业务操作记录</p></div><ScrollText :size="19" /></div>
<div v-if="!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" /><strong>暂无操作日志查看权限</strong><p>请联系管理员开通平台操作日志查看权限。</p></div>
<template v-else>
<QueryToolbar v-model="filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
<template #description="{ row }"><el-tooltip :content="auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement="top" :show-after="400"><span class="cell-clamp">{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}</span></el-tooltip></template>
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
<template #username="{ row }">{{ auditOperatorLabel((row as PlatformAuditLogListItem).username) }}</template>
<template #serviceName="{ row }"><el-tooltip :content="display((row as PlatformAuditLogListItem).serviceName)" placement="top" :show-after="500"><span class="service-label">{{ auditServiceLabel((row as PlatformAuditLogListItem).serviceName) }}</span></el-tooltip></template>
<template #success="{ row }"><el-tag :type="(row as PlatformAuditLogListItem).success ? 'success' : 'danger'">{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}</el-tag></template>
<template #occurredAt="{ row }">{{ formatDate((row as PlatformAuditLogListItem).occurredAt) }}</template>
<template #pagination-left>共 {{ total }} 条客户操作记录</template>
</DataTable>
</template>
<AppDrawer class="customer-audit-drawer admin-density-drawer" v-model="detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="audit-detail-hero" :class="detail.success ? 'is-success' : 'is-failed'">
<span class="result-icon" aria-hidden="true"><CircleCheck v-if="detail.success" :size="22" /><CircleX v-else :size="22" /></span>
<div class="hero-copy"><span>事件类型</span><h3>{{ auditEventLabel(detail.eventType) }}</h3><p>{{ auditDescriptionLabel(detail.description) }}</p></div>
<el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag>
</section>
<section class="audit-detail-grid" aria-label="操作日志基础信息">
<div><span>操作人</span><strong>{{ auditOperatorLabel(detail.username) }}</strong></div>
<div><span>发生时间</span><strong>{{ formatDate(detail.occurredAt) }}</strong></div>
<div><span>来源服务</span><strong>{{ auditServiceLabel(detail.serviceName) }}</strong><small>{{ display(detail.serviceName) }}</small></div>
<div><span>客户端 IP</span><strong>{{ display(detail.clientIp) }}</strong></div>
<div><span>业务资源</span><strong>{{ auditResourceLabel(detail.resourceType) }}</strong><small>{{ display(detail.resourceId) }}</small></div>
<div><span>处理耗时</span><strong>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</strong></div>
<div class="wide request-cell"><span>请求地址</span><div><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></div></div>
<div class="wide identity-cell"><span>请求标识</span><strong>{{ display(detail.requestId) }}</strong><small v-if="detail.traceId">链路标识:{{ detail.traceId }}</small></div>
</section>
<el-alert v-if="!detail.success" class="failure-alert" type="error" :closable="false" show-icon :title="display(detail.errorMessage || detail.errorCode)" />
<div class="snapshot-grid"><section><h3>操作前快照</h3><p>操作执行前保留的业务数据</p><pre>{{ formatSnapshot(detail.beforeData) }}</pre></section><section><h3>操作结果快照</h3><p>操作执行后返回或保存的业务数据</p><pre>{{ formatSnapshot(detail.afterData) }}</pre></section></div>
</template>
</AppDrawer>
</section>
</template>
<style scoped>
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; max-width:100%; padding:3px 9px; overflow:hidden; border:1px solid #dbeafe; border-radius:999px; background:#eff6ff; color:#1d4ed8; font-size:var(--admin-font-caption, 12px); line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:#475467; text-overflow:ellipsis; white-space:nowrap; }
.audit-detail-hero { display:flex; align-items:flex-start; gap:13px; padding:var(--admin-space-3, 8px) var(--admin-space-4, 10px); border:1px solid #d1fadf; border-radius:0; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; height:var(--admin-tab-height, 38px); flex:0 0 auto; place-items:center; border-radius:50%; background:#dcfae6; color:#079455; }.is-failed .result-icon { background:#fee4e2; color:#d92d20; }.hero-copy { min-width:0; flex:1; }.hero-copy > span { color:#667085; font-size:var(--admin-font-caption, 12px); }.hero-copy h3 { margin:3px 0 var(--admin-space-1, 4px); color:#101828; font-size:var(--admin-font-section, 14px); }.hero-copy p { margin:0; color:#475467; font-size:var(--admin-font-body, 13px); line-height:1.55; }
.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:var(--admin-space-6, 16px); overflow:hidden; border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.audit-detail-grid > div { display:flex; min-width:0; min-height:var(--admin-row-height, 36px); flex-direction:column; justify-content:center; gap:5px; padding:var(--admin-space-2, 6px) var(--admin-space-4, 10px); border-right:1px solid #eaecf0; border-bottom:1px solid #eaecf0; }.audit-detail-grid > div:nth-child(3n) { border-right:0; }.audit-detail-grid > div.wide { grid-column:1/-1; border-right:0; }.audit-detail-grid > div:last-child { border-bottom:0; }.audit-detail-grid span { color:#667085; font-size:var(--admin-font-caption, 12px); }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:var(--admin-font-section, 14px); font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.request-cell > div { display:flex; min-width:0; align-items:center; gap:9px; }.request-cell code { min-width:0; overflow-wrap:anywhere; color:#344054; font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }.failure-alert { margin-top:var(--admin-space-6, 16px); }
.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-6, 16px); margin-top:18px; }.snapshot-grid section { min-width:0; padding:var(--admin-space-4, 10px); border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.snapshot-grid h3 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.snapshot-grid p { margin:var(--admin-space-1, 4px) 0 var(--admin-space-4, 10px); color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:var(--admin-label-bg, #f8fafc); color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
@media(max-width:720px){.audit-detail-grid{grid-template-columns:1fr}.audit-detail-grid>div,.audit-detail-grid>div:nth-child(3n){border-right:0}.audit-detail-grid>div.wide{grid-column:auto}.snapshot-grid{grid-template-columns:1fr}.audit-detail-hero{align-items:flex-start}.audit-detail-hero>.el-tag{flex:0 0 auto}}
</style>
+1
View File
@@ -0,0 +1 @@
export { default as CustomerAuditLogsPanel } from './components/CustomerAuditLogsPanel.vue'
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { auditDescriptionLabel, auditEventLabel, auditHttpMethodLabel, auditOperatorLabel, auditResourceLabel, auditServiceLabel } from './presentation'
describe('audit event presentation', () => {
it('uses customer-facing labels for known event codes', () => {
expect(auditEventLabel('PLATFORM_TENANT_UPDATE')).toBe('编辑客户档案')
expect(auditEventLabel('TENANT_REVIEW')).toBe('客户入驻审核')
expect(auditEventLabel('INBOUND_MEASURE_APPROVE')).toBe('入库测量审核通过')
expect(auditEventLabel('INBOUND_WEIGHT_OCR_CONFIRM')).toBe('确认 OCR 重量')
})
it('uses a Chinese fallback instead of exposing an unknown technical code', () => {
expect(auditEventLabel('INBOUND_CARTON_CREATE')).toBe('入库箱码创建')
expect(auditEventLabel('FUTURE_CUSTOMER_EVENT')).toBe('客户')
expect(auditEventLabel('')).toBe('—')
})
it('localizes service, resource, operator and request method values', () => {
expect(auditServiceLabel('firstleg-service')).toBe('头程履约服务')
expect(auditResourceLabel('INBOUND_MEASUREMENT')).toBe('入库测量')
expect(auditOperatorLabel('platform_admin')).toBe('平台管理员')
expect(auditHttpMethodLabel('POST')).toBe('提交')
})
it('prefers the Chinese half of a bilingual audit description', () => {
expect(auditDescriptionLabel('Approve order / 审核通过')).toBe('审核通过')
expect(auditDescriptionLabel('测量审核通过')).toBe('测量审核通过')
})
})
+129
View File
@@ -0,0 +1,129 @@
const auditEventLabels: Record<string, string> = {
TENANT_REGISTER: '客户注册',
TENANT_REVIEW: '客户入驻审核',
PLATFORM_TENANT_UPDATE: '编辑客户档案',
PLATFORM_TENANT_STATUS_CHANGE: '启停客户',
PLATFORM_TENANT_SUBMIT_REVIEW: '提交客户审核',
TENANT_PROFILE_UPDATE: '编辑企业资料',
PLATFORM_CUSTOMER_ARCHIVE: '归档客户',
PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET: '重置企业管理员密码',
PLATFORM_CUSTOMER_IMPERSONATION_TICKET_CREATE: '创建客户代登录授权',
TENANT_ADDRESS_CREATE: '新增客户地址',
TENANT_QUALIFICATION_UPLOAD: '上传企业资质',
PLATFORM_TENANT_QUALIFICATION_UPLOAD: '平台上传企业资质',
TENANT_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD: '上传必填资质包',
PLATFORM_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD: '平台上传必填资质包',
INBOUND_MEASURE_APPROVE: '入库测量审核通过',
INBOUND_MEASURE_REJECT: '驳回入库测量',
INBOUND_MEASURE_EXCEPTION_RELEASE: '解除测量异常',
INBOUND_CARTON_SCAN: '扫描入库箱码',
INBOUND_CARTON_MANUAL_ENTRY: '手工录入箱码',
INBOUND_CARTON_MEASUREMENT_COUNT_CONFIRM: '确认测量箱数',
INBOUND_MEASUREMENT_DRAFT_SAVE: '保存入库测量草稿',
INBOUND_MEASUREMENT_SUBMIT: '提交入库测量审核',
INBOUND_WEIGHT_IMAGE_DELETE: '删除称重图片',
INBOUND_WEIGHT_OCR_RECORD: '登记称重图片 OCR 原值',
INBOUND_WEIGHT_OCR_CORRECT: '修正 OCR 重量',
INBOUND_WEIGHT_OCR_CONFIRM: '确认 OCR 重量',
INBOUND_SMART_SPLIT: '执行智能分柜',
INBOUND_START_PICKING: '开始拣货',
INBOUND_COMPLETE_PICKING: '完成拣货',
INBOUND_COMPLETE_LOADING: '完成装柜',
INBOUND_LOADING_EVIDENCE_UPSERT: '保存装柜证据',
INBOUND_LOADING_EVIDENCE_VOID: '作废装柜证据',
ORDER_CREATE: '创建运单',
ORDER_UPDATE: '编辑运单',
ORDER_SUBMIT: '提交运单',
ORDER_APPROVE: '审核通过运单',
ORDER_REJECT: '驳回运单',
ORDER_CANCEL: '取消运单',
ORDER_ACCEPT: '受理运单',
ORDER_START: '开始处理运单',
ORDER_COMPLETE: '完成运单',
OCEAN_BOOKING_CREATE: '创建海运订舱',
OCEAN_BOOKING_RELEASE: '确认放舱',
OCEAN_BOOKING_CANCEL: '取消海运订舱',
CONTAINER_CREATE: '创建柜次',
CONTAINER_UPDATE: '编辑柜次',
CONTAINER_TRANSPORT_UPDATE: '更新柜次运输信息',
OUTBOUND_CONFIRM_DISPATCH: '确认国内出仓',
OUTBOUND_REVERSE_DISPATCH: '撤销国内出仓',
PDA_COMPLETE_LOADING: 'PDA 完成装柜',
}
const serviceLabels: Record<string, string> = {
'iam-service': '客户与权限服务',
'auth-service': '认证服务',
'order-service': '订单服务',
'firstleg-service': '头程履约服务',
'finance-service': '财务服务',
'warehouse-service': '仓储服务',
'file-service': '文件服务',
'audit-service': '审计服务',
'integration-service': '外部集成服务',
'event-bridge-service': '事件桥接服务',
gateway: '网关服务',
}
const resourceLabels: Record<string, string> = {
TENANT: '客户',
CUSTOMER: '客户',
FIRST_LEG_ORDER: '头程运单',
INBOUND: '入库单',
INBOUND_MEASUREMENT: '入库测量',
CONTAINER: '柜次',
OCEAN_BOOKING: '海运订舱',
LOADING_PLAN: '装柜计划',
OUTBOUND_DISPATCH: '国内出仓',
QUALIFICATION: '企业资质',
MEMBER: '企业成员',
}
const eventTokenLabels: Record<string, string> = {
TENANT: '客户', CUSTOMER: '客户', ORDER: '运单', FIRSTLEG: '头程履约', INBOUND: '入库',
MEASURE: '测量', MEASUREMENT: '测量', WEIGHT: '重量', OCR: 'OCR', CARTON: '箱码',
CONTAINER: '柜次', OCEAN: '海运', BOOKING: '订舱', OUTBOUND: '出仓', LOADING: '装柜',
CREATE: '创建', UPDATE: '更新', SUBMIT: '提交', APPROVE: '审核通过', REJECT: '驳回',
CANCEL: '取消', CONFIRM: '确认', COMPLETE: '完成', START: '开始', SAVE: '保存',
DELETE: '删除', UPLOAD: '上传', ATTACH: '关联', RELEASE: '放行', RECORD: '记录',
CORRECT: '修正', DRAFT: '草稿', SCAN: '扫描', MANUAL: '手工', ENTRY: '录入',
}
export function auditEventLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
if (auditEventLabels[code]) return auditEventLabels[code]
const translated = code.split('_').map((token) => eventTokenLabels[token]).filter(Boolean).join('')
return translated || '其他业务操作'
}
export function auditDescriptionLabel(value?: string | null) {
const text = value?.trim()
if (!text) return '—'
const chinesePart = text.split(/\s*\/\s*/).find((part) => /[\u3400-\u9fff]/.test(part))
return chinesePart?.trim() || text
}
export function auditServiceLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
return serviceLabels[code] ?? code.replace(/-service$/i, '服务')
}
export function auditResourceLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
return resourceLabels[code] ?? code
}
export function auditOperatorLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
return code === 'platform_admin' ? '平台管理员' : code
}
export function auditHttpMethodLabel(value?: string | null) {
const code = value?.trim().toUpperCase()
if (!code) return '—'
return ({ GET: '查询', POST: '提交', PUT: '更新', PATCH: '变更', DELETE: '删除' } as Record<string, string>)[code] ?? code
}
+49 -1
View File
@@ -6,6 +6,7 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import LoginView from './LoginView.vue'
const loginSource = readFileSync(resolve(process.cwd(), 'src/modules/auth/pages/LoginView.vue'), 'utf8')
@@ -172,8 +173,55 @@ describe('LoginView authentication lifecycle', () => {
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable')
expect(wrapper.get('[role="alert"]').text()).toContain('登录失败,请检查账号和密码后重试')
expect(wrapper.get('[role="alert"]').text()).not.toContain('network unavailable')
expect(initialize).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
})
it('shows the backend credential error and diagnostic identifiers', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/login', component: LoginView },
{ path: '/', component: { template: '<p>home</p>' } },
],
})
await router.push('/login')
await router.isReady()
vi.spyOn(auth, 'login').mockRejectedValue(new ApiResponseError(
'用户名或密码错误',
'A0401',
undefined,
null,
'request-login',
'trace-login',
))
const wrapper = mount(LoginView, {
global: {
plugins: [pinia, router],
stubs: {
'el-form': formStub,
'el-form-item': { template: '<label><slot /></label>' },
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
},
},
})
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('platform_admin')
await inputs[1]!.setValue('incorrect-password')
await wrapper.get('form').trigger('submit')
await flushPromises()
const alert = wrapper.get('[role="alert"]').text()
expect(alert).toContain('用户名或密码错误')
expect(alert).toContain('request-login')
expect(alert).toContain('trace-login')
expect(alert).not.toContain('登录状态已失效')
})
})
+5 -1
View File
@@ -41,7 +41,11 @@ async function submit() {
: requestedPath
await router.replace(targetPath)
} catch (error) {
loginError.value = getApiErrorDetails(error, '登录失败,请检查账号和密码后重试')
loginError.value = getApiErrorDetails(
error,
'登录失败,请检查账号和密码后重试',
{ preserveAuthenticationMessage: true },
)
ElMessage.error(loginError.value.message)
} finally {
loading.value = false
@@ -0,0 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listContainerArrivalTimes, updateContainerExpectedArrival } from './containerArrivalTimes'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { post: vi.fn(), put: vi.fn() } }))
describe('container arrival-time overseas API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries visible containers and updates the whole-container expected arrival', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
await listContainerArrivalTimes(['9001'])
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
})
})
@@ -0,0 +1,37 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface ContainerArrivalTime {
sourceContainerBookingId: string
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
version: number
}
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
function operationId(containerBookingId: string) {
return `container-expected-arrival-${containerBookingId}-${Date.now()}-${crypto.randomUUID()}`
}
export async function listContainerArrivalTimes(sourceContainerBookingIds: string[]) {
if (!sourceContainerBookingIds.length) return []
const response = await http.post<ApiEnvelope<ContainerArrivalTime[]>, ApiEnvelope<ContainerArrivalTime[]>>(
queryPath,
{ sourceContainerBookingIds },
)
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
}
export async function updateContainerExpectedArrival(
sourceContainerBookingId: string,
body: { estimatedWarehouseArrivalAt: string; expectedVersion: number; remark?: string },
) {
const response = await http.put<ApiEnvelope<ContainerArrivalTime>, ApiEnvelope<ContainerArrivalTime>>(
`/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/expected-arrival`,
body,
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
)
const result = unwrapApiResponse(response)
return { ...result, version: Number(result.version || 0) }
}
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerFeeSaveRequest } from './overseasContainerFees'
const raw = { id: 91, sourceFeeId: 81, sourceVersion: 2, customerId: 11, customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: 99, businessNo: 'CONT-99', containerBookingId: 99, containerNo: 'CONT-99', feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', amount: 300, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'ACTIVE', businessDate: '2026-09-08' }
const fee: OverseasContainerFeeSaveRequest = { customerId: '11', customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: '99', businessNo: 'CONT-99', orderId: null, inboundId: null, inboundCode: null, containerId: null, containerBookingId: '99', containerNo: 'CONT-99', warehouseId: null, feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', supplierId: '71', supplierName: '港口服务商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null, businessDate: '2026-09-08', currency: 'CNY', amount: 300 }
describe('overseas container fees API', () => {
beforeEach(() => vi.restoreAllMocks())
it('loads all fees and customers with two container aggregate requests', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] })
await expect(listOverseasContainerFees('99')).resolves.toMatchObject([{ sourceFeeId: '81', containerBookingId: '99', feeItemCode: 'PORT_FEE' }])
await expect(listOverseasContainerCustomers('99')).resolves.toEqual([{ customerId: '11', customerName: '客户A' }])
expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/finance-fee-sources', { params: { containerBookingId: '99', businessType: 'OVERSEAS_CONTAINER' } })
expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/finance-fee-sources/container-customers', { params: { containerBookingId: '99' } })
})
it('uses the exact create and lifecycle action contracts', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: raw })
await createOverseasContainerFee(fee, 'create-1'); await submitOverseasContainerFee('81', 'submit-1'); await reviewOverseasContainerFee('81', 'APPROVE', null, 'review-1'); await adjustOverseasContainerFee('81', 2, fee, 'adjust-1'); await voidOverseasContainerFee('81', 2, '重复费用', 'void-1')
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources', fee, { headers: { 'Idempotency-Key': 'create-1' } })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/submit', { idempotencyKey: 'submit-1' })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/review', { decision: 'APPROVE', reason: null, idempotencyKey: 'review-1' })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' })
expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' })
})
it('preserves a pending financial cancellation without marking the source voided', async () => {
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { ...raw, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' } })
await expect(voidOverseasContainerFee('81', 2, '重复费用', 'void-1')).resolves.toMatchObject({ sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' })
})
})
@@ -0,0 +1,68 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
const basePath = '/platform/v1/last-leg/finance-fee-sources'
type RawResource = Record<string, unknown>
export type OverseasContainerFeeItem = 'PORT_FEE' | 'STORAGE_FEE' | 'CONTAINER_REPAIR_FEE' | 'CRANE_FEE' | 'TERMINAL_DRAYAGE_FEE' | 'OTHER'
export type OverseasContainerFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface OverseasContainerCustomer { customerId: string; customerName: string | null }
export interface OverseasContainerFeeSource {
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string | null
sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'; businessId: string; businessNo: string
containerBookingId: string; containerNo: string; feeItemCode: OverseasContainerFeeItem; feeItemName: string
direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null
settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null
paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number
reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED'
reviewReason: string | null; reviewedAt: string | null
voidStatus?: string | null
}
export interface OverseasContainerFeeSaveRequest {
customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER'
businessId: string; businessNo: string; orderId: null; inboundId: null; inboundCode: null; containerId: null
containerBookingId: string; containerNo: string; warehouseId: null; feeItemCode: OverseasContainerFeeItem; feeItemName: string
direction: OverseasContainerFeeDirection; supplierId: string | null; supplierName: string | null; supplierCategory: string | null
settlementMode: 'ON_ACCOUNT' | 'DIRECT_PAID' | null; paidAt: string | null
paymentMethod: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null
businessDate: string; currency: 'CNY'; amount: number
}
const text = (value: unknown) => value == null || value === '' ? null : String(value)
const requiredText = (value: unknown) => String(value ?? '')
const numberValue = (value: unknown) => Number.isFinite(Number(value)) ? Number(value) : 0
function normalizeFee(raw: RawResource): OverseasContainerFeeSource {
return {
id: requiredText(raw.id), sourceFeeId: requiredText(raw.sourceFeeId), sourceVersion: numberValue(raw.sourceVersion), customerId: requiredText(raw.customerId), customerName: text(raw.customerName),
sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: requiredText(raw.businessId), businessNo: requiredText(raw.businessNo), containerBookingId: requiredText(raw.containerBookingId), containerNo: requiredText(raw.containerNo),
feeItemCode: raw.feeItemCode as OverseasContainerFeeItem, feeItemName: requiredText(raw.feeItemName), direction: raw.direction as OverseasContainerFeeDirection,
supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo),
paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount),
reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt),
voidStatus: text(raw.voidStatus),
}
}
export async function listOverseasContainerFees(containerBookingId: string) {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(basePath, { params: { containerBookingId, businessType: 'OVERSEAS_CONTAINER' } })
return unwrapApiResponse(response).map(normalizeFee)
}
export async function listOverseasContainerCustomers(containerBookingId: string) {
const response = await http.get<ApiEnvelope<Array<{ customerId: unknown; customerName?: unknown }>>, ApiEnvelope<Array<{ customerId: unknown; customerName?: unknown }>>>(`${basePath}/container-customers`, { params: { containerBookingId } })
return unwrapApiResponse(response).map((item) => ({ customerId: requiredText(item.customerId), customerName: text(item.customerName) }))
}
export async function createOverseasContainerFee(payload: OverseasContainerFeeSaveRequest, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } })))
}
export async function submitOverseasContainerFee(sourceFeeId: string, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey })))
}
export async function reviewOverseasContainerFee(sourceFeeId: string, decision: 'APPROVE' | 'REJECT', reason: string | null, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey })))
}
export async function adjustOverseasContainerFee(sourceFeeId: string, expectedVersion: number, fee: OverseasContainerFeeSaveRequest, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey })))
}
export async function voidOverseasContainerFee(sourceFeeId: string, expectedVersion: number, reason: string, idempotencyKey: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey })))
}
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
planNo: 'LP-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'READY_TO_START', hasActualCargo: true, version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: 2092438843630391404n, orderId: 2092438843630391405n, orderNo: 'O-1', inboundCode: 'RK-1', tenantId: 2092438843630391406n, customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{ documentId: 2092438843630391407n, tenantId: 2092438843630391406n, source: { fileId: '2092438843630391408', fileName: 'source.pdf', fileHash: 'hash', sizeBytes: 1, version: 2, uploadedAt: '2026-08-28T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: null, finalDocument: null, confirmationHistory: [] }],
brokerSubmission: null,
startedAt: null, completedAt: null,
}
describe('platform container customs API', () => {
beforeEach(() => vi.restoreAllMocks())
it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-09-29T16:30:00Z'))
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: '1' } })
await recordClearanceResult({ id: '123', version: 1 } as DeclarationBatch, 'OTHER', 'test')
expect(post).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/declaration-batches/123/results',
expect.objectContaining({ occurredAt: '2026-09-30T00:30:00', resultType: 'OTHER', resolution: 'test' }),
expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } }))
} finally {
vi.useRealTimers()
}
})
it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs })
const result = await getContainerCustoms('2092438843630391402')
expect(result).not.toBeNull()
if (!result) throw new Error('expected completed loading plan customs view')
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/2092438843630391402')
expect(result).toMatchObject({ aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403' })
expect(result.inbounds[0]).toMatchObject({ inboundId: '2092438843630391404', orderId: '2092438843630391405', tenantId: '2092438843630391406' })
expect(result.documents[0]?.documentId).toBe('2092438843630391407')
})
it('returns null while the loading plan is not completed', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: null })
await expect(getContainerCustoms('2097922185010147329')).resolves.toBeNull()
})
it('sends confirmed start and all-buy completion with optimistic version and idempotency', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
await startContainerCustoms('2092438843630391402', 3)
await completeAllBuyContainerCustoms('2092438843630391402', 4)
expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/start', { expectedVersion: 3, confirm: true, remark: null }])
expect(post.mock.calls[1]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/complete-all-buy', { expectedVersion: 4, confirm: true, remark: null }])
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('sends the frozen source documents to the broker through the first-send action', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: customs })
await sendContainerCustomsToBroker('2092438843630391402', 3)
expect(post.mock.calls[0]?.slice(0, 2)).toEqual(['/platform/v1/customs/loading-plans/2092438843630391402/send-to-broker', { expectedVersion: 3, confirm: true, remark: '已将冻结退税报关资料首次发送给报关行' }])
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('uploads one receipt file without completing the container', async () => {
const document = { ...customs.documents[0], documentId: '2092438843630391407', tenantId: '2092438843630391406' }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: document })
const completion = new File(['done'], 'done.pdf')
await uploadContainerCustomsReceipt('2092438843630391407', completion, 0)
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/receipt/upload')
expect((post.mock.calls[0]?.[1] as FormData).get('file')).toBe(completion)
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { expectedVersion: 0 }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
})
it('keeps source and completion downloads isolated on the long document id', async () => {
const source = new Blob(['source'])
const completion = new Blob(['completion'])
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(source).mockResolvedValueOnce(completion)
const documentId = '2092438843630391407'
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
await expect(downloadContainerCustomsReceipt(documentId)).resolves.toBe(completion)
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
])
})
})
@@ -0,0 +1,268 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
export interface ContainerCustomsFile {
fileId: string
fileName: string
fileHash: string
sizeBytes: number
version: number
uploadedAt: string
}
export interface ContainerCustomsReceiptVersion {
version: number
file: ContainerCustomsFile
operatorName?: string | null
createdAt: string
}
export type ContainerCustomsConfirmationAction = 'CONFIRM' | 'REJECT'
export interface ContainerCustomsConfirmationHistory {
id: string
receiptVersion: number
action: ContainerCustomsConfirmationAction
reason: string | null
operatorName: string
createdAt: string
}
export interface ContainerCustomsBrokerSubmission {
id: string
customsContainerId: string
loadingPlanId: string
batchNo: string
status: 'SENT'
sourceVersion: number
sentByName: string
sentAt: string
version: number
}
export interface ContainerCustomsInbound {
inboundId: string
orderId: string
orderNo: string
inboundCode: string
tenantId: string
customerName: string
taxMode: ContainerCustomsTaxMode
covered: boolean
}
export interface ContainerCustomsDocument {
documentId: string
tenantId: string
source: ContainerCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
receipt: ContainerCustomsFile | null
receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
receiptRejectReason?: string | null
finalDocument: ContainerCustomsFile | null
receiptHistory?: ContainerCustomsReceiptVersion[]
confirmationHistory: ContainerCustomsConfirmationHistory[]
}
export interface ContainerCustomsView {
aggregateId: string
loadingPlanId: string
containerBookingId: string
planNo: string
cabinetNo: string
containerNo: string | null
status: ContainerCustomsStatus
hasActualCargo: boolean
version: number
taxRefundCount: number
coveredTaxRefundCount: number
actualBoxCount: number
inbounds: ContainerCustomsInbound[]
documents: ContainerCustomsDocument[]
brokerSubmission: ContainerCustomsBrokerSubmission | null
startedAt: string | null
completedAt: string | null
brokerSubmittedAt?: string | null
}
export type DeclarationBatchStatus = 'READY' | 'SUBMITTED' | 'ACKNOWLEDGED' | 'RESULT_PENDING' | 'CLOSED' | 'CANCELLED'
export type ClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER'
export type ClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED'
export interface ClearanceResult {
id: string
declarationBatchId: string
customsContainerId: string
resultType: ClearanceResultType
resultStatus: ClearanceResultStatus
customsEntryNo: string | null
occurredAt: string
resolution: string | null
evidenceFileId: string | null
recordedByName: string
recordedAt: string
previousResultId: string | null
}
export interface DeclarationBatch {
id: string
customsContainerId: string
loadingPlanId: string
batchNo: string
status: DeclarationBatchStatus
sourceVersion: number
customsEntryNo: string | null
version: number
submittedAt: string | null
submittedByName: string | null
acknowledgedAt: string | null
acknowledgedByName: string | null
remark: string | null
results: ClearanceResult[]
}
interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateId' | 'loadingPlanId' | 'containerBookingId' | 'version' | 'inbounds' | 'documents'> {
aggregateId: string | number
loadingPlanId: string | number
containerBookingId: string | number
version: string | number
inbounds: Array<Omit<ContainerCustomsInbound, 'inboundId' | 'orderId' | 'tenantId'> & { inboundId: string | number; orderId: string | number; tenantId: string | number }>
documents: Array<Omit<ContainerCustomsDocument, 'documentId' | 'tenantId' | 'source' | 'receipt' | 'finalDocument'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; receipt: ContainerCustomsFile | null; finalDocument: ContainerCustomsFile | null }>
}
const basePath = '/platform/v1/customs/loading-plans'
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function file(value: ContainerCustomsFile | null): ContainerCustomsFile | null {
return value ? { ...value, fileId: String(value.fileId), sizeBytes: Number(value.sizeBytes), version: Number(value.version) } : null
}
function normalize(value: RawContainerCustomsView): ContainerCustomsView {
return {
...value,
aggregateId: String(value.aggregateId), loadingPlanId: String(value.loadingPlanId), containerBookingId: String(value.containerBookingId), version: Number(value.version),
inbounds: value.inbounds.map((item) => ({ ...item, inboundId: String(item.inboundId), orderId: String(item.orderId), tenantId: String(item.tenantId) })),
brokerSubmission: value.brokerSubmission ? {
...value.brokerSubmission,
id: String(value.brokerSubmission.id),
customsContainerId: String(value.brokerSubmission.customsContainerId),
loadingPlanId: String(value.brokerSubmission.loadingPlanId),
sourceVersion: Number(value.brokerSubmission.sourceVersion),
version: Number(value.brokerSubmission.version),
} : null,
documents: value.documents.map((item) => ({
...item,
documentId: String(item.documentId),
tenantId: String(item.tenantId),
source: file(item.source)!,
receipt: file(item.receipt),
finalDocument: file(item.finalDocument),
receiptHistory: (item.receiptHistory || []).map((version) => ({ ...version, version: Number(version.version), file: file(version.file)! })),
confirmationHistory: (item.confirmationHistory || []).map((history) => ({ ...history, id: String(history.id), receiptVersion: Number(history.receiptVersion), reason: history.reason ?? null })),
})),
}
}
function normalizeClearanceResult(value: ClearanceResult): ClearanceResult {
return { ...value, id: String(value.id), declarationBatchId: String(value.declarationBatchId), customsContainerId: String(value.customsContainerId), evidenceFileId: value.evidenceFileId == null ? null : String(value.evidenceFileId), previousResultId: value.previousResultId == null ? null : String(value.previousResultId) }
}
function normalizeDeclarationBatch(value: DeclarationBatch): DeclarationBatch {
return { ...value, id: String(value.id), customsContainerId: String(value.customsContainerId), loadingPlanId: String(value.loadingPlanId), sourceVersion: Number(value.sourceVersion), version: Number(value.version), results: (value.results || []).map(normalizeClearanceResult) }
}
export async function getDeclarationBatches(loadingPlanId: string) {
const response = await http.get<ApiEnvelope<DeclarationBatch[]>, ApiEnvelope<DeclarationBatch[]>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`)
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
}
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) {
// The domestic API stores LocalDateTime; send Shanghai wall time, not UTC with a discarded offset.
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).formatToParts(new Date()).map(({ type, value }) => [type, value]))
const occurredAt = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`
const response = await http.post<ApiEnvelope<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt, resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } })
return normalizeClearanceResult(unwrapApiResponse(response))
}
export async function resolveClearanceResult(result: ClearanceResult, batchVersion: number, resolution: string) {
const response = await http.post<ApiEnvelope<ClearanceResult>, ApiEnvelope<ClearanceResult>>(`${basePath}/clearance-results/${encodeURIComponent(result.id)}/resolve`, { expectedVersion: batchVersion, resolution: resolution.trim() }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-resolve') } })
return normalizeClearanceResult(unwrapApiResponse(response))
}
export async function getContainerCustoms(loadingPlanId: string) {
const response = await http.get<ApiEnvelope<RawContainerCustomsView | null>, ApiEnvelope<RawContainerCustomsView | null>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
const value = unwrapApiResponse(response)
return value ? normalize(value) : null
}
export function downloadContainerCustomsDocument(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsReceipt(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsFinal(documentId: string) {
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
}
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
const response = await http.post<ApiEnvelope<RawContainerCustomsView>, ApiEnvelope<RawContainerCustomsView>>(
`${basePath}/${encodeURIComponent(loadingPlanId)}/${name}`,
{ expectedVersion, confirm: true, remark: remark?.trim() || null },
{ headers: { 'Idempotency-Key': operationId(`container-customs-${name}`) } },
)
return normalize(unwrapApiResponse(response))
}
export function startContainerCustoms(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'start', expectedVersion)
}
export function sendContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'send-to-broker', expectedVersion, '已将冻结退税报关资料首次发送给报关行')
}
export function completeAllBuyContainerCustoms(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'complete-all-buy', expectedVersion)
}
export function submitContainerCustomsToBroker(loadingPlanId: string, expectedVersion: number) {
return action(loadingPlanId, 'submit-to-broker', expectedVersion, '已将客户确认回执发送给报关行')
}
async function uploadFile(documentId: string, fileValue: File, expectedVersion: number, kind: 'receipt' | 'final') {
const body = new FormData()
body.append('file', fileValue)
const response = await http.post<ApiEnvelope<ContainerCustomsDocument>, ApiEnvelope<ContainerCustomsDocument>>(
`${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/upload`,
body,
{ params: { expectedVersion }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-customs-${kind}`) } },
)
const result = unwrapApiResponse(response)
return { ...result, documentId: String(result.documentId), tenantId: String(result.tenantId), source: file(result.source)!, receipt: file(result.receipt), finalDocument: file(result.finalDocument) }
}
export function uploadContainerCustomsReceipt(documentId: string, fileValue: File, expectedVersion: number) {
return uploadFile(documentId, fileValue, expectedVersion, 'receipt')
}
export function uploadContainerCustomsFinal(documentId: string, fileValue: File, expectedVersion: number) {
return uploadFile(documentId, fileValue, expectedVersion, 'final')
}
@@ -1,9 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
updateContainer, updateContainerTransport,
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, removeSmartLoadingPallet,
recommendSmartLoading,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, updateContainerPickupCode, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
getCustomerContainerTransitSummary,
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
updateContainer,
} from './platformContainerLoading'
vi.mock('@/shared/api/http', () => ({
@@ -11,50 +18,289 @@ vi.mock('@/shared/api/http', () => ({
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, version: '2', capacityCbm: '68', maxWeightKg: '28000' }
const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] }
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelGroupCode: 'EXX', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: 9007199254740999, unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '68', maxWeightKg: '19500' }
const plan = {
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
pdaTaskId: 9007199254740996, pdaTaskStatus: 'LOADING', pdaTaskVersion: '4',
items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1', pickedPackageCount: '2', minPackageCount: '2', canAdjust: true, canRemove: false }],
}
describe('platform container loading API', () => {
beforeEach(() => vi.clearAllMocks())
it('normalizes paged container ids and numeric metadata', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listContainers({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), version: 2, capacityCbm: 68 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
it('uses the customer transit summary and preserves fractional TEU', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { customerId: '9007199254740993', containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0, asOf: '2026-08-13T12:00:00', definition: '已开船未到仓' } })
const result = await getCustomerContainerTransitSummary('9007199254740993')
expect(http.get).toHaveBeenCalledWith('/platform/v1/customers/9007199254740993/container-transit-summary')
expect(result).toMatchObject({ customerId: '9007199254740993', containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0 })
})
it('uses full-edit and transport whitelist request shapes', async () => {
it('normalizes paged container ids and numeric metadata', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [{ container, workbenchStage: 'PRE_LOADING', statusSummary: { loadingStatus: 'WAIT_LOADING' }, allowedActions: ['EDIT_CONTAINER'], actualFacts: {}, terminalDrayage: null }], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listContainers({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupCode: '00123456', pickupCodeUpdatedBy: String(container.pickupCodeUpdatedBy), unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 68 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/domestic', { params: { page: 1, size: 20, scope: 'ACTIVE' } })
})
it('uses the full-edit request shape without retaining the retired container transport endpoint', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
await updateContainer('7', 2, data)
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' })
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/api/platformContainerLoading.ts'), 'utf8')).not.toContain('/transport')
})
it.each(['DOMESTIC', 'OVERSEAS'] as const)('reads the %s detail envelope and exact long identifiers in one request', async (workbench) => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
container: { ...container, id: '9007199254740993', planId: '9007199254740995' },
workbenchStage: 'PRE_LOADING', allowedActions: ['EDIT_CONTAINER'], statusSummary: {}, actualFacts: null, terminalDrayage: null,
} })
expect(await getContainer('9007199254740993', workbench)).toMatchObject({ id: '9007199254740993', planId: '9007199254740995', allowedActions: ['EDIT_CONTAINER'] })
expect(http.get).toHaveBeenCalledExactlyOnceWith(`/platform/v1/containers/${workbench.toLowerCase()}/9007199254740993`)
})
it('keeps empty overseas history pages and query filters without fetching individual details', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
const query = { page: 2, size: 20, warehouseId: '9007199254740993', cabinetNo: 'CB-TEST', scope: 'HISTORY' as const }
expect(await listContainers(query, 'OVERSEAS')).toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 })
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/containers/overseas', { params: query })
})
it('propagates a failed workbench request without retrying retired routes', async () => {
const failure = new Error('workbench unavailable')
vi.mocked(http.get).mockRejectedValueOnce(failure)
await expect(listContainers({ page: 1, size: 20 })).rejects.toBe(failure)
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects a retired flat response instead of rendering incorrect container rows', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: 1, size: 20, total: 1, pages: 1 } })
await expect(listContainers({ page: 1, size: 20 })).rejects.toThrow()
expect(http.get).toHaveBeenCalledTimes(1)
})
it('uses the frozen simulation create and ocean booking link contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: 101, oceanBookingRequirementId: 202 } })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: { ...container, oceanBookingId: null, oceanBookingRequirementId: null } })
await createContainerSimulation({ channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
await linkContainerOceanBooking('7', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
await unlinkContainerOceanBooking('7', 4, '空柜改配')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/containers', { channelGroupCode: 'EXX', warehouseId: '8', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/containers/7/ocean-booking-link', { oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 3 })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/ocean-booking-link', { data: { expectedVersion: 4, reason: '空柜改配' } })
})
it('normalizes every long id in candidates and plans', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: container })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, warehouseId: 5, destinationWarehouseId: 6, availablePackageCount: '7' }] })
.mockResolvedValueOnce({ code: '200', data: { container, workbenchStage: 'PRE_LOADING', statusSummary: {}, allowedActions: [], actualFacts: {}, terminalDrayage: null } })
.mockResolvedValueOnce({ code: '200', data: { records: [{ accountId: 1, inboundId: 2, orderId: 4, orderNo: 'ORD-1', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', warehouseId: 5, destinationWarehouseId: 6, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', customerName: '示例企业', goodsSummary: '服装、鞋类', availablePackageCount: '7', unitGrossWeightKg: '12.5', unitNetWeightKg: '11.8', averageUnitVolumeCbm: '0.187', availableVolumeCbm: '1.309', estimatedVolumeCbm: '2.5', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } })
expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates('5')).toMatchObject([{ accountId: '1', inboundDetailId: '3', destinationWarehouseId: '6', availablePackageCount: 7 }])
expect(await listSmartLoadingCandidates({ channelGroupCodes: ['EXX', 'HEDE'], warehouseId: '5', orderStatuses: ['PENDING_REVIEW', 'WAIT_MEASURE'], page: 1, size: 20 })).toMatchObject({ records: [{ accountId: '1', customerId: '1', inboundId: '2', inboundCode: 'IN-1', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-10T09:30:00', destinationWarehouseId: '6', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', goodsSummary: '服装、鞋类', availablePackageCount: 7, unitGrossWeightKg: 12.5, unitNetWeightKg: 11.8, averageUnitVolumeCbm: 0.187, availableVolumeCbm: 1.309, estimatedVolumeCbm: 2.5, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 })
const candidateParams = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(candidateParams.getAll('orderStatuses')).toEqual(['PENDING_REVIEW', 'WAIT_MEASURE'])
expect(candidateParams.has('orderStatus')).toBe(false)
const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(params.getAll('channelGroupCodes')).toEqual(['EXX', 'HEDE'])
expect(params.get('warehouseId')).toBe('5')
expect(params.has('bookingId')).toBe(false)
})
it('sends plan and item versions plus operation and idempotency keys', async () => {
it('sends plan and item versions plus operation identifiers', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await createSmartLoadingPlan('7', null)
await assignSmartLoadingItem('10', { inboundId: '2', inboundDetailId: '3', packageCount: 4, expectedVersion: 5 })
await assignSmartLoadingItem('10', { inboundId: '2', packageCount: 4, expectedVersion: 5 })
await adjustSmartLoadingItem('10', '11', 6, 7, 8)
await removeSmartLoadingItem('10', '11', 9, 10)
await pushSmartLoadingPreload('10', 11)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null })
expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-') })
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/smart-loading/plans/10/inbound-assignments')
expect(vi.mocked(http.post).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-inbound-') })
expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') })
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
})
it('assigns a sealed pallet through the whole-pallet command', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
await assignSmartLoadingPallet('10', { palletId: '9007199254740998', expectedVersion: 5 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments',
{ palletId: '9007199254740998', expectedVersion: 5, operationId: expect.stringContaining('assign-pallet-') },
)
})
it('limits smart recommendation to the current customer and inbound filters', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
await recommendSmartLoading('10', 5, {
customerId: '9007199254740998',
inboundCode: ' EXXHWK2609140162 ',
})
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/recommend',
expect.objectContaining({
expectedVersion: 5,
customerId: '9007199254740998',
inboundCode: 'EXXHWK2609140162',
operationId: expect.stringContaining('recommend-'),
}),
)
})
it('removes a sealed pallet through the whole-pallet command', async () => {
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await removeSmartLoadingPallet('10', '9007199254740998', 6)
expect(http.delete).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/pallet-assignments/9007199254740998',
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-pallet-') } },
)
})
it('uses the dedicated provisional assignment command for a pending-review order', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
await assignPendingReviewSmartLoadingOrder('10', {
customerId: '21', orderId: '31', inboundCode: 'EXXHK2609020361',
packageCount: 100, expectedVersion: 5,
})
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/pending-review-assignments',
expect.objectContaining({
customerId: '21', orderId: '31', inboundCode: 'EXXHK2609020361',
packageCount: 100, expectedVersion: 5,
operationId: expect.stringContaining('assign-pending-review-'),
}),
)
})
it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
page: '1', size: '200', total: '1', pages: '1',
} })
const result = await listSmartLoadingWorkbenchContainers({ channelGroupCodes: ['MATSON_CLX', 'EXX'], customerId: '12', inboundCode: 'IN-1', createdStart: '2026-09-01', createdEnd: '2026-09-03', expectedArrivalStart: '2026-09-04', expectedArrivalEnd: '2026-09-06', orderStatuses: ['WAIT_SPLIT'], page: 1, size: 200 })
expect(result).toMatchObject({ records: [{ bookingId: '9007199254740993', warehouseId: '8', planId: '9007199254740994', version: 2 }], total: 1 })
const params = vi.mocked(http.get).mock.calls.at(-1)?.[1]?.params as URLSearchParams
expect(params.getAll('channelGroupCodes')).toEqual(['MATSON_CLX', 'EXX'])
expect(params.get('customerId')).toBe('12')
expect(params.get('inboundCode')).toBe('IN-1')
expect(params.get('createdStart')).toBe('2026-09-01')
expect(params.get('createdEnd')).toBe('2026-09-03')
expect(params.get('expectedArrivalStart')).toBe('2026-09-04')
expect(params.get('expectedArrivalEnd')).toBe('2026-09-06')
expect(params.has('orderStatus')).toBe(false)
expect(params.has('orderStatuses')).toBe(false)
expect(params.has('bookingId')).toBe(false)
})
it('uses only the atomic group assign and whole-group remove endpoints for co-load groups', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await assignSmartLoadingGroup('10', '2093000000000000006', 5)
await removeSmartLoadingGroup('10', '2093000000000000001', 6)
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/group-assignments',
{ inboundId: '2093000000000000006', expectedVersion: 5, operationId: expect.stringContaining('assign-coload-group-') },
)
expect(http.delete).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/group-assignments/2093000000000000001',
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-coload-group-') } },
)
})
it('uses one endpoint to remove all unpicked plan items', async () => {
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await removeAllUnpickedSmartLoadingItems('10', 6)
expect(http.delete).toHaveBeenCalledWith(
'/platform/v1/first-leg/smart-loading/plans/10/unpicked-items',
{ data: { expectedPlanVersion: 6, operationId: expect.stringContaining('remove-unpicked-') } },
)
})
it('uses stable container id and versioned clearance document actions', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 2 } })
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 4, documentType: 'CUSTOMS_CLEARANCE', fileId: 8, fileName: 'clearance.pdf', version: 3 } })
expect((await getContainerClearance('7'))?.fileId).toBe('8')
const file = new File(['clearance'], 'clearance.pdf', { type: 'application/pdf' })
await uploadContainerClearance('7', file, 2, '上传资料')
await completeContainerClearance('7', 3, '完成清关')
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/containers/7/clearance')
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/clearance/document/upload')
const body = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
expect(body.get('file')).toBe(file)
expect(body.get('expectedVersion')).toBe('2')
expect(body.get('operationReason')).toBe('上传资料')
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
expect(vi.mocked(http.post).mock.calls[1]?.[0]).toBe('/platform/v1/containers/7/clearance/complete')
})
it('normalizes aggregate ids and uses the frozen overview and exception contracts', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
container: { id: 9007199254740993, warehouseId: 9007199254740994, version: '4' }, loadingPlanId: 9007199254740995,
pda: { taskId: 9007199254740996, loadingPlanId: 9007199254740995, containerBookingId: 9007199254740993, taskVersion: '2', itemCount: '4', completedItemCount: '3', unresolvedItemCount: '1', photoCounts: [{ evidenceType: 'EMPTY_CONTAINER', count: '2' }], photos: [{ evidenceId: 20, tenantId: 21, inboundId: 22, fileId: 23, downloadPath: '/api/platform/v1/containers/9007199254740993/pda/photos/23/download' }], exceptionReasons: [] },
documents: [{ id: 9007199254740997, documentType: 'DELIVERY_ORDER', fileId: 9007199254740998, uploadedBy: 9, version: '2' }],
exception: { active: false, markedBy: null }, exceptionHistory: [{ id: 10, operatorId: 11 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: 12, eventType: 'PUSH_PRELOAD', detail: '推送预装清单' }],
} })
const result = await getContainerOverview('7')
expect(result).toMatchObject({ container: { id: expect.any(String), warehouseId: expect.any(String), version: 4 }, loadingPlanId: expect.any(String), pda: { taskId: expect.any(String), itemCount: 4, photoCounts: [{ count: 2 }] }, documents: [{ fileId: expect.any(String), version: 2 }], timeline: [{ id: '2092438843630397001', eventId: '2092438843630397002', idempotencyKey: '2092438843630397003', operatorId: '12' }] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/overview')
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: null })
vi.mocked(http.delete).mockResolvedValueOnce({ code: '200', data: null })
await markContainerException('7', 2, '查验')
await cancelContainerException('7', 3, '已解除')
expect(http.post).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { expectedVersion: 2, reason: '查验' }, { headers: { 'Idempotency-Key': expect.stringContaining('container-exception-mark-') } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/containers/7/exception', { data: { expectedVersion: 3, reason: '已解除' }, headers: { 'Idempotency-Key': expect.stringContaining('container-exception-cancel-') } })
})
it('uploads only DO or PU through the logistics endpoint and downloads through the protected proxy', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { id: 1, documentType: 'DELIVERY_ORDER', fileId: 2, fileName: 'do.pdf', version: 1 } })
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['do']))
const file = new File(['do'], 'do.pdf')
await uploadContainerDocument('7', 'DELIVERY_ORDER', file, 0, '上传DO')
await downloadContainerDocument('7', 'DELIVERY_ORDER', '2')
expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe('/platform/v1/containers/7/documents/DELIVERY_ORDER/upload')
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
})
it('saves the PU numeric code as a string with the container version', async () => {
vi.mocked(http.put).mockResolvedValueOnce({ code: '200', data: { container: { id: 7, warehouseId: 8, pickupCode: '00123456', pickupCodeUpdatedBy: 9, version: 3 }, documents: [], exception: {}, exceptionHistory: [], timeline: [] } })
const result = await updateContainerPickupCode('7', '00123456', 2)
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7/pickup-code', { pickupCode: '00123456', expectedVersion: 2 })
expect(result.container).toMatchObject({ pickupCode: '00123456', pickupCodeUpdatedBy: '9', version: 3 })
})
it('normalizes the gateway /api prefix once and accepts only exact protected container photo paths', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['photo']))
const longPath = '/api/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download'
await downloadContainerPdaPhoto(longPath)
await downloadContainerPdaPhoto('/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' })
expect(vi.mocked(http.get).mock.calls.every((call) => !String(call[0]).includes('/api/api/'))).toBe(true)
for (const path of [
'/api/platform/v1/files/loading-evidence/21/22/23', 'https://example.com/photo.jpg',
'//example.com/platform/v1/containers/7/pda/photos/23/download', '/platform/v1/containers/7/pda/photos/23/download?token=x',
'/platform/v1/containers/7/pda/photos/../23/download', '/api/api/platform/v1/containers/7/pda/photos/23/download',
]) expect(() => downloadContainerPdaPhoto(path)).toThrow('invalid container photo path')
})
it('downloads destination clearance through its exact protected endpoint with string ids', async () => {
const blob = new Blob(['clearance'])
vi.mocked(http.get).mockResolvedValueOnce(blob)
const result = await downloadContainerClearance('2092438843630391297', '2092438843630393502')
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/2092438843630391297/clearance/document/2092438843630393502/download', { responseType: 'blob' })
expect(result).toBe(blob)
})
})
@@ -1,12 +1,34 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface CustomerContainerTransitSummary {
customerId: string
containerCount: number
totalTeu: number | string
unknownContainerCount: number
asOf: string
definition: string
}
import type {
ContainerBookingResource,
ContainerPage,
ContainerQuery,
ContainerSaveRequest,
ContainerSimulationCreateRequest,
ContainerOceanBookingLinkRequest,
SmartLoadingCandidate,
SmartLoadingCandidatePage,
SmartLoadingCandidateQuery,
SmartLoadingWorkbenchContainerPage,
SmartLoadingItem,
SmartLoadingPlan,
BusinessDocument,
ContainerDocumentType,
ContainerOverview,
ContainerOverviewDocument,
ContainerWorkbenchKind,
ContainerWorkbenchRow,
ContainerWorkbenchItem,
ContainerWorkbenchPage,
} from '../types'
const containerPath = '/platform/v1/containers'
@@ -19,17 +41,85 @@ function numberValue(value: unknown, fallback = 0): number {
return Number.isFinite(parsed) ? parsed : fallback
}
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function document(raw: BusinessDocument | null | undefined): BusinessDocument | null {
if (!raw) return null
return { ...raw, id: raw.id == null ? null : id(raw.id), fileId: id(raw.fileId), uploadedBy: raw.uploadedBy == null ? null : id(raw.uploadedBy), completedBy: raw.completedBy == null ? null : id(raw.completedBy), version: numberValue(raw.version) }
}
function overviewDocument(raw: ContainerOverviewDocument): ContainerOverviewDocument {
return { ...raw, id: id(raw.id), fileId: id(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), version: numberValue(raw.version) }
}
function overview(raw: ContainerOverview): ContainerOverview {
return {
...raw,
container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), pickupCodeUpdatedBy: optionalId(raw.container.pickupCodeUpdatedBy), version: numberValue(raw.container.version) },
loadingPlanId: optionalId(raw.loadingPlanId),
pda: raw.pda ? {
...raw.pda, taskId: id(raw.pda.taskId), loadingPlanId: id(raw.pda.loadingPlanId),
containerBookingId: id(raw.pda.containerBookingId), taskVersion: numberValue(raw.pda.taskVersion),
itemCount: numberValue(raw.pda.itemCount), completedItemCount: numberValue(raw.pda.completedItemCount),
unresolvedItemCount: numberValue(raw.pda.unresolvedItemCount),
photoCounts: (raw.pda.photoCounts || []).map((item) => ({ ...item, count: numberValue(item.count) })),
photos: (raw.pda.photos || []).map((item) => ({ ...item, evidenceId: id(item.evidenceId), tenantId: id(item.tenantId), inboundId: id(item.inboundId), fileId: id(item.fileId) })),
exceptionReasons: raw.pda.exceptionReasons || [],
} : null,
documents: (raw.documents || []).map(overviewDocument),
exception: { ...raw.exception, markedBy: optionalId(raw.exception?.markedBy) },
exceptionHistory: (raw.exceptionHistory || []).map((entry) => ({ ...entry, id: id(entry.id), operatorId: optionalId(entry.operatorId) })),
timeline: (raw.timeline || []).map((entry) => ({
...entry,
id: optionalId(entry.id),
eventId: optionalId(entry.eventId),
idempotencyKey: optionalId(entry.idempotencyKey),
operatorId: optionalId(entry.operatorId),
})),
}
}
function container(raw: ContainerBookingResource): ContainerBookingResource {
return {
...raw,
id: id(raw.id),
planId: optionalId(raw.planId),
planNo: raw.planNo || null,
mergedIntoContainerBookingId: optionalId(raw.mergedIntoContainerBookingId),
warehouseId: id(raw.warehouseId),
oceanBookingId: optionalId(raw.oceanBookingId),
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
customerId: optionalId(raw.customerId),
shippingCompanyId: optionalId(raw.shippingCompanyId),
terminalTruckingCompanyId: optionalId(raw.terminalTruckingCompanyId),
emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion),
pickupCodeUpdatedBy: optionalId(raw.pickupCodeUpdatedBy),
version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
exceptionActive: Boolean(raw.exceptionActive),
clearanceDocument: document(raw.clearanceDocument),
}
}
function normalizeWorkbenchRow(raw: ContainerWorkbenchItem): ContainerWorkbenchRow {
const drayage = raw.terminalDrayage
return {
...container(raw.container),
workbenchStage: raw.workbenchStage || null,
statusSummary: raw.statusSummary || {},
allowedActions: raw.allowedActions || [],
actualFacts: raw.actualFacts || {},
terminalDrayage: drayage ? {
...drayage,
containerBookingId: id(drayage.containerBookingId),
version: numberValue(drayage.version),
fleetSupplierId: optionalId(drayage.fleetSupplierId),
emptyReturnWarning: {
...drayage.emptyReturnWarning,
overdueHours: numberValue(drayage.emptyReturnWarning?.overdueHours),
overdueDays: numberValue(drayage.emptyReturnWarning?.overdueDays),
},
allowedActions: drayage.allowedActions || [],
} : null,
}
}
@@ -39,7 +129,14 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId),
inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId),
destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version),
palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null,
unitType: raw.unitType, indivisible: Boolean(raw.indivisible),
packageCount: numberValue(raw.packageCount),
pickedPackageCount: numberValue(raw.pickedPackageCount),
minPackageCount: numberValue(raw.minPackageCount, 1),
totalPackageCount: numberValue(raw.totalPackageCount, raw.packageCount),
remainingPackageCount: numberValue(raw.remainingPackageCount),
canAdjust: Boolean(raw.canAdjust), canRemove: Boolean(raw.canRemove),
productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity),
grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg),
volumeCbm: raw.volumeCbm == null ? null : numberValue(raw.volumeCbm),
@@ -47,22 +144,116 @@ function item(raw: SmartLoadingItem): SmartLoadingItem {
}
function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
return { ...raw, id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), items: (raw.items || []).map(item) }
return {
...raw,
id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version),
canAssign: Boolean(raw.canAssign), canEdit: Boolean(raw.canEdit),
blockedReason: raw.blockedReason || null,
pdaTaskId: optionalId(raw.pdaTaskId),
pdaTaskVersion: raw.pdaTaskVersion == null ? null : numberValue(raw.pdaTaskVersion),
coLoadGroups: (raw.coLoadGroups || []).map((group) => ({
...group,
groupId: id(group.groupId), lockedPlanId: optionalId(group.lockedPlanId),
memberOrderCount: numberValue(group.memberOrderCount),
inboundFragmentCount: numberValue(group.inboundFragmentCount),
fullPackageCount: numberValue(group.fullPackageCount),
memberInboundIds: (group.memberInboundIds || []).map(id),
})),
palletMembers: (raw.palletMembers || []).map((pallet) => ({
palletId: id(pallet.palletId),
members: (pallet.members || []).map((member) => ({
...member,
accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
availablePackageCount: numberValue(member.availablePackageCount),
})),
})),
items: (raw.items || []).map(item),
}
}
export async function listContainers(query: ContainerQuery): Promise<ContainerPage> {
const response = await http.get<ApiEnvelope<ContainerPage>, ApiEnvelope<ContainerPage>>(containerPath, { params: query })
export async function listContainers(query: ContainerQuery, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise<ContainerPage> {
const params = { ...query, scope: query.scope || 'ACTIVE' }
const response = await http.get<ApiEnvelope<ContainerWorkbenchPage>, ApiEnvelope<ContainerWorkbenchPage>>(`${containerPath}/${workbench.toLowerCase()}`, { params })
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(container), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
return { ...result, records: result.records.map(normalizeWorkbenchRow), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
}
export async function getContainer(containerId: string) {
const response = await http.get<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`)
export async function getCustomerContainerTransitSummary(customerId: string): Promise<CustomerContainerTransitSummary> {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`/platform/v1/customers/${encodeURIComponent(customerId)}/container-transit-summary`,
)
const result = unwrapApiResponse(response)
return {
customerId: id(result.customerId), containerCount: numberValue(result.containerCount),
totalTeu: String(result.totalTeu ?? '0'), unknownContainerCount: numberValue(result.unknownContainerCount),
asOf: id(result.asOf), definition: id(result.definition),
}
}
export async function getContainer(containerId: string, workbench: ContainerWorkbenchKind = 'DOMESTIC'): Promise<ContainerWorkbenchRow> {
const response = await http.get<ApiEnvelope<ContainerWorkbenchItem>, ApiEnvelope<ContainerWorkbenchItem>>(`${containerPath}/${workbench.toLowerCase()}/${encodeURIComponent(containerId)}`)
return normalizeWorkbenchRow(unwrapApiResponse(response))
}
export async function getContainerOverview(containerId: string) {
const response = await http.get<ApiEnvelope<ContainerOverview>, ApiEnvelope<ContainerOverview>>(`${containerPath}/${encodeURIComponent(containerId)}/overview`)
return overview(unwrapApiResponse(response))
}
export async function updateContainerPickupCode(containerId: string, pickupCode: string, expectedVersion: number) {
const response = await http.put<ApiEnvelope<ContainerOverview>, ApiEnvelope<ContainerOverview>>(
`${containerPath}/${encodeURIComponent(containerId)}/pickup-code`,
{ pickupCode, expectedVersion },
)
return overview(unwrapApiResponse(response))
}
export async function markContainerException(containerId: string, expectedVersion: number, reason: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { expectedVersion, reason }, { headers: { 'Idempotency-Key': operationId('container-exception-mark') } })
unwrapApiResponse(response)
}
export async function cancelContainerException(containerId: string, expectedVersion: number, reason: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${containerPath}/${encodeURIComponent(containerId)}/exception`, { data: { expectedVersion, reason }, headers: { 'Idempotency-Key': operationId('container-exception-cancel') } })
unwrapApiResponse(response)
}
export async function uploadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER'>, file: File, expectedVersion: number, operationReason?: string | null) {
const body = new FormData()
body.append('file', file)
body.append('expectedVersion', String(expectedVersion))
if (operationReason) body.append('operationReason', operationReason)
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId(`container-${documentType.toLowerCase()}-upload`) } })
return document(unwrapApiResponse(response))
}
export function downloadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER'>, fileId: string) {
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
function normalizeContainerPhotoDownloadPath(downloadPath: string) {
if (typeof downloadPath !== 'string' || !downloadPath.startsWith('/')) throw new Error('invalid container photo path')
const normalized = downloadPath.startsWith('/api/') ? downloadPath.slice(4) : downloadPath
if (!/^\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(normalized)) throw new Error('invalid container photo path')
return normalized
}
export function downloadContainerPdaPhoto(downloadPath: string) {
return http.get<Blob, Blob>(normalizeContainerPhotoDownloadPath(downloadPath), { responseType: 'blob' })
}
export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) {
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
return container(unwrapApiResponse(response))
}
export async function createContainer(payload: ContainerSaveRequest) {
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
export async function linkContainerOceanBooking(containerId: string, payload: ContainerOceanBookingLinkRequest) {
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/ocean-booking-link`, payload)
return container(unwrapApiResponse(response))
}
export async function unlinkContainerOceanBooking(containerId: string, expectedVersion: number, reason: string) {
const response = await http.delete<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/ocean-booking-link`, { data: { expectedVersion, reason } })
return container(unwrapApiResponse(response))
}
@@ -71,20 +262,92 @@ export async function updateContainer(containerId: string, version: number, data
return container(unwrapApiResponse(response))
}
export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) {
const response = await http.patch<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version })
return container(unwrapApiResponse(response))
export async function getContainerClearance(containerId: string) {
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance`)
return document(unwrapApiResponse(response))
}
export async function listSmartLoadingCandidates(warehouseId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/candidates`, { params: { warehouseId } })
return unwrapApiResponse(response).map((raw) => ({
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
availablePackageCount: numberValue(raw.availablePackageCount),
unitWeightKg: raw.unitWeightKg == null ? null : numberValue(raw.unitWeightKg),
unitVolumeCbm: raw.unitVolumeCbm == null ? null : numberValue(raw.unitVolumeCbm),
}))
export function downloadContainerClearance(containerId: string, fileId: string) {
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
export async function uploadContainerClearance(containerId: string, file: File, expectedVersion: number, operationReason?: string | null) {
const body = new FormData()
body.append('file', file)
body.append('expectedVersion', String(expectedVersion))
if (operationReason) body.append('operationReason', operationReason)
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/upload`, body, { headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-clearance-upload') } })
return document(unwrapApiResponse(response))
}
export async function completeContainerClearance(containerId: string, expectedVersion: number, operationReason?: string | null) {
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/complete`, { expectedVersion, operationReason }, { headers: { 'Idempotency-Key': operationId('container-clearance-complete') } })
return document(unwrapApiResponse(response))
}
function smartLoadingQueryParams(query: SmartLoadingCandidateQuery, includeOrderStatuses = false) {
const params = new URLSearchParams()
for (const channelGroupCode of query.channelGroupCodes) params.append('channelGroupCodes', channelGroupCode)
if (includeOrderStatuses) {
for (const orderStatus of query.orderStatuses || []) params.append('orderStatuses', orderStatus)
}
const optionalParams: Array<[string, string | number | undefined]> = [
['warehouseId', query.warehouseId], ['customerId', query.customerId], ['inboundCode', query.inboundCode],
['createdStart', query.createdStart], ['createdEnd', query.createdEnd],
['expectedArrivalStart', query.expectedArrivalStart], ['expectedArrivalEnd', query.expectedArrivalEnd],
['page', query.page], ['size', query.size],
]
for (const [key, value] of optionalParams) if (value !== undefined && value !== '') params.append(key, String(value))
return params
}
export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQuery) {
const params = smartLoadingQueryParams(query, true)
const response = await http.get<ApiEnvelope<SmartLoadingCandidatePage>, ApiEnvelope<SmartLoadingCandidatePage>>(`${smartLoadingPath}/inbound-candidates`, { params })
const result = unwrapApiResponse(response)
return {
...result,
page: numberValue(result.page, query.page), size: numberValue(result.size, query.size),
total: numberValue(result.total), pages: numberValue(result.pages),
records: result.records.map((raw) => {
const accountId = id(raw.accountId)
return {
...raw, accountId, customerId: accountId, inboundId: optionalId(raw.inboundId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
coLoadGroupId: optionalId(raw.coLoadGroupId), palletId: optionalId(raw.palletId), palletNo: raw.palletNo || null,
unitType: raw.unitType, indivisible: Boolean(raw.indivisible),
memberCount: raw.memberCount == null ? null : numberValue(raw.memberCount),
groupTotalBoxes: raw.groupTotalBoxes == null ? null : numberValue(raw.groupTotalBoxes),
locked: raw.locked == null ? null : Boolean(raw.locked),
availablePackageCount: numberValue(raw.availablePackageCount),
unitGrossWeightKg: raw.unitGrossWeightKg == null ? null : numberValue(raw.unitGrossWeightKg),
unitNetWeightKg: raw.unitNetWeightKg == null ? null : numberValue(raw.unitNetWeightKg),
averageUnitVolumeCbm: raw.averageUnitVolumeCbm == null ? null : numberValue(raw.averageUnitVolumeCbm),
availableVolumeCbm: raw.availableVolumeCbm == null ? null : numberValue(raw.availableVolumeCbm),
estimatedVolumeCbm: raw.estimatedVolumeCbm == null ? null : numberValue(raw.estimatedVolumeCbm),
groupMembers: raw.groupMembers.map((member) => ({
...member, accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
availablePackageCount: numberValue(member.availablePackageCount),
})),
}
}),
}
}
export async function listSmartLoadingWorkbenchContainers(query: SmartLoadingCandidateQuery) {
const response = await http.get<ApiEnvelope<SmartLoadingWorkbenchContainerPage>, ApiEnvelope<SmartLoadingWorkbenchContainerPage>>(`${smartLoadingPath}/containers`, { params: smartLoadingQueryParams(query) })
const result = unwrapApiResponse(response)
return {
...result,
page: numberValue(result.page, query.page), size: numberValue(result.size, query.size),
total: numberValue(result.total), pages: numberValue(result.pages),
records: result.records.map((raw) => ({
...raw, bookingId: id(raw.bookingId), warehouseId: id(raw.warehouseId), planId: optionalId(raw.planId),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
version: numberValue(raw.version),
})),
}
}
export async function getSmartLoadingPlanByBooking(bookingId: string) {
@@ -97,18 +360,48 @@ export async function getSmartLoadingPlan(planId: string) {
return plan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string, remark: string | null) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans`, { bookingId, remark })
export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/inbound-assignments`, { ...payload, operationId: operationId('assign-inbound') })
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; inboundDetailId: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/assignments`, { ...payload, operationId: operationId('assign') })
export async function assignSmartLoadingPallet(planId: string, payload: { palletId: string; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments`,
{ ...payload, operationId: operationId('assign-pallet') },
)
return plan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
export async function assignPendingReviewSmartLoadingOrder(planId: string, payload: { customerId: string; orderId: string; inboundCode: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pending-review-assignments`,
{ ...payload, operationId: operationId('assign-pending-review') },
)
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingGroup(planId: string, inboundId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments`,
{ inboundId, expectedVersion, operationId: operationId('assign-coload-group') },
)
return plan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(
planId: string,
expectedVersion: number,
scope: { customerId?: string; inboundCode?: string } = {},
) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`,
{
expectedVersion,
operationId: operationId('recommend'),
...(scope.customerId ? { customerId: scope.customerId } : {}),
...(scope.inboundCode?.trim() ? { inboundCode: scope.inboundCode.trim() } : {}),
},
)
return plan(unwrapApiResponse(response))
}
@@ -122,7 +415,27 @@ export async function removeSmartLoadingItem(planId: string, itemId: string, exp
return plan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
export async function removeSmartLoadingPallet(planId: string, palletId: string, expectedPlanVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/pallet-assignments/${encodeURIComponent(palletId)}`,
{ data: { expectedPlanVersion, operationId: operationId('remove-pallet') } },
)
return plan(unwrapApiResponse(response))
}
export async function removeSmartLoadingGroup(planId: string, groupId: string, expectedPlanVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/group-assignments/${encodeURIComponent(groupId)}`,
{ data: { expectedPlanVersion, operationId: operationId('remove-coload-group') } },
)
return plan(unwrapApiResponse(response))
}
export async function removeAllUnpickedSmartLoadingItems(planId: string, expectedPlanVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(
`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/unpicked-items`,
{ data: { expectedPlanVersion, operationId: operationId('remove-unpicked') } },
)
return plan(unwrapApiResponse(response))
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
sendTerminalDrayageEmail,
getTerminalDrayageEmails,
retryTerminalDrayageEmail,
getTerminalDrayageHistory,
listTerminalDrayages,
recordTerminalDrayageEmptyReturn,
scheduleTerminalDrayagePickup,
selectTerminalDrayageFleet,
} from './terminalDrayages'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const view = {
containerBookingId: '9007199254740993', containerNo: 'MSCU1234567', oceanBookingId: '9007199254740994',
status: 'UNLOADED', version: '3', fleetSupplierId: '9007199254740995', fleetSupplierName: '港运车队',
pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00',
actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00',
emptyReturnWarning: { status: 'OVERDUE', deadlineAt: '2026-09-10T10:00:00', overdueHours: '26', overdueDays: '1' },
allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'], updatedAt: '2026-09-11T12:00:00',
}
describe('terminal drayages API', () => {
beforeEach(() => vi.clearAllMocks())
it('queues pickup email with version and idempotency and scopes retries to the container', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ data: view })
await sendTerminalDrayageEmail('9007199254740993', 3)
expect(http.post).toHaveBeenCalledWith('/platform/v1/terminal-drayages/9007199254740993/pickup-email',
{ expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
vi.mocked(http.get).mockResolvedValueOnce({ data: { deliveryEnabled: false, records: [{ id: '9007199254740999', status: 'FAILED' }] } })
expect((await getTerminalDrayageEmails('9007199254740993')).records[0].id).toBe('9007199254740999')
vi.mocked(http.post).mockResolvedValueOnce({ data: null })
await retryTerminalDrayageEmail('9007199254740993', '9007199254740999')
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/terminal-drayages/9007199254740993/emails/9007199254740999/retry')
})
it('sends repeated container ids and preserves long identifiers as strings', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ data: [view] })
const result = await listTerminalDrayages(['9007199254740993', '9007199254740994'])
const config = vi.mocked(http.get).mock.calls[0][1] as { params: URLSearchParams }
expect(config.params.getAll('containerBookingIds')).toEqual(['9007199254740993', '9007199254740994'])
expect(result[0]).toMatchObject({ containerBookingId: '9007199254740993', oceanBookingId: '9007199254740994', fleetSupplierId: '9007199254740995', version: 3 })
expect(result[0].emptyReturnWarning).toMatchObject({ overdueHours: 26, overdueDays: 1 })
})
it('uses frozen command paths, versions and idempotency keys', async () => {
vi.mocked(http.post).mockResolvedValue({ data: view })
await selectTerminalDrayageFleet('101', { expectedVersion: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队' })
await scheduleTerminalDrayagePickup('101', { expectedVersion: 4, pickupAppointmentAt: '2026-09-07T08:00:00' })
await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 5, actualAt: '2026-09-10T09:00:00', remark: '实际还柜' })
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/terminal-drayages/101/fleet', expect.objectContaining({ expectedVersion: 3, fleetSupplierId: '501' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-fleet-') } }))
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/terminal-drayages/101/pickup-appointment', expect.objectContaining({ expectedVersion: 4 }), expect.any(Object))
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 5, remark: '实际还柜' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-empty-return-') } }))
})
it('loads operation history from the container business id', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ data: [{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: null, operatorId: '9007199254740993', operatorName: '平台管理员', createdAt: '2026-09-07T08:00:00' }] })
const result = await getTerminalDrayageHistory('101')
expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/101/history')
expect(result[0].operatorId).toBe('9007199254740993')
})
})
@@ -0,0 +1,128 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type TerminalDrayageStatus = 'WAITING_FOR_ARRIVAL' | 'ARRIVED' | 'PICKUP_SCHEDULED' | 'PICKED_UP' | 'UNLOADED' | 'EMPTY_RETURNED'
export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL'
export interface TerminalDrayageEmail {
id: string
fleetName: string
recipient: string
kind: 'REQUEST_PICKUP' | 'CANCEL_PICKUP'
subject: string
body: string
status: 'PENDING' | 'SENDING' | 'SENT' | 'FAILED'
attempts: number
failureReason: string | null
createdAt: string
sentAt: string | null
}
export async function getTerminalDrayageEmails(containerId: string) {
type Mailbox = { deliveryEnabled: boolean; records: TerminalDrayageEmail[] }
const response = await http.get<ApiEnvelope<Mailbox>, ApiEnvelope<Mailbox>>(`${path}/${encodeURIComponent(containerId)}/emails`)
const result = unwrapApiResponse(response)
return { ...result, records: result.records.map((mail) => ({ ...mail, id: String(mail.id) })) }
}
export function sendTerminalDrayageEmail(containerId: string, expectedVersion: number) {
return command(containerId, 'pickup-email', { expectedVersion })
}
export async function retryTerminalDrayageEmail(containerId: string, mailId: string) {
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(`${path}/${encodeURIComponent(containerId)}/emails/${encodeURIComponent(mailId)}/retry`)
unwrapApiResponse(response)
}
export type EmptyReturnWarningStatus = 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED'
export interface EmptyReturnWarning {
status: EmptyReturnWarningStatus
deadlineAt: string | null
overdueHours: number
overdueDays: number
}
export interface TerminalDrayageView {
containerBookingId: string
containerNo: string | null
oceanBookingId: string | null
status: TerminalDrayageStatus
version: number
fleetSupplierId: string | null
fleetSupplierName: string | null
pickupAppointmentAt: string | null
actualPickupAt: string | null
estimatedEmptyReturnAt: string | null
actualEmptyReturnAt: string | null
unloadingCompletedAt: string | null
emptyReturnWarning: EmptyReturnWarning
allowedActions: TerminalDrayageAction[]
updatedAt: string | null
}
export interface TerminalDrayageHistory {
action: TerminalDrayageAction
fromStatus: string
toStatus: string
fromVersion: number
toVersion: number
reason: string | null
operatorId: string
operatorName: string | null
createdAt: string
}
const path = '/platform/v1/terminal-drayages'
function operationId(action: string) { return `terminal-drayage-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function id(value: unknown): string { return value == null ? '' : String(value) }
function optionalId(value: unknown): string | null { return value == null ? null : String(value) }
function normalize(raw: TerminalDrayageView): TerminalDrayageView {
return {
...raw,
containerBookingId: id(raw.containerBookingId),
oceanBookingId: optionalId(raw.oceanBookingId),
fleetSupplierId: optionalId(raw.fleetSupplierId),
version: Number(raw.version || 0),
allowedActions: raw.allowedActions || [],
emptyReturnWarning: {
...raw.emptyReturnWarning,
overdueHours: Number(raw.emptyReturnWarning?.overdueHours || 0),
overdueDays: Number(raw.emptyReturnWarning?.overdueDays || 0),
},
}
}
export async function listTerminalDrayages(containerBookingIds: string[]) {
const params = new URLSearchParams()
containerBookingIds.forEach((containerBookingId) => params.append('containerBookingIds', containerBookingId))
const response = await http.get<ApiEnvelope<TerminalDrayageView[]>, ApiEnvelope<TerminalDrayageView[]>>(path, { params })
return (unwrapApiResponse(response) || []).map(normalize)
}
export async function getTerminalDrayage(containerBookingId: string) {
const response = await http.get<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(`${path}/${encodeURIComponent(containerBookingId)}`)
return normalize(unwrapApiResponse(response))
}
export async function getTerminalDrayageHistory(containerBookingId: string) {
const response = await http.get<ApiEnvelope<TerminalDrayageHistory[]>, ApiEnvelope<TerminalDrayageHistory[]>>(`${path}/${encodeURIComponent(containerBookingId)}/history`)
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) }))
}
async function command(containerBookingId: string, action: string, body: object) {
const response = await http.post<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(
`${path}/${encodeURIComponent(containerBookingId)}/${action}`,
body,
{ headers: { 'Idempotency-Key': operationId(action) } },
)
return normalize(unwrapApiResponse(response))
}
export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) {
return command(containerBookingId, 'fleet', body)
}
export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) {
return command(containerBookingId, 'pickup-appointment', body)
}
export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) {
return command(containerBookingId, 'empty-return', body)
}
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
const api = vi.hoisted(() => ({
get: vi.fn(),
start: vi.fn(),
completeAllBuy: vi.fn(),
sendToBroker: vi.fn(),
getDeclarationBatches: vi.fn(),
upload: vi.fn(),
downloadSource: vi.fn(),
downloadReceipt: vi.fn(),
downloadFinal: vi.fn(),
}))
vi.mock('../api/platformContainerCustoms', () => ({
getContainerCustoms: api.get,
startContainerCustoms: api.start,
completeAllBuyContainerCustoms: api.completeAllBuy,
uploadContainerCustomsReceipt: api.upload,
downloadContainerCustomsDocument: api.downloadSource,
downloadContainerCustomsReceipt: api.downloadReceipt,
downloadContainerCustomsFinal: api.downloadFinal,
submitContainerCustomsToBroker: vi.fn(),
sendContainerCustomsToBroker: api.sendToBroker,
getDeclarationBatches: api.getDeclarationBatches,
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
import ContainerCustomsPanel from './ContainerCustomsPanel.vue'
function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView {
return {
aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403',
planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12,
inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }],
documents: [{
documentId: '2092438843630391407', tenantId: '2092438843630391406',
source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' },
inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true,
receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null,
confirmationHistory: hasCompletion ? [{ id: '2092438843630391411', receiptVersion: 1, action: 'CONFIRM', reason: null, operatorName: '客户一', createdAt: '2026-08-29T11:15:00' }] : [],
}],
brokerSubmission: hasCompletion ? { id: '2092438843630391412', customsContainerId: '2092438843630391401', loadingPlanId: '2092438843630391402', batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } : null,
startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null,
completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null,
}
}
async function render(status: ContainerCustomsStatus, canManage = true, hasCompletion = false) {
api.get.mockResolvedValue(customs(status, hasCompletion))
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
return wrapper
}
function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
return wrapper.findAll('button').map((button) => button.text())
}
describe('ContainerCustomsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
api.getDeclarationBatches.mockResolvedValue([])
api.downloadSource.mockResolvedValue(new Blob(['source']))
api.downloadReceipt.mockResolvedValue(new Blob(['receipt']))
api.downloadFinal.mockResolvedValue(new Blob(['final']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
})
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
const reasons: Array<[ClearanceResultType, string]> = [
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
['DETAINED', '扣货'], ['RELEASED', '放行'], ['OTHER', '其他'],
]
const batch: DeclarationBatch = {
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
batchNo: 'DEC-TEST-001', status: 'RESULT_PENDING', sourceVersion: 1,
customsEntryNo: 'ENTRY-TEST-001', version: 1, submittedAt: null,
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null,
results: reasons.map(([resultType], index) => ({
id: String(200 + index), declarationBatchId: '101', customsContainerId: '102',
resultType, resultStatus: 'OPEN', customsEntryNo: 'ENTRY-TEST-001',
occurredAt: '2026-09-29T10:00:00', resolution: `人工备注${index}`,
evidenceFileId: null, recordedByName: '操作员', recordedAt: '2026-09-29T10:00:00',
previousResultId: null,
})),
}
api.getDeclarationBatches.mockResolvedValue([batch])
api.get.mockResolvedValue({ ...customs('COMPLETED', true), brokerSubmittedAt: '2026-09-29T09:00:00' })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
const rows = wrapper.findAll('.declaration-result-row')
expect(rows).toHaveLength(reasons.length)
for (const [index, [code, label]] of reasons.entries()) {
expect(rows[index]!.text()).toContain(label)
expect(rows[index]!.text()).toContain(`人工备注${index}`)
expect(rows[index]!.text()).not.toContain(code)
expect(rows[index]!.find('button').exists()).toBe(false)
}
wrapper.unmount()
})
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
expect(wrapper.text()).toContain('实际箱数 12')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('报关行回执')
expect(wrapper.text()).not.toContain('客户报关资料')
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => {
api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, actualBoxCount: 0, inbounds: [], documents: [] })
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('当前装柜计划没有实际装柜货物,无需报关')
expect(wrapper.text()).toContain('无需报关')
expect(buttonTexts(wrapper)).not.toContain('开始报关')
expect(buttonTexts(wrapper)).not.toContain('导出买单 XLSX')
})
it('shows a pending state instead of an error before loading is completed', async () => {
api.get.mockResolvedValue(null)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2097922185010147329', canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('当前装柜计划尚未完成,完成装柜后可处理报关')
expect(wrapper.text()).not.toContain('整柜报关加载失败')
})
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('首次发送报关行')
expect(wrapper.text()).toContain('首次发送后上传回执')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('发送报关行执行')
expect(wrapper.text()).toContain('覆盖报关行回执')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
wrapper.unmount()
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('loads declaration batches during DECLARING after broker execution was sent', async () => {
const value = { ...customs('DECLARING', true), brokerSubmittedAt: '2026-08-29T11:20:00' }
api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(api.getDeclarationBatches).toHaveBeenCalledWith('2092438843630391402')
wrapper.unmount()
})
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
it('routes source and completion downloads independently by the long document id and original names', async () => {
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadReceipt).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadFinal).toHaveBeenCalledWith('2092438843630391407')
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(3)
expect(URL.createObjectURL).toHaveBeenCalledTimes(3)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file')
})
it('preserves backend error code, request id and trace id when one download fails', async () => {
api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download'))
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('文件不可用')
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
expect(wrapper.text()).toContain('request-customs-download')
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadReceipt).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,362 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { createPlatformExportTask } from '@/modules/downloads'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import {
completeAllBuyContainerCustoms,
createDeclarationBatch,
downloadContainerCustomsReceipt,
downloadContainerCustomsFinal,
downloadContainerCustomsDocument,
getContainerCustoms,
getDeclarationBatches,
recordClearanceResult,
resolveClearanceResult,
submitDeclarationBatch,
startContainerCustoms,
sendContainerCustomsToBroker,
submitContainerCustomsToBroker,
uploadContainerCustomsReceipt,
uploadContainerCustomsFinal,
type ContainerCustomsDocument,
type ContainerCustomsStatus,
type ContainerCustomsView,
type ClearanceResultType,
type DeclarationBatch,
} from '../api/platformContainerCustoms'
const props = defineProps<{
loadingPlanId: string
canList: boolean
canManage: boolean
departureState: 'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'
}>()
const emit = defineEmits<{ refreshed: [] }>()
const customs = ref<ContainerCustomsView | null>(null)
const declarationBatches = ref<DeclarationBatch[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const downloadingKey = ref('')
const receiptFiles = reactive<Record<string, File | null>>({})
const finalFiles = reactive<Record<string, File | null>>({})
const clearanceDialogVisible = ref(false)
const clearanceBatch = ref<DeclarationBatch | null>(null)
const clearanceResultType = ref<ClearanceResultType>('INSPECTION')
const clearanceRemark = ref('')
const clearanceResultOptions: Array<{ value: ClearanceResultType; label: string }> = [
{ value: 'INSPECTION', label: '查验' },
{ value: 'SUPPLEMENT_REQUIRED', label: '补资料' },
{ value: 'DETAINED', label: '扣货' },
{ value: 'RELEASED', label: '放行' },
{ value: 'OTHER', label: '其他' },
]
function clearanceResultLabel(value: ClearanceResultType) {
return clearanceResultOptions.find((option) => option.value === value)?.label || '未知结果'
}
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
const allReceiptsConfirmed = computed(() => Boolean(customs.value?.documents.length) && customs.value?.documents.every((document) => document.receipt && document.receiptStatus === 'CONFIRMED') === true)
const openDeclarationBatch = computed(() => declarationBatches.value.find((item) => ['READY', 'SUBMITTED', 'ACKNOWLEDGED', 'RESULT_PENDING'].includes(item.status)) || null)
const statusText: Record<ContainerCustomsStatus, string> = {
WAITING_CUSTOMER_DOCUMENTS: '待客户补齐退税资料', READY_TO_START: '可开始报关', DECLARING: '报关中', COMPLETED: '报关已完成',
}
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function receiptHistoryLabel(document: ContainerCustomsDocument) {
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
}
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
return action === 'CONFIRM' ? '确认' : '打回'
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function selectReceipt(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
receiptFiles[documentId] = upload.raw || null
actionError.value = null
}
function selectFinal(documentId: string, upload: UploadFile) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
finalFiles[documentId] = upload.raw || null
actionError.value = null
}
async function load() {
if (!props.canList || !props.loadingPlanId) return
loading.value = true
loadError.value = null
try {
customs.value = await getContainerCustoms(props.loadingPlanId)
if (customs.value?.status === 'COMPLETED' || customs.value?.brokerSubmittedAt) {
try { declarationBatches.value = await getDeclarationBatches(props.loadingPlanId) } catch { declarationBatches.value = [] }
} else declarationBatches.value = []
}
catch (error) { customs.value = null; loadError.value = getApiErrorDetails(error, '整柜报关状态加载失败') }
finally { loading.value = false }
}
function declarationStatusLabel(value: DeclarationBatch['status']) {
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
}
async function createFormalDeclaration() {
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
finally { actionLoading.value = false }
}
async function submitFormalDeclaration(batch: DeclarationBatch) {
try {
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
actionLoading.value = true; actionError.value = null
await submitDeclarationBatch(batch, answer.value)
await load(); ElMessage.success('正式申报已提交')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '正式申报提交失败')
} finally { actionLoading.value = false }
}
function openFormalResultDialog(batch: DeclarationBatch) {
clearanceBatch.value = batch
clearanceResultType.value = 'INSPECTION'
clearanceRemark.value = ''
clearanceDialogVisible.value = true
}
async function recordFormalResult() {
const batch = clearanceBatch.value
if (!batch) return
if (clearanceResultType.value === 'OTHER' && !clearanceRemark.value.trim()) {
ElMessage.warning('选择“其他”时请填写备注')
return
}
actionLoading.value = true; actionError.value = null
try {
await recordClearanceResult(batch, clearanceResultType.value, clearanceRemark.value.trim() || undefined)
clearanceDialogVisible.value = false
await load(); ElMessage.success('海关结果已记录')
} catch (error) { actionError.value = getApiErrorDetails(error, '海关结果记录失败') }
finally { actionLoading.value = false }
}
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
try {
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
actionLoading.value = true; actionError.value = null
await resolveClearanceResult(result, batch.version, answer.value)
await load(); ElMessage.success('海关结果已处理')
} catch (error) {
if (error === 'cancel' || error === 'close') return
actionError.value = getApiErrorDetails(error, '海关结果处理失败')
} finally { actionLoading.value = false }
}
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
catch (error) { actionError.value = getApiErrorDetails(error, '开始整柜报关失败') }
finally { actionLoading.value = false }
}
async function uploadReceipt(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
receiptFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success('报关行回执已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
finally { actionLoading.value = false }
}
async function uploadFinal(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING') return
const selected = finalFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择真实报关完税文件'); return }
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsFinal(document.documentId, selected, document.finalDocument?.version || 0)
finalFiles[document.documentId] = null
await load(); emit('refreshed'); ElMessage.success('真实报关完税文件已上传')
} catch (error) { actionError.value = getApiErrorDetails(error, '真实报关完税文件上传失败') }
finally { actionLoading.value = false }
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'receipt' | 'final') {
const target = kind === 'source' ? document.source : kind === 'receipt' ? document.receipt : document.finalDocument
if (!target) return
const key = `${kind}:${document.documentId}`
downloadingKey.value = key
actionError.value = null
try {
const blob = kind === 'source'
? await downloadContainerCustomsDocument(document.documentId)
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
}
async function submitToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
finally { actionLoading.value = false }
}
async function sendToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
finally { actionLoading.value = false }
}
async function completeAllBuy() {
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
catch { return }
actionLoading.value = true; actionError.value = null
try {
customs.value = await completeAllBuyContainerCustoms(props.loadingPlanId, customs.value.version)
emit('refreshed')
ElMessage.success('全买单柜报关已完成')
} catch (error) { actionError.value = getApiErrorDetails(error, '完成全买单柜报关失败') }
finally { actionLoading.value = false }
}
async function exportAllBuy() {
actionLoading.value = true; actionError.value = null
try {
const task = await createPlatformExportTask({
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || '柜级明细'}`,
parameters: { loadingPlanId: String(props.loadingPlanId) },
})
ElMessage.success(`买单报关导出任务已创建:${task.taskNo}`)
} catch (error) { actionError.value = getApiErrorDetails(error, '买单报关导出任务创建失败') }
finally { actionLoading.value = false }
}
watch(() => props.loadingPlanId, () => { void load() })
onMounted(load)
</script>
<template>
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
<OperationErrorToast :details="actionError" />
<header class="customs-header">
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
<div v-if="customs" class="customs-actions">
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'DECLARING' && !brokerSubmission" type="primary" :loading="actionLoading" @click="sendToBroker">首次发送报关行</el-button>
<el-button v-if="canManage && customs.hasActualCargo && customs.status === 'DECLARING' && brokerSubmission && !brokerSubmitted" type="primary" plain :disabled="!allReceiptsConfirmed" :loading="actionLoading" @click="submitToBroker">发送报关行执行</el-button>
<el-button v-if="canManage && allBuy && customs.status === 'DECLARING'" type="primary" :loading="actionLoading" @click="completeAllBuy">确认报关完成</el-button>
</div>
</header>
<div v-if="actionError" class="action-error" role="alert"><span>{{ actionError.message }}</span><ApiErrorMeta :details="actionError" /></div>
<LoadErrorState v-if="loadError" compact title="整柜报关加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else-if="loading"><el-skeleton :rows="3" animated /></div>
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<div class="customs-metrics"><span>实际箱数 <strong>{{ customs.actualBoxCount }}</strong></span><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
<div v-if="brokerSubmission" class="broker-submission-fact" aria-label="首次发送报关行批次"><span>首次发送批次 <strong>{{ brokerSubmission.batchNo }}</strong></span><span>状态 <strong>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</strong></span><span>发送人 <strong>{{ brokerSubmission.sentByName || '—' }}</strong></span><span>发送时间 <strong>{{ formatDateTime(brokerSubmission.sentAt) }}</strong></span><span>资料版本 <strong>v{{ brokerSubmission.sourceVersion }}</strong></span></div>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺退税资料清单</h3>
<table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料、报关行回执与完税文件</h3>
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载报关行回执 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
</tr>
</tbody></table>
</div>
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>回执确认并发送报关行执行后建立正式申报批次;完税文件、船期和 PDA 节点保持独立。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="openFormalResultDialog(batch)">标记海关异常/结果</el-button></div>
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ clearanceResultLabel(result.resultType) }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
</article>
</div>
</div>
</template>
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
</template>
<el-empty v-else-if="canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
<el-dialog v-model="clearanceDialogVisible" class="admin-density-dialog" title="标记海关异常/结果" width="460px" :close-on-click-modal="false">
<el-form label-position="top" class="clearance-result-form">
<el-form-item label="选择理由" required>
<el-select v-model="clearanceResultType" class="clearance-result-select" placeholder="请选择理由">
<el-option v-for="option in clearanceResultOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item label="备注" :required="clearanceResultType === 'OTHER'">
<el-input v-model="clearanceRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="clearanceResultType === 'OTHER' ? '请填写具体情况' : '可填写短信内容或人工处理说明(可选)'" />
</el-form-item>
</el-form>
<template #footer><el-button @click="clearanceDialogVisible = false">取消</el-button><el-button type="primary" :loading="actionLoading" @click="recordFormalResult">确认标记</el-button></template>
</el-dialog>
</section>
</template>
<style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
</style>
@@ -0,0 +1,96 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue'
const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download }))
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] }))
vi.mock('../api/overseasContainerFees', () => ({
listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }],
createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(),
}))
async function setup(readonly = false) {
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: {
AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '<section v-if="modelValue"><h3>{{ title }}</h3><slot /><button :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>' },
} } })
await flushPromises()
return wrapper
}
type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean }
async function upload(wrapper: Awaited<ReturnType<typeof setup>>, name: string) {
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] })
await input.trigger('change')
await flushPromises()
}
describe('尾程付款凭证', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.fees.mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
URL.createObjectURL = vi.fn(() => 'blob:voucher')
URL.revokeObjectURL = vi.fn()
mocks.metadata.mockResolvedValue({ fileId: '91', fileName: '付款.png', contentType: 'image/png' })
mocks.download.mockResolvedValue(new Blob(['image']))
})
it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => {
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }])
const wrapper = await setup()
expect(wrapper.text()).toContain('待财务确认')
expect(wrapper.text()).not.toContain('已作废')
expect(wrapper.findAll('button').some(button => ['调整', '作废'].includes(button.text()))).toBe(false)
wrapper.unmount()
})
it('显示上传文件名,允许再次上传替换和移除', async () => {
const wrapper = await setup()
await wrapper.get('button').trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
state.form.settlementMode = 'DIRECT_PAID'
await flushPromises()
mocks.upload.mockResolvedValueOnce({ fileId: '91', fileName: '付款.png' }).mockResolvedValueOnce({ fileId: '92', fileName: '新凭证.png' })
await upload(wrapper, '付款.png')
expect(mocks.upload).toHaveBeenCalledWith('11', expect.any(File))
expect(wrapper.text()).toContain('付款.png')
await upload(wrapper, '新凭证.png')
expect(wrapper.text()).toContain('新凭证.png')
expect(wrapper.text()).not.toContain('付款.png')
await wrapper.findAll('button').find(b => b.text() === '移除')!.trigger('click')
expect(wrapper.text()).not.toContain('新凭证.png')
wrapper.unmount()
})
it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => {
const wrapper = await setup()
await wrapper.get('button').trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
state.form.settlementMode = 'DIRECT_PAID'
await flushPromises()
let complete!: (file: { fileId: string; fileName: string }) => void
mocks.upload.mockReturnValue(new Promise(resolve => { complete = resolve }))
await upload(wrapper, '旧客户.png')
expect(wrapper.findAll('button').find(b => b.text() === '保存')!.attributes('disabled')).toBeDefined()
state.form.customerId = '12'
complete({ fileId: '91', fileName: '旧客户.png' })
await flushPromises()
expect(wrapper.text()).not.toContain('旧客户.png')
wrapper.unmount()
})
it('只读列表能查看已保存的待审核凭证,并在关闭时释放预览资源', async () => {
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'PENDING', paymentVoucherFileIds: ['91'] }])
const wrapper = await setup(true)
await wrapper.findAll('button').find(b => b.text() === '付款凭证 1')!.trigger('click')
await flushPromises()
expect(mocks.metadata).toHaveBeenCalledWith('11', '91')
expect(mocks.download).toHaveBeenCalledWith('11', '91')
expect(wrapper.get('img').attributes('src')).toBe('blob:voucher')
expect(wrapper.get('a[download]').attributes('download')).toBe('付款.png')
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:voucher')
})
})
@@ -0,0 +1,210 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getFinancePaymentVoucher, downloadFinancePaymentVoucher } from '@/modules/finance'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
const access = useAccessPreviewStore()
const rows = ref<OverseasContainerFeeSource[]>([])
const customers = ref<OverseasContainerCustomer[]>([])
const suppliers = ref<ContainerFeeSupplierOption[]>([])
const loading = ref(false)
const actionLoading = ref(false)
const voucherUploading = ref(false)
const voucherInput = ref<HTMLInputElement | null>(null)
const voucherNames = reactive<Record<string, string>>({})
let uploadSequence = 0
let previewSequence = 0
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const previewName = ref('付款凭证')
const previewIsImage = ref(false)
function clearPreview() {
previewSequence++
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
async function previewVoucher(customerId: string, fileId: string) {
clearPreview()
const sequence = previewSequence
previewOpen.value = true; previewLoading.value = true; previewError.value = null
previewName.value = voucherNames[fileId] || '付款凭证'
try {
const metadata = await getFinancePaymentVoucher(customerId, fileId)
const blob = await downloadFinancePaymentVoucher(customerId, fileId)
if (sequence !== previewSequence) return
previewName.value = metadata.fileName || '付款凭证'
voucherNames[fileId] = previewName.value
previewIsImage.value = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'].includes(metadata.contentType)
previewUrl.value = URL.createObjectURL(new Blob([blob], { type: previewIsImage.value ? metadata.contentType : 'application/octet-stream' }))
} catch (cause) {
if (sequence === previewSequence) previewError.value = getApiErrorDetails(cause, '付款凭证读取失败')
} finally {
if (sequence === previewSequence) previewLoading.value = false
}
}
watch(previewOpen, (open) => { if (!open) clearPreview() })
onBeforeUnmount(() => { uploadSequence++; clearPreview() })
const error = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
const editing = ref<OverseasContainerFeeSource | null>(null)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canEdit = computed(() => !props.readonly && access.can('platform:finance:fee-source:edit'))
const canReview = computed(() => !props.readonly && access.can('platform:finance:fee-source:review'))
const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [
{ value: 'PORT_FEE', label: '港口费' }, { value: 'STORAGE_FEE', label: '堆存费' },
{ value: 'CONTAINER_REPAIR_FEE', label: '修箱费' }, { value: 'CRANE_FEE', label: '吊机费' },
{ value: 'TERMINAL_DRAYAGE_FEE', label: '码头拖车费' }, { value: 'OTHER', label: '其他' },
]
const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value
const reviewLabels: Record<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' }
const voidLabels: Record<string, string> = { NOT_REQUESTED: '—', PENDING_FINANCE: '待财务确认', FINANCE_ATTENTION: '待财务处理', CONFIRMED: '已作废' }
function voidPending(row: OverseasContainerFeeSource) { return ['PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(row.voidStatus || '') }
const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] })
watch(() => form.customerId, () => { uploadSequence++; voucherUploading.value = false; form.paymentVoucherFileIds = [] }, { flush: 'sync' })
watch(dialogOpen, (open) => { if (!open) { uploadSequence++; voucherUploading.value = false } }, { flush: 'sync' })
function key(scope: string) { return `${scope}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` }
function customerName(customerId: string) { return customers.value.find((item) => item.customerId === customerId)?.customerName || null }
function selectedSupplier() { return suppliers.value.find((item) => item.id === form.supplierId) || null }
function saveRequest(row?: OverseasContainerFeeSource): OverseasContainerFeeSaveRequest {
const item = feeItems.find((option) => option.value === form.feeItemCode)
return {
customerId: form.customerId, customerName: customerName(form.customerId), sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER',
businessId: props.containerBookingId, businessNo: props.containerNo || '', orderId: null, inboundId: null, inboundCode: null, containerId: null,
containerBookingId: props.containerBookingId, containerNo: props.containerNo || '', warehouseId: null,
feeItemCode: form.feeItemCode, feeItemName: item?.label || row?.feeItemName || form.feeItemCode, direction: form.direction,
supplierId: form.direction === 'PAYABLE' ? selectedSupplier()?.id || null : null,
supplierName: form.direction === 'PAYABLE' ? selectedSupplier()?.supplierName || null : null,
supplierCategory: form.direction === 'PAYABLE' ? selectedSupplier()?.category || null : null,
settlementMode: form.direction === 'PAYABLE' ? form.settlementMode : null,
paidAt: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paidAt || null : null,
paymentMethod: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paymentMethod : null,
paymentReferenceNo: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' ? form.paymentReferenceNo.trim() || null : null,
paymentVoucherFileIds: form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' && form.paymentVoucherFileIds.length ? form.paymentVoucherFileIds : null,
businessDate: form.businessDate, currency: 'CNY', amount: Number(form.amount),
}
}
async function load() {
if (!canView.value) return
loading.value = true; error.value = null
try { [rows.value, customers.value, suppliers.value] = await Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), canEdit.value && access.can('platform:supplier:list') ? listContainerFeeSupplierOptions(null) : Promise.resolve([])]) }
catch (cause) { error.value = getApiErrorDetails(cause, '尾程计费加载失败') }
finally { loading.value = false }
}
function openCreate() {
editing.value = null; form.customerId = customers.value[0]?.customerId || ''; form.feeItemCode = 'PORT_FEE'; form.direction = 'PAYABLE'; form.businessDate = new Date().toISOString().slice(0, 10); form.amount = null; form.supplierId = ''; form.settlementMode = 'ON_ACCOUNT'; form.paidAt = ''; form.paymentMethod = 'BANK_TRANSFER'; form.paymentReferenceNo = ''; form.paymentVoucherFileIds = []; dialogOpen.value = true
}
function openAdjust(row: OverseasContainerFeeSource) {
editing.value = row; form.customerId = row.customerId; form.feeItemCode = row.feeItemCode; form.direction = row.direction; form.businessDate = row.businessDate; form.amount = row.amount; form.supplierId = row.supplierId || ''; form.settlementMode = row.settlementMode === 'DIRECT_PAID' ? 'DIRECT_PAID' : 'ON_ACCOUNT'; form.paidAt = row.paidAt || ''; form.paymentMethod = (row.paymentMethod as ContainerFeePaymentMethod) || 'BANK_TRANSFER'; form.paymentReferenceNo = row.paymentReferenceNo || ''; form.paymentVoucherFileIds = [...row.paymentVoucherFileIds]; dialogOpen.value = true
}
async function mutate(task: () => Promise<unknown>, success: string) {
actionLoading.value = true
try { await task(); ElMessage.success(success); dialogOpen.value = false; await load() }
catch (cause) { ElMessage.error(getApiErrorMessage(cause, '尾程计费操作失败')) }
finally { actionLoading.value = false }
}
async function save() {
if (voucherUploading.value || actionLoading.value) return
if (!form.customerId || !form.businessDate || !form.amount || form.amount <= 0) return ElMessage.warning('请完整填写客户、业务日期和大于 0 的金额')
if (!props.containerNo) return ElMessage.warning('请先补录真实集装箱号后再维护尾程费用')
if (form.direction === 'PAYABLE' && !selectedSupplier()) return ElMessage.warning('应付费用必须选择供应商')
if (form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID' && (!form.paidAt || !form.paymentMethod || (!form.paymentReferenceNo.trim() && form.paymentVoucherFileIds.length === 0))) return ElMessage.warning('直接已付必须填写付款时间、方式,并提供流水号或付款凭证')
const payload = saveRequest(editing.value || undefined)
if (editing.value) await mutate(() => adjustOverseasContainerFee(editing.value!.sourceFeeId, editing.value!.sourceVersion, payload, key('container-fee-adjust')), '费用调整已提交审核')
else await mutate(() => createOverseasContainerFee(payload, key('container-fee-create')), '费用草稿已创建')
}
async function uploadVoucher(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || voucherUploading.value) return
if (!form.customerId) { ElMessage.warning('请先选择费用所属客户'); return }
const customerId = form.customerId
const sequence = ++uploadSequence
voucherUploading.value = true
try {
const uploaded = await uploadContainerFeePaymentVoucher(customerId, file)
if (sequence !== uploadSequence) return
form.paymentVoucherFileIds = [uploaded.fileId]
voucherNames[uploaded.fileId] = uploaded.fileName || file.name
ElMessage.success('付款凭证已上传')
} catch (cause) {
if (sequence === uploadSequence) ElMessage.error(getApiErrorMessage(cause, '付款凭证上传失败'))
} finally {
if (sequence === uploadSequence) voucherUploading.value = false
}
}
async function submit(row: OverseasContainerFeeSource) {
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { customClass: 'admin-density-confirm', type: 'warning' })
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
}
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
let reason: string | null = null
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { customClass: 'admin-density-confirm', type: 'warning' })
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
}
async function voidFee(row: OverseasContainerFeeSource) {
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态')
}
onMounted(load)
</script>
<template>
<div class="last-leg-fees admin-density">
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
<el-table-column prop="supplierName" label="供应商" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.supplierName || '—' }}</template></el-table-column>
<el-table-column prop="amount" label="金额" min-width="130" align="right"><template #default="{ row }">¥{{ row.amount.toFixed(2) }}</template></el-table-column>
<el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column>
<el-table-column prop="businessDate" label="业务日期" width="120" />
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<el-form class="density-form" label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
<p v-if="previewLoading">正在读取付款凭证…</p>
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
</AppDialog>
</div>
</template>
<style scoped>
.last-leg-fees { display: grid; gap: var(--admin-space-5); min-width: 0; }
.last-leg-fees__header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
.last-leg-fees__header h2 { margin: 0; color: #253553; font-size: var(--admin-font-section); }
.last-leg-fees__header p { margin: var(--admin-space-2) 0 0; color: #77839b; }
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); min-width: 0; }
.voucher-item { display: flex; gap: var(--admin-space-3); max-width: 100%; }
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
.voucher-preview { display: grid; gap: var(--admin-space-5); }
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
.row-actions { white-space: nowrap; }
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
import type { ContainerOverview } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
exception: { active: true, reason: '码头查验', markedBy: '13', markedByName: '操作员', markedAt: '2026-08-13T11:00:00' },
exceptionHistory: [{ id: '14', action: 'MARK', reason: '码头查验', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T11:00:00' }],
timeline: [{ eventType: 'TRANSPORT_UPDATE', detail: '更新预计到港时间', operatorId: '13', operatorName: '操作员', occurredAt: '2026-08-13T12:00:00' }],
}
describe('ContainerOverviewPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
})
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度')
expect(wrapper.text()).toContain('待人工复核')
expect(wrapper.text()).toContain('空柜照片')
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER')
expect(wrapper.text()).toContain('一项待复核')
expect(wrapper.text()).toContain('码头查验')
expect(wrapper.text()).not.toContain('更新预计到港时间')
})
it('emits stable document types and does not expose file ids in visible text', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
await updateButton.trigger('click')
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
expect(wrapper.text()).not.toContain('9007199254740999')
})
it('shows the channel restriction and suppresses DO uploads when the document is not applicable', () => {
const withoutDo = { ...overview, documents: overview.documents.filter((item) => item.documentType !== 'DELIVERY_ORDER') }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutDo, canManageDocuments: true, canManageCustoms: true, canManageException: true, deliveryOrderBlockedReason: '海铁渠道不适用 DO 资料' }, global: { plugins: [ElementPlus] } })
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
expect(deliveryRow.text()).toContain('海铁渠道不适用 DO 资料')
expect(deliveryRow.find('button').exists()).toBe(false)
})
it('renders two file rows and one inline PU code row without a nested heading', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
expect(headers).toEqual(['操作', '资料类型', '资料内容', '更新人', '更新时间', '状态'])
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
expect(wrapper.findAll('.document-item')).toHaveLength(0)
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
expect(wrapper.text()).not.toContain('业务资料')
const pickupInput = wrapper.find('input[aria-label="PU码"]')
expect((pickupInput.element as HTMLInputElement).value).toBe('00123456')
await pickupInput.setValue('998877')
await wrapper.find('.pickup-code-row button').trigger('click')
expect(wrapper.emitted('savePickupCode')?.[0]).toEqual(['998877'])
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
expect(deliveryRow.text()).toContain('do.pdf')
expect(deliveryRow.text()).toContain('操作员')
expect(deliveryRow.text()).toContain('2026-08-13 10:00')
expect(deliveryRow.text()).toContain('已上传')
expect(deliveryRow.find('.document-file-name').attributes('title')).toBe('do.pdf')
})
it('renders one compact normal exception section with a single-line empty history', () => {
const normal = { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [] }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: normal, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const section = wrapper.find('.exception-management')
expect(section.text()).toContain('异常管理')
expect(section.text()).toContain('正常')
expect(section.text()).toContain('标记异常')
expect(section.text()).toContain('暂无异常处理记录')
expect(section.find('.el-empty').exists()).toBe(false)
expect(wrapper.findAll('[aria-label="异常管理"]')).toHaveLength(1)
})
it('keeps current exception summary and multiple history rows together without mixing transport logs', () => {
const histories = [
...overview.exceptionHistory,
{ id: '15', action: 'CANCEL', reason: '查验解除', operatorId: '16', operatorName: '复核员', occurredAt: '2026-08-13T11:30:00' },
]
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exceptionHistory: histories }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const section = wrapper.find('.exception-management')
expect(section.text()).toContain('异常')
expect(section.text()).toContain('码头查验')
expect(section.text()).toContain('操作员 · 2026-08-13 11:00')
expect(section.findAll('.exception-history li')).toHaveLength(2)
expect(section.text()).toContain('取消异常')
expect(section.text()).toContain('复核员')
expect(section.text()).toContain('查验解除')
expect(section.text()).not.toContain('更新预计到港时间')
expect(wrapper.find('[aria-label="运输轨迹和操作日志"]').exists()).toBe(false)
})
it('emits the unchanged manage action for mark and cancel buttons', async () => {
let wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('.exception-management button').trigger('click')
expect(wrapper.emitted('manageException')).toHaveLength(1)
wrapper.unmount()
wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('.exception-management button').trigger('click')
expect(wrapper.emitted('manageException')).toHaveLength(1)
expect(wrapper.find('.exception-management button').text()).toContain('取消异常')
wrapper.unmount()
})
it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download')
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click')
expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer')
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
})
it('revokes old thumbnail URLs when the overview photos change', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' }
await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } })
await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download')
wrapper.unmount()
})
it('isolates one thumbnail failure without blocking other photos', async () => {
const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' }
vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable'))
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
expect(wrapper.text()).toContain('照片加载失败')
wrapper.unmount()
})
it('delegates warehouse events to the unified fulfillment timeline without exposing technical keys here', () => {
const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76'
const timeline = [
{ eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
{ eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
{ eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' },
]
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).not.toContain('领取 PDA 任务')
expect(wrapper.text()).not.toContain('提交拣货结果')
expect(wrapper.text()).not.toContain('完成装柜')
expect(wrapper.text()).not.toContain(longKey)
})
it('renders a compact empty photo state without exposing credentials', () => {
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('暂无可查看的有效照片')
expect(wrapper.text()).not.toContain('Authorization')
})
})
@@ -0,0 +1,179 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
const props = defineProps<{
overview: ContainerOverview
canManageDocuments: boolean
canManageCustoms: boolean
canManageException: boolean
documentBusy?: boolean
pickupCodeBusy?: boolean
deliveryOrderBlockedReason?: string | null
sections?: Array<'exception' | 'documents' | 'pda'>
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
savePickupCode: [pickupCode: string]
manageException: []
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const containerDownloadableTypes = new Set(['DELIVERY_ORDER'])
const pickupCodeInput = ref('')
watch(() => props.overview.container.pickupCode, (value) => { pickupCodeInput.value = value || '' }, { immediate: true })
const pickupCodeValid = computed(() => /^[0-9]{1,64}$/.test(pickupCodeInput.value))
function canDownloadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType)
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
}
function canUploadDocument(documentType: string) {
if (documentType === 'DELIVERY_ORDER' && props.deliveryOrderBlockedReason) return false
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
}
function hasDocumentAction(documentType: string) {
const document = documentsByType.value.get(documentType)
return Boolean((document && canDownloadDocument(documentType)) || (canUploadDocument(documentType) && !document?.completed))
}
function documentStatusText(documentType: string) {
const document = documentsByType.value.get(documentType)
return document ? formatTime(document.uploadedAt) : '等待上传'
}
function documentStatusLabel(documentType: string) {
const document = documentsByType.value.get(documentType)
if (!document) return '未上传'
return document.completed ? '已完成' : '已上传'
}
function documentStatusType(documentType: string) {
const document = documentsByType.value.get(documentType)
if (!document) return 'info'
return document.completed ? 'success' : 'primary'
}
const pdaProgress = computed(() => {
const pda = props.overview.pda
if (!pda || pda.itemCount <= 0) return 0
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
})
const visibleSections = computed(() => new Set(props.sections || ['exception', 'documents', 'pda']))
const activePhotos = computed(() => visibleSections.value.has('pda')
? (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath)
: [])
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
let photoGeneration = 0
const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
const evidenceTypeLabels: Record<string, string> = {
PHOTO_STEP_EMPTY_CONTAINER: '空柜照片', PHOTO_STEP_HALF_LOADED: '半柜照片', PHOTO_STEP_FULL_CONTAINER: '满柜照片',
EMPTY_CONTAINER: '空柜照片', HALF_LOADED: '半柜照片', FULL_CONTAINER: '满柜照片', SEAL: '封条照片',
}
function workflowStatusLabel(workflowStatus: string | null | undefined, taskStatus: string) { return workflowStatusLabels[workflowStatus || taskStatus] || '作业状态待同步' }
function evidenceTypeLabel(value: string) { return evidenceTypeLabels[value] || '装柜照片' }
function formatTime(value: string | null | undefined) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function eventLabel(value: string) {
return ({
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜',
} as Record<string, string>)[value] || value || '状态更新'
}
function revokePhotoUrls() {
Object.values(photoStates).forEach((state) => { if (state.url) URL.revokeObjectURL(state.url) })
Object.keys(photoStates).forEach((key) => delete photoStates[key])
}
async function loadPhotoThumbnails() {
const generation = ++photoGeneration
revokePhotoUrls()
await Promise.all(activePhotos.value.map(async (photo) => {
photoStates[photo.evidenceId] = { loading: true, url: null, error: null }
try {
const blob = await downloadContainerPdaPhoto(photo.downloadPath)
if (generation !== photoGeneration) return
photoStates[photo.evidenceId] = { loading: false, url: URL.createObjectURL(blob), error: null }
} catch (cause) {
if (generation !== photoGeneration) return
photoStates[photo.evidenceId] = { loading: false, url: null, error: getApiErrorDetails(cause, '照片加载失败').message }
}
}))
}
function openPhoto(photo: ContainerPdaPhoto) {
const url = photoStates[photo.evidenceId]?.url
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
watch(() => activePhotos.value.map((photo) => `${photo.evidenceId}:${photo.downloadPath}`).join('|'), () => void loadPhotoThumbnails(), { immediate: true })
onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
</script>
<template>
<div class="overview-panel admin-density">
<section v-if="visibleSections.has('exception')" class="overview-section exception-management" aria-label="异常管理">
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
</section>
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
<div class="document-table-wrap">
<table class="document-table density-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>资料内容</th><th>更新人</th><th>更新时间</th><th>状态</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions density-actions"><el-button class="density-action" v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)">下载</el-button><el-button class="density-action" v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)">{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></td>
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
</tr><tr class="pickup-code-row">
<td class="document-table__actions"><el-button data-tone="primary" class="density-action" v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
<td><strong>PU 资料</strong></td>
<td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td>
<td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td>
<td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
</tr></tbody>
</table>
</div>
</section>
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
</section>
</div>
</template>
<style scoped>
.overview-panel { display: grid; gap: var(--admin-space-5); }
.overview-section { min-width: 0; background: var(--admin-surface); border: 0; padding: 0; }
.overview-section--documents{padding:0;border:0}
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.overview-section h3 { margin: 0; font-size: var(--admin-font-section); }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{color:var(--cc-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:var(--admin-space-5);margin-bottom:var(--admin-space-4)}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2); margin:0; }
.metric-grid > div { display: flex; align-items: baseline; gap: var(--admin-space-2); padding: 0; background: transparent; }
.metric-grid span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.metric-grid strong { font-size: var(--admin-font-body); }
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
</style>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { createContainerSimulation } from '../api/platformContainerLoading'
import type { ContainerSimulationCreateRequest } from '../types'
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
const props = withDefaults(defineProps<{
loadWarehouseOptions?: () => Promise<PlatformWarehouseSummaryResource[]>
successMessage?: string
dense?: boolean
}>(), { successMessage: '模拟柜已创建,可先排货后关联真实订舱柜' })
const emit = defineEmits<{ created: [] }>()
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const warehouseLoading = ref(false)
let warehousesLoaded = false
let channelSequence = 0
const simulationOpen = ref(false)
const simulationSaving = ref(false)
const simulationError = ref<ApiErrorDetails | null>(null)
const simulationChannels = ref<OceanLogisticsChannel[]>([])
const simulationChannelsLoading = ref(false)
const simulationFormRef = ref<FormInstance>()
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500 })
const simulationChannelGroups = computed(() => {
const activeCodes = new Set(simulationChannels.value.map((channel) => channel.channelCode))
return SMART_LOADING_CHANNEL_GROUPS.filter((group) => group.channelCodes.some((code) => activeCodes.has(code)))
})
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelGroupCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
}
async function openSimulation() {
Object.assign(simulationForm, { channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500 })
simulationChannels.value = []
simulationError.value = null
simulationOpen.value = true
channelSequence++
simulationChannelsLoading.value = false
simulationFormRef.value?.clearValidate()
if (warehousesLoaded) return
warehouseLoading.value = true
try {
warehouses.value = props.loadWarehouseOptions
? await props.loadWarehouseOptions()
: (await listAllPlatformWarehouses({ status: 'ACTIVE' })).filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
warehousesLoaded = true
} catch (error) { simulationError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
}
function selectSimulationContainerType(containerType: string) {
applyContainerSpecification(simulationForm, containerType)
}
async function selectSimulationWarehouse(value: unknown) {
const sequence = ++channelSequence
simulationChannelsLoading.value = false
simulationForm.warehouseId = String(value || '')
simulationForm.channelGroupCode = ''
simulationChannels.value = []
simulationError.value = null
if (!simulationForm.warehouseId) return
simulationChannelsLoading.value = true
try {
const channels = await listWarehouseLogisticsChannels(simulationForm.warehouseId)
if (sequence === channelSequence) simulationChannels.value = channels
}
catch (error) { if (sequence === channelSequence) simulationError.value = getApiErrorDetails(error, '物流渠道加载失败') }
finally { if (sequence === channelSequence) simulationChannelsLoading.value = false }
}
async function saveSimulation() {
if (simulationSaving.value || warehouseLoading.value || simulationChannelsLoading.value) return
if (!(await simulationFormRef.value?.validate().catch(() => false))) return
simulationSaving.value = true
simulationError.value = null
try {
await createContainerSimulation({ ...simulationForm })
simulationOpen.value = false
ElMessage.success(props.successMessage)
emit('created')
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
}
</script>
<template>
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
</style>
@@ -0,0 +1,79 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import type { ContainerBookingResource } from '../types'
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
const container = {
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null,
oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null,
destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null,
loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 7,
} satisfies ContainerBookingResource
const candidate = {
oceanBookingId: '101', bookingNo: 'OB-101', billOfLadingNo: 'BL-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
}
describe('OceanBookingLinkDialog', () => {
beforeEach(() => {
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [candidate], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(linkContainerOceanBooking).mockReset().mockResolvedValue({ ...container, oceanBookingId: '101', oceanBookingRequirementId: '202', version: 8 } as never)
})
it('loads matching released-booking requirements when opened', async () => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
wrapper.unmount()
})
it.each([
[' mscu1234567 ', 'MSCU1234567'],
['', null],
])('normalizes and submits the optional real container number %#', async (input, expected) => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
vm.candidateKey = '101:202'
vm.containerNo = input
await vm.save()
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
})
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
expect(wrapper.emitted('linked')).toHaveLength(1)
wrapper.unmount()
})
it('prefills and preserves a real container number recorded before booking linkage', async () => {
const existingContainer = { ...container, containerNo: 'MSCU6639870' }
const wrapper = mount(OceanBookingLinkDialog, {
props: { modelValue: true, container: existingContainer },
global: { plugins: [ElementPlus] },
})
await flushPromises()
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
expect(vm.containerNo).toBe('MSCU6639870')
vm.candidateKey = '101:202'
await vm.save()
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU6639870', expectedVersion: 7,
})
wrapper.unmount()
})
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
import type { ContainerBookingResource } from '../types'
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
linked: [container: ContainerBookingResource]
}>()
const loading = ref(false)
const saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const candidateKey = ref('')
const containerNo = ref('')
const candidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string }>>([])
let requestSequence = 0
function optional(value: string | null | undefined) { return value?.trim() || null }
function normalizeContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
function close() { emit('update:modelValue', false) }
async function loadCandidates() {
const target = props.container
candidateKey.value = ''
containerNo.value = normalizeContainerNo(target?.containerNo)
candidates.value = []
error.value = null
if (!target) return
const warehouseId = optional(target.warehouseId)
const channelGroupCode = optional(target.channelGroupCode)
const containerType = optional(target.containerType)
if (!warehouseId || !channelGroupCode || !containerType) {
error.value = { message: '当前模拟柜缺少仓库、计划渠道或柜型,无法查询可关联订舱' }
return
}
const sequence = ++requestSequence
loading.value = true
try {
const result = await listOceanBookingLinkCandidates({ warehouseId, channelGroupCode, containerType, page: 1, size: 200 })
if (sequence !== requestSequence || !props.modelValue || props.container?.id !== target.id) return
candidates.value = result.records.map((candidate) => ({
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
label: `${candidate.bookingNo} · 提单号 ${candidate.billOfLadingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (cause) {
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
} finally {
if (sequence === requestSequence) loading.value = false
}
}
async function save() {
const target = props.container
const candidate = candidates.value.find((item) => item.key === candidateKey.value)
const normalized = normalizeContainerNo(containerNo.value)
containerNo.value = normalized
if (!target || !candidate) {
error.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }
return
}
saving.value = true
error.value = null
try {
const linked = await linkContainerOceanBooking(target.id, {
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
containerNo: normalized || null,
expectedVersion: target.version,
})
close()
ElMessage.success(normalized ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
emit('linked', linked)
} catch (cause) {
error.value = getApiErrorDetails(cause, '关联真实订舱柜失败')
} finally { saving.value = false }
}
watch(() => props.modelValue, (open) => {
if (open) void loadCandidates()
else {
requestSequence += 1
loading.value = false
}
}, { immediate: true })
defineExpose({ candidates, candidateKey, containerNo, save })
</script>
<template>
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
<!-- <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" /> -->
<el-form class="density-form" label-position="top">
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
</el-form>
<!-- <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> -->
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.dialog-hint { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
</style>
@@ -0,0 +1,32 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SmartLoadingCandidateInbounds from './SmartLoadingCandidateInbounds.vue'
import type { SmartLoadingCandidate } from '../types'
describe('sealed pallet composition', () => {
it('shows the two inbound portions, not the whole pallet count twice', async () => {
const candidate = { unitType: 'SEALED_PALLET', inboundCode: 'I180', availablePackageCount: 13,
groupMembers: [{ accountId: '1', inboundId: '180', inboundCode: 'I180', availablePackageCount: 3 },
{ accountId: '1', inboundId: '181', inboundCode: 'I181', availablePackageCount: 10 }] } as SmartLoadingCandidate
const wrapper = mount(SmartLoadingCandidateInbounds, { props: { candidate } })
expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开')
expect(wrapper.text()).toContain('I180:3 箱')
expect(wrapper.text()).toContain('I181:10 箱')
expect(wrapper.text()).not.toContain('13 箱')
expect(wrapper.findAll('button')).toHaveLength(0)
})
it('renders the same composition when it comes from an assigned plan pallet', () => {
const wrapper = mount(SmartLoadingCandidateInbounds, { props: {
sealedPallet: true,
fallbackInboundCode: 'I180',
members: [
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
],
} })
expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开')
expect(wrapper.text()).toContain('I180:6 箱')
expect(wrapper.text()).toContain('I181:5 箱')
})
})
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SmartLoadingCandidate, SmartLoadingCandidateGroupMember } from '../types'
const props = defineProps<{
candidate?: SmartLoadingCandidate
members?: SmartLoadingCandidateGroupMember[]
sealedPallet?: boolean
fallbackInboundCode?: string | null
}>()
const displayedMembers = computed(() => props.members || props.candidate?.groupMembers || [])
const isSealedPallet = computed(() => props.candidate
? props.candidate.unitType === 'SEALED_PALLET'
: props.sealedPallet === true)
const inboundCode = computed(() => props.fallbackInboundCode ?? props.candidate?.inboundCode)
</script>
<template>
<details v-if="isSealedPallet && displayedMembers.length">
<summary>{{ displayedMembers.length }} 个入仓码 · 展开</summary>
<div v-for="member in displayedMembers" :key="`${member.accountId}:${member.inboundId}`">
{{ member.inboundCode || '—' }}:{{ member.availablePackageCount }} 箱
</div>
</details>
<span v-else>{{ inboundCode || '—' }}</span>
</template>
@@ -0,0 +1,51 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingDialog from './SmartLoadingDialog.vue'
describe('SmartLoadingDialog', () => {
afterEach(() => { document.body.innerHTML = '' })
it('opens fullscreen by default, can return to normal size, and reopens fullscreen', async () => {
const wrapper = mount(SmartLoadingDialog, {
attachTo: document.body,
props: { modelValue: true },
global: {
plugins: [ElementPlus],
stubs: { GlobalSmartLoadingView: { template: '<div>智能装柜工作台</div>' } },
},
})
await flushPromises()
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).toBeNull()
expect(wrapper.find('button[aria-label="展开全屏"]').exists()).toBe(true)
await wrapper.setProps({ modelValue: false })
await flushPromises()
await wrapper.setProps({ modelValue: true })
await flushPromises()
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
})
it('keeps the outer dialog fixed so only workbench data panes scroll', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/SmartLoadingDialog.vue'), 'utf8')
expect(source).toContain('fullscreenable')
expect(source).toContain('default-fullscreen')
expect(source).not.toContain('Maximize2')
expect(source).not.toContain('const fullscreen')
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
expect(source).toContain('height: 94vh; max-height: 94vh;')
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
})
})
@@ -0,0 +1,31 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
const open = defineModel<boolean>({ default: false })
</script>
<template>
<AppDialog
v-model="open"
class="smart-loading-dialog"
title="智能装柜"
width="min(1600px, 98vw)"
fullscreenable
default-fullscreen
cancel-text="关闭"
:show-confirm="false"
destroy-on-close
>
<GlobalSmartLoadingView embedded />
</AppDialog>
</template>
<style scoped>
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
@media (max-width: 720px) {
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
}
</style>
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { containerChannelLabel } from './containerChannelLabel'
describe('container actual channel label', () => {
it('preserves the supplied channel name regardless of booking linkage', () => {
expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: '123' })).toBe('纽约海铁')
expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: null })).toBe('纽约海铁')
})
it('does not describe a linked booking as unlinked when the channel is absent', () => {
expect(containerChannelLabel({ channelName: null, oceanBookingId: '123' })).toBe('—')
expect(containerChannelLabel({ channelName: '', oceanBookingId: '123' })).toBe('—')
})
it('shows pending linkage only when both channel and booking are absent', () => {
expect(containerChannelLabel({ channelName: null, oceanBookingId: null })).toBe('待关联订舱')
expect(containerChannelLabel({ channelName: '', oceanBookingId: undefined })).toBe('待关联订舱')
})
})
@@ -0,0 +1,5 @@
import type { ContainerBookingResource } from './types'
export function containerChannelLabel(container: Pick<ContainerBookingResource, 'channelName' | 'oceanBookingId'>): string {
return container.channelName || (container.oceanBookingId ? '—' : '待关联订舱')
}
+3
View File
@@ -1,2 +1,5 @@
export * from './api/platformContainerLoading'
export * from './types'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
@@ -0,0 +1,315 @@
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
import { listContainerArrivalTimes } from '../api/containerArrivalTimes'
import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch'
import { ref } from 'vue'
import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists'
import DomesticContainerDetailView from './DomesticContainerDetailView.vue'
const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true))
const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {} as Record<string, string> }))
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
vi.mock('@/modules/loading-lists', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/modules/loading-lists')>()
const api = { getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }
return {
...api,
useLoadingListDetail: () => actual.useLoadingListDetail(api),
ContainerFeesPanel: { template: '<div />' },
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded', 'dense', 'backLabel'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' },
PalletLabelPrintDialog: { template: '<div />' },
}
})
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() }))
const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } }
const overview = { container: { id: '9001', version: 1 }, loadingPlanId: null, documents: [], exception: { active: false }, timeline: [] }
enableAutoUnmount(afterEach)
async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') {
const wrapper = mount(ContainerDetailView, { props: { workbench }, attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } })
await flushPromises()
return wrapper
}
function accessibleText(wrapper: { element: Element } | undefined) {
if (!wrapper) return undefined
const element = wrapper.element.cloneNode(true) as Element
element.querySelectorAll('[aria-hidden="true"]').forEach(node => node.remove())
return element.textContent?.trim()
}
describe('ContainerDetailView empty return', () => {
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
expect(panel.props('container').id).toBe('9001')
vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never)
panel.vm.$emit('saved'); await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('REASSIGNED / V2')
expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1')
})
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
canAccess.mockReset().mockReturnValue(true)
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
})
it('loads departed gates exactly once when read permission arrives after the container detail', async () => {
const granted = ref(false)
canAccess.mockImplementation((code) => code === 'platform:firstleg:outbound-dispatch:view' ? granted.value : true)
const wrapper = await mountDetail('DOMESTIC')
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
granted.value = true
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.text()).toContain('整柜已确认离仓;')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
const first = await mountDetail('DOMESTIC')
expect(first.text()).toContain('已确认离仓')
first.unmount()
const reopened = await mountDetail('DOMESTIC')
expect(reopened.text()).toContain('已确认离仓')
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
})
it.each(['DOMESTIC', 'OVERSEAS'] as const)('gates fee tabs by granted permissions in %s', async (workbench) => {
for (const [firstLeg, lastLeg] of [[true, false], [false, true], [true, true], [false, false]]) {
canAccess.mockImplementation((code) => code === 'firstleg:inbound:fee-detail:view' ? firstLeg! : code === 'platform:finance:customer-arap:list' ? lastLeg! : false)
const wrapper = await mountDetail(workbench)
const labels = wrapper.findAll('[role="tab"]').map((tab) => accessibleText(tab))
expect(labels.includes('国内段费用(头程)')).toBe(firstLeg)
expect(labels.includes('目的港及海外段费用(尾程)')).toBe(lastLeg)
if (lastLeg) {
await wrapper.findAll('[role="tab"]').find((tab) => accessibleText(tab) === '目的港及海外段费用(尾程)')!.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'ContainerLastLegFeesPanel' }).props('readonly')).toBe(false)
}
wrapper.unmount()
}
})
it('uses the overseas stage, submits one versioned command and refreshes to returned state', async () => {
vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never)
const wrapper = await mountDetail()
expect(wrapper.text()).toContain('待还柜')
expect(wrapper.text()).not.toContain('装柜完成')
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: expect.any(String), remark: undefined }))
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('已还柜')
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
})
it('falls back to confirmed loading facts for destination and terminal drayage for unloading time', async () => {
vi.mocked(getContainer).mockResolvedValue({
...base,
destinationWarehouseCode: null,
destinationWarehouseName: null,
warehouseId: '8',
terminalTruckingCompany: null,
actualFacts: {},
} as never)
vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
vi.mocked(getConfirmedLoadingList).mockResolvedValue({
planNo: 'LP-1',
summary: { status: 'COMPLETED' },
items: [{ destinationWarehouse: 'ABE4' }, { destinationWarehouse: 'ABE4' }],
} as never)
const wrapper = await mountDetail()
expect(getConfirmedLoadingList).toHaveBeenCalledExactlyOnceWith('7001', '8')
expect(wrapper.text()).toContain('ABE4')
expect(wrapper.text()).toContain('港运车队')
expect(wrapper.text()).toContain('2026-09-07 10:00')
})
it.each([
['缺权限', false, ['RECORD_EMPTY_RETURN'], ['RECORD_EMPTY_RETURN'], 'UNLOADED'],
['缺服务端动作', true, [], [], 'UNLOADED'],
['已还柜', true, [], [], 'EMPTY_RETURNED'],
])('%s时不提供确认还柜', async (_name, allowed, rowActions, drayageActions, status) => {
canAccess.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm' || allowed)
vi.mocked(getContainer).mockResolvedValue({ ...base, workbenchStage: status, statusSummary: { terminalDrayageStatus: status }, allowedActions: rowActions, terminalDrayage: { ...base.terminalDrayage, status, allowedActions: drayageActions } } as never)
const wrapper = await mountDetail()
expect(wrapper.findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
})
})
describe('domestic container loading list and booking link', () => {
const domestic = { ...base, containerNo: null, warehouseId: '8', channelCode: 'ZBYHK', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null }
const loadingDetail = { planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] }
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
canAccess.mockReset().mockImplementation((code) => ['platform:firstleg:container:list', 'platform:firstleg:container:edit', 'platform:firstleg:preload:list', 'platform:firstleg:loading-list:list'].includes(code))
vi.mocked(getContainer).mockReset().mockResolvedValue(domestic as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never)
vi.mocked(getPreloadPlan).mockReset().mockResolvedValue(loadingDetail as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue(loadingDetail as never)
vi.mocked(listOceanBookingLinkCandidates).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(linkContainerOceanBooking).mockReset()
})
async function mountDomestic() {
const wrapper = mount(DomesticContainerDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } })
await flushPromises()
expect(getContainer).toHaveBeenLastCalledWith('9001', 'DOMESTIC')
return wrapper
}
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic()
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
const tab = wrapper.findAll('[role="tab"]').find((item) => accessibleText(item) === '装柜清单')!
expect(tab).toBeDefined()
await tab.trigger('click')
await flushPromises()
expect(tab.attributes('aria-selected')).toBe('true')
expect(wrapper.get('[data-testid="loading-detail"]').text()).toBe('LP-1')
expect(planStatus === 'COMPLETED' ? getConfirmedLoadingList : getPreloadPlan).toHaveBeenCalledExactlyOnceWith('7001', '8')
expect(planStatus === 'COMPLETED' ? getPreloadPlan : getConfirmedLoadingList).not.toHaveBeenCalled()
})
it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('enforces the matching read permission for a %s deep link', async (planStatus) => {
for (const preload of [false, true]) {
for (const confirmed of [false, true]) {
vi.mocked(getPreloadPlan).mockClear()
vi.mocked(getConfirmedLoadingList).mockClear()
routeState.query = { tab: 'loading-list' }
canAccess.mockImplementation((code) => code === 'platform:firstleg:preload:list' ? preload : code === 'platform:firstleg:loading-list:list' ? confirmed : code === 'platform:firstleg:container:list')
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never)
const wrapper = await mountDomestic()
const permitted = planStatus === 'COMPLETED' ? confirmed : preload
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.some((tab) => accessibleText(tab) === '装柜清单')).toBe(permitted)
expect(accessibleText(tabs.find((tab) => tab.attributes('aria-selected') === 'true'))).toBe(permitted ? '装柜清单' : '送柜报关')
expect(getPreloadPlan).toHaveBeenCalledTimes(permitted && planStatus !== 'COMPLETED' ? 1 : 0)
expect(getConfirmedLoadingList).toHaveBeenCalledTimes(permitted && planStatus === 'COMPLETED' ? 1 : 0)
if (permitted) {
const drawer = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(drawer.props('mode')).toBe(planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
expect(drawer.props('embedded')).toBeUndefined()
expect(drawer.props('backLabel')).toBe('返回货柜概览')
}
wrapper.unmount()
}
}
})
it('does not expose the domestic loading-list deep link in the overseas workbench', async () => {
routeState.query = { tab: 'loading-list' }
vi.mocked(getContainer).mockResolvedValue(base as never)
const wrapper = await mountDetail('OVERSEAS')
expect(wrapper.findAll('[role="tab"]').map((tab) => accessibleText(tab))).not.toContain('装柜清单')
expect(accessibleText(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true'))).toBe('清关提柜')
expect(getPreloadPlan).not.toHaveBeenCalled()
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
})
it('keeps the tab and reports a loading failure without inventing a successful list', async () => {
routeState.query = { tab: 'loading-list' }
vi.mocked(getPreloadPlan).mockRejectedValue(new Error('清单读取失败'))
const wrapper = await mountDomestic()
const list = wrapper.findComponent({ name: 'LoadingListDetailDrawer' })
expect(list.props('detail')).toBeNull()
expect(list.props('error')).toMatchObject({ message: '装柜清单加载失败' })
expect(list.props('loading')).toBe(false)
expect(accessibleText(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true'))).toBe('装柜清单')
})
it.each(['WAIT_LOADING', 'EDITING'])('retains the unlinked booking entry with an existing plan in %s', async (planStatus) => {
vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus, version: 2 } as never)
const wrapper = await mountDomestic()
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
expect(link).toBeDefined()
await link.trigger('click')
await flushPromises()
expect(routerPush).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OceanBookingLinkDialog' }).props('modelValue')).toBe(true)
})
it('refreshes the routed detail after the shared link dialog succeeds', async () => {
const linked = { ...domestic, oceanBookingId: '101', billOfLadingNo: 'BL-101', version: 2 }
vi.mocked(getContainer).mockResolvedValueOnce(domestic as never).mockResolvedValueOnce(linked as never)
const wrapper = await mountDomestic()
await wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!.trigger('click')
await flushPromises()
const dialog = wrapper.findComponent({ name: 'OceanBookingLinkDialog' })
expect(dialog.props('modelValue')).toBe(true)
dialog.vm.$emit('linked', linked)
await flushPromises()
expect(getContainer).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('BL-101')
expect(wrapper.findAll('button').some((button) => button.text() === '关联已放舱订舱')).toBe(false)
})
it('keeps the same cabinet link before and after an inbound assignment snapshot refresh', async () => {
// Assignment changes the version and plan items, not the booking association or EDITING status.
for (const [version, items] of [[1, []], [2, [{ inboundCode: 'IN-001' }]]] as const) {
routeState.query = { tab: 'loading-list' }
vi.mocked(getContainer).mockResolvedValue({ ...domestic, version } as never)
vi.mocked(getPreloadPlan).mockResolvedValue({ ...loadingDetail, items } as never)
const wrapper = await mountDomestic()
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail').items).toEqual(items)
await wrapper.findAll('[role="tab"]').find((tab) => accessibleText(tab) === '送柜报关')!.trigger('click')
await flushPromises()
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
expect(link.isVisible()).toBe(true)
routerPush.mockClear()
await link.trigger('click')
await flushPromises()
expect(routerPush).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'OceanBookingLinkDialog' }).props('modelValue')).toBe(true)
wrapper.unmount()
}
})
it.each([
{ label: 'already linked simulation', patch: { oceanBookingId: '8001' }, edit: true },
{ label: 'picking started', patch: { planStatus: 'LOADING' }, edit: true },
{ label: 'completed', patch: { planStatus: 'COMPLETED' }, edit: true },
{ label: 'server denies editing', patch: { allowedActions: [] }, edit: true },
{ label: 'account lacks edit permission', patch: {}, edit: false },
])('hides the booking link when $label', async ({ patch, edit }) => {
canAccess.mockImplementation((code) => code === 'platform:firstleg:container:list' || (edit && code === 'platform:firstleg:container:edit'))
vi.mocked(getContainer).mockResolvedValue({ ...domestic, ...patch } as never)
const wrapper = await mountDomestic()
expect(wrapper.findAll('button').some((button) => button.text() === '关联已放舱订舱')).toBe(false)
expect(routerPush).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,139 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const loadingRouteSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
const navigationSource = readFileSync(resolve(process.cwd(), 'src/core/navigation/adminNavigation.ts'), 'utf8')
const lastLegFeesSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerLastLegFeesPanel.vue'), 'utf8')
describe('ContainerDetailView customs integration', () => {
it('renders the requested detail sections in the confirmed business order', () => {
const headings = [
'container-information-heading">货柜信息',
'container-voyage-heading">船名航次信息',
'container-customs-heading">{{ isDomestic',
'container-receiving-heading">装柜作业与国内出仓',
]
let previous = -1
for (const heading of headings) {
const current = source.indexOf(heading)
expect(current, heading).toBeGreaterThan(previous)
previous = current
}
})
it('reuses the current schedule, customs, receipt and container-fee capabilities', () => {
expect(source).toContain('<CarrierScheduleQueryPanel')
expect(source).toContain(':sections="[\'documents\']"')
expect(source).toContain(':sections="[\'pda\']"')
expect(source).toContain('<LoadingListDetailDrawer')
expect(source).toContain('useLoadingListDetail')
expect(source).toContain('await loadListDetail(mode, aggregate.loadingPlanId')
expect(source).toContain('label="装柜清单"')
expect(source).not.toContain('function printLoadingList')
expect(source).toContain("const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')")
expect(source).toContain('<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览"')
expect(source).toContain(':sections="[\'pda\']"')
})
it('keeps the container detail entry alongside the standalone manifest', () => {
expect(routeSource).toContain("name: 'domestic-container-detail'")
expect(routeSource).toContain("name: 'overseas-container-detail'")
expect(loadingRouteSource).toContain("name: 'loading-lists'")
expect(navigationSource).toContain("title: '装柜清单'")
expect(source).toContain('<el-tabs v-model="activeTab"')
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
expect(source).toContain("await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '')")
expect(source).toContain('getContainer(containerId.value, props.workbench)')
expect(source).toContain('getContainerOverview(containerId.value)')
expect(source).toContain(':can-edit="false"')
})
it('keeps the loaded overview mounted while switching fee tabs', () => {
expect(source).toContain("v-show=\"activeTab === 'overview'\" class=\"detail-section surface-panel\" aria-labelledby=\"container-voyage-heading\"")
expect(source).toContain("v-if=\"isOverseas\" v-show=\"activeTab === 'overview'\"")
expect(source).not.toContain("v-if=\"activeTab === 'overview'\" class=\"detail-section surface-panel\"")
})
it('exposes empty return directly in overseas detail only when permission and server action both allow it', () => {
expect(source).toContain("access.can('platform:firstleg:terminal-drayage:empty-return:confirm')")
expect(source).toContain("hasAction('RECORD_EMPTY_RETURN')")
expect(source).toContain("terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')")
expect(source).toContain('v-if="canRecordEmptyReturn" type="primary"')
expect(source).toContain('>确认还柜</el-button>')
expect(source).toContain('expectedVersion: current.version')
expect(source).toContain('actualAt: emptyReturnAt.value')
expect(source).toContain('remark: emptyReturnReason.value.trim() || undefined')
expect(source).toContain('await load()')
})
it('separates domestic first-leg fees and connects the overseas fee lifecycle lazily', () => {
expect(source).toContain('label="国内段费用(头程)" name="first-leg-fees"')
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
expect(source).not.toContain(':readonly="isOverseas"')
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
expect(source).toContain('title="目的港及海外段费用(尾程)"')
expect(source).not.toContain(':readonly="isDomestic"')
expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)")
expect(source).toContain("const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')")
expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"'))
expect(lastLegFeesSource).toContain('show-overflow-tooltip')
expect(lastLegFeesSource).toContain('尾程计费加载失败')
expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"')
expect(lastLegFeesSource).toContain('v-loading="loading"')
expect(lastLegFeesSource).toContain("canEdit.value && access.can('platform:supplier:list') ? listContainerFeeSupplierOptions(null) : Promise.resolve([])")
expect(lastLegFeesSource).toContain("sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER'")
expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'")
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')")
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')")
expect(source).toContain("if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'")
expect(source).toContain(':description="firstLegFeesEmptyReason"')
expect(source).toContain("const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')")
})
it('uses domestic and overseas route-specific primary status and summary labels', () => {
expect(source).toContain("const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)")
expect(source).toContain("UNLOADED: '待还柜'")
expect(source).toContain("EMPTY_RETURNED: '已还柜'")
expect(source).toContain("{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}")
expect(source).toContain("{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}")
expect(source).toContain('emptyReturnWarningText')
})
it('mounts the new container customs panel only after loading completes', () => {
expect(source).toContain("detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms")
expect(source).toContain('<ContainerCustomsPanel')
expect(source).toContain(':loading-plan-id="overview.loadingPlanId"')
expect(source).toContain(':can-manage="canManageCustoms"')
})
it('allows the overview panel to render only the section owned by each detail group', () => {
expect(overviewSource).toContain("sections?: Array<'exception' | 'documents' | 'pda'>")
expect(overviewSource).toContain("visibleSections.has('exception')")
expect(overviewSource).toContain("visibleSections.has('documents')")
expect(overviewSource).toContain("visibleSections.has('pda')")
})
it('removes the retired loading-list customs UI and calls', () => {
for (const retired of ['getLoadingDeclaration', 'uploadLoadingDeclaration', 'completeLoadingDeclaration', 'downloadLoadingDeclaration', 'CUSTOMS_DECLARATION']) {
expect(source).not.toContain(retired)
expect(overviewSource).not.toContain(retired)
}
})
it('offers the released-booking link workflow only for an eligible unlinked simulation container', () => {
expect(source).toContain("detail.value?.sourceType === 'SIMULATION'")
expect(source).toContain("!detail.value.oceanBookingId")
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)")
expect(source).toContain('关联已放舱订舱')
expect(source).toContain('oceanBookingLinkOpen.value = true')
expect(source).toContain('<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />')
expect(source).not.toContain('linkContainerId')
})
})
@@ -0,0 +1,609 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
import {
cancelContainerException,
completeContainerClearance,
downloadContainerClearance,
downloadContainerDocument,
getContainer,
getContainerClearance,
getContainerOverview,
markContainerException,
uploadContainerClearance,
uploadContainerDocument,
updateContainerPickupCode,
} from '../api/platformContainerLoading'
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
const canViewFirstLegFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
const canViewLastLegFees = computed(() => access.can('platform:finance:customer-arap:list'))
const firstLegFeesOpened = ref(false)
const lastLegFeesOpened = ref(false)
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo,
inboundId: item.inboundId, inboundCode: item.inboundCode,
})))
const firstLegFeesEmptyReason = computed(() => {
if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'
if (!detail.value?.warehouseId) return '当前货柜尚未关联装柜仓库,无法加载头程计费归属。'
if (detail.value.planStatus === 'COMPLETED' && !canViewLoadingList.value) return '当前账号无权读取已确认装柜清单,无法查看头程计费。'
if (detail.value.planStatus !== 'COMPLETED' && !canViewPreload.value) return '当前账号无权读取预装计划,无法查看头程计费。'
return '当前柜次暂无可用费用归属明细。'
})
async function rememberWorkbenchTab(tabName: string | number) {
if ((tabName === 'first-leg-fees' && !canViewFirstLegFees.value) || (tabName === 'last-leg-fees' && !canViewLastLegFees.value)) {
activeTab.value = 'overview'
return
}
if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true
if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true
const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')
if (needsLoadingDetail && detail.value && overview.value) {
await loadLoadingDetail(detail.value, overview.value)
}
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const arrivalTime = ref<ContainerArrivalTime | null>(null)
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
const canManageDestinationDocuments = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:document:edit') && hasAction('MANAGE_DESTINATION_DOCUMENTS'))
const deliveryOrderBlockedReason = computed(() => {
if (isSeaRailChannel(detail.value?.channelCode)) return '海铁渠道不适用 DO 资料'
if (detail.value?.planStatus !== 'COMPLETED' || !detail.value?.outboundConfirmedAt) return '货柜进入海外履约后才可上传'
if (detail.value?.actualEmptyReportedAt) return '货柜已还柜,不可再上传'
return null
})
const canRecordEmptyReturn = computed(() => isOverseas.value
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
&& hasAction('RECORD_EMPTY_RETURN')
&& Boolean(workbenchDetail.value?.terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')))
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewCurrentLoadingList = computed(() => {
if (!isDomestic.value || !detail.value) return false
if (detail.value.planStatus === 'COMPLETED') return canViewLoadingList.value
return ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(detail.value.planStatus) && canViewPreload.value
})
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
&& detail.value?.sourceType === 'SIMULATION'
&& !detail.value.oceanBookingId
&& ['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)))
const loading = ref(false)
const detail = ref<ContainerBookingResource | null>(null)
const overview = ref<ContainerOverview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentOpen = ref(false)
type UploadDocumentType = 'DELIVERY_ORDER'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentUploadKey = ref(0)
const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
const pickupCodeSaving = ref(false)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceLoading = ref(false)
const clearanceSaving = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
const exceptionOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
const emptyReturnOpen = ref(false)
const emptyReturnAt = ref('')
const emptyReturnReason = ref('')
const emptyReturnSaving = ref(false)
const emptyReturnError = ref<ApiErrorDetails | null>(null)
const oceanBookingLinkOpen = ref(false)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => 'DO 资料')
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const overseasStageText = computed(() => {
const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus
|| workbenchDetail.value?.terminalDrayage?.status
|| workbenchDetail.value?.workbenchStage
return ({
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
} as Record<string, string>)[status || ''] || '海外履约中'
})
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
? '装柜明细、PDA 证据与报关关联均以当前柜次为边界。'
: '核对预装明细与 PDA 作业准备情况,装柜完成后再处理整柜报关。')
const overseasDestinationText = computed(() => {
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
.filter(Boolean).join(' / ')
if (bookingDestination) return bookingDestination
const destinations = new Set((loadingDetail.value?.items || [])
.map((item) => String(item.destinationWarehouse || '').trim()).filter(Boolean))
return [...destinations].join('、') || '—'
})
const plannedChannelName = computed(() => getSmartLoadingChannelGroup(detail.value?.channelGroupCode)?.name || '—')
const emptyReturnWarningText = computed(() => {
const warning = workbenchDetail.value?.terminalDrayage?.emptyReturnWarning
if (!warning || warning.status === 'NOT_APPLICABLE') return '当前暂无还空预警'
if (warning.status === 'RESOLVED') return `已完成还柜${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
const overdue = warning.status === 'OVERDUE' ? (warning.overdueDays > 0 ? `已超时 ${warning.overdueDays} 天` : `已超时 ${warning.overdueHours} 小时`) : '待还柜'
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
})
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
const grouped = new Map<string, AggregatedGateRow>()
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
for (const item of inbound.gate.items || []) {
if (referenceOnlyGateCodes.has(item.code)) continue
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
const existing = grouped.get(key)
if (existing) {
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
}
}
return [...grouped.values()]
})
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
const outboundGateSummary = computed(() => ({
total: outboundGateRows.value.length,
passed: outboundGateRows.value.filter((row) => row.passed).length,
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
!outboundDetail.value ? 'UNKNOWN'
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
function openOceanBookingLink() {
if (!detail.value || !canLinkOceanBooking.value) return
oceanBookingLinkOpen.value = true
}
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
return {
id: document.id || fallback?.id || document.fileId,
documentType, fileId: document.fileId, fileName: document.fileName, fileHash: document.fileHash,
uploadedBy: document.uploadedBy, uploadedByName: document.uploadedByName, uploadedAt: document.uploadedAt,
version: document.version, completed: Boolean(document.completedAt),
}
}
function replaceOverviewDocument(document: ContainerOverviewDocument) {
if (!overview.value) return
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
}
async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
let refreshCause: unknown
let refreshed: ContainerOverview | null = null
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
if (!current) {
try {
const business = await getContainerClearance(containerId.value)
if (business) {
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
}
} catch (cause) { if (!refreshCause) refreshCause = cause }
}
if (refreshed) overview.value = current && !refreshed.documents.some((item) => item.documentType === documentType)
? { ...refreshed, documents: [...refreshed.documents, current] }
: refreshed
else if (current) replaceOverviewDocument(current)
else if (refreshCause) throw refreshCause
return current
}
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function localDateTimeValue() {
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
return now.toISOString().slice(0, 19)
}
function openEmptyReturn() {
if (!canRecordEmptyReturn.value || emptyReturnSaving.value) return
emptyReturnAt.value = localDateTimeValue()
emptyReturnReason.value = ''
emptyReturnError.value = null
emptyReturnOpen.value = true
}
async function confirmEmptyReturn() {
const current = workbenchDetail.value?.terminalDrayage
if (!current || !canRecordEmptyReturn.value || !emptyReturnAt.value || emptyReturnSaving.value) return
emptyReturnSaving.value = true
emptyReturnError.value = null
try {
await recordTerminalDrayageEmptyReturn(containerId.value, {
expectedVersion: current.version,
actualAt: emptyReturnAt.value,
remark: emptyReturnReason.value.trim() || undefined,
})
emptyReturnOpen.value = false
ElMessage.success('还柜已确认')
await load()
} catch (cause) {
const details = getApiErrorDetails(cause, '确认还柜失败')
if (details.code === 'A0409') await load()
emptyReturnError.value = details
} finally { emptyReturnSaving.value = false }
}
async function load() {
if (!containerId.value) return
resetLoadingDetail()
loading.value = true; error.value = null; overviewError.value = null
try {
const [resource, aggregate, arrivals] = await Promise.all([
getContainer(containerId.value, props.workbench),
getContainerOverview(containerId.value),
isOverseas.value ? listContainerArrivalTimes([containerId.value]) : Promise.resolve([]),
])
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
arrivalTime.value = arrivals[0] || null
if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview'
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
if (isOverseas.value && resource.planStatus === 'COMPLETED' && canViewLoadingList.value) {
await loadLoadingDetail(resource, aggregate)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
watch(
() => !loading.value && isDomestic.value && detail.value?.planStatus === 'COMPLETED'
&& canViewOutboundDispatch.value ? containerId.value : null,
(eligibleContainerId) => {
if (eligibleContainerId) void loadOutboundDispatch()
else { outboundDetail.value = null; outboundError.value = null }
},
)
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
}
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
const mode = resource.planStatus === 'COMPLETED' ? 'confirmed' : 'preload'
if (!(mode === 'confirmed' ? canViewLoadingList.value : canViewPreload.value)) {
resetLoadingDetail()
return
}
await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '')
}
async function refreshAfterCustoms() {
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
expectedVersion: aggregate.version,
remark: '货柜详情确认国内出仓',
})
carrierRefreshKey.value += 1
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
try { overview.value = await getContainerOverview(containerId.value) }
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
} catch (cause) {
const details = getApiErrorDetails(cause, '确认国内出仓失败')
if (details.code === 'A0409') await loadOutboundDispatch()
outboundError.value = details
} finally { outboundConfirming.value = false }
}
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
}
function truckReferenceText(status?: string | null) {
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
if (deliveryOrderBlockedReason.value) { ElMessage.warning(deliveryOrderBlockedReason.value); return }
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
const expectedVersion = selectedDocument.value?.version || 0
const targetType = documentType.value
const targetTitle = documentTitle.value
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
replaceOverviewDocument(optimistic)
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
ElMessage.success(`${targetTitle}已上传`)
try {
const refreshed = await getContainerOverview(containerId.value)
const current = refreshed.documents.find((item) => item.documentType === targetType)
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
}
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
} catch (cause) {
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
documentError.value = details
}
finally { documentSaving.value = false }
}
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
async function downloadDocument(item: ContainerOverviewDocument) {
if (!['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
documentDownloading.value = true
try {
const blob = item.documentType === 'DESTINATION_CLEARANCE'
? await downloadContainerClearance(containerId.value, item.fileId)
: await downloadContainerDocument(containerId.value, 'DELIVERY_ORDER', item.fileId)
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
} catch (cause) {
const details = getApiErrorDetails(cause, '资料下载失败')
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
ElMessage.error(details.message)
}
finally { documentDownloading.value = false }
}
async function savePickupCode(pickupCode: string) {
if (!overview.value || !canManageDestinationDocuments.value || !/^[0-9]{1,64}$/.test(pickupCode)) return
pickupCodeSaving.value = true; documentRefreshWarning.value = null
try {
const refreshed = await updateContainerPickupCode(containerId.value, pickupCode, overview.value.container.version)
overview.value = refreshed
if (detail.value) detail.value = { ...detail.value, pickupCode: refreshed.container.pickupCode, pickupCodeUpdatedBy: refreshed.container.pickupCodeUpdatedBy, pickupCodeUpdatedByName: refreshed.container.pickupCodeUpdatedByName, pickupCodeUpdatedAt: refreshed.container.pickupCodeUpdatedAt, version: refreshed.container.version }
ElMessage.success('PU 码已保存')
} catch (cause) {
const details = getApiErrorDetails(cause, 'PU 码保存失败')
documentRefreshWarning.value = details
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留并发错误证据 */ }
} finally { pickupCodeSaving.value = false }
}
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
async function saveException() {
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
exceptionSaving.value = true; exceptionError.value = null
try {
const action = overview.value.exception.active ? cancelContainerException : markContainerException
await action(containerId.value, overview.value.container.version, exceptionReason.value.trim())
overview.value = await getContainerOverview(containerId.value)
exceptionOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
} catch (cause) { exceptionError.value = getApiErrorDetails(cause, '货柜异常处理失败') }
finally { exceptionSaving.value = false }
}
async function openClearance() {
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
}
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
clearanceDocument.value = uploaded
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE')))
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
ElMessage.success('目的港清关资料已上传')
try {
const refreshed = await getContainerOverview(containerId.value)
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] }
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
} catch (cause) {
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
if (details.code === 'A0409') {
try { await refreshOverviewDocument('DESTINATION_CLEARANCE') } catch { /* 保留原并发错误证据 */ }
}
clearanceError.value = details
}
finally { clearanceSaving.value = false }
}
async function completeClearance() {
if (!clearanceDocument.value) return
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成')
if (!completed) throw new Error('确认清关完成但未返回附件信息')
clearanceDocument.value = completed
const optimistic = toOverviewDocument(completed, 'DESTINATION_CLEARANCE')
replaceOverviewDocument(optimistic)
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
ElMessage.success('已完成清关')
try {
const refreshed = await getContainerOverview(containerId.value)
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, optimistic] }
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关已完成,但最新资料刷新失败;当前已保留完成事实') }
}
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
finally { clearanceSaving.value = false }
}
function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
</script>
<template>
<AdminPage class="container-detail-page admin-density">
<section class="container-overview surface-panel">
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1>
<!-- <p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p> -->
</div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
<div v-if="isOverseas && detail" class="overseas-stage-strip" :class="{ 'is-overdue': workbenchDetail?.terminalDrayage?.emptyReturnWarning.status === 'OVERDUE' }"><div><span>当前海外履约状态</span><strong>{{ primaryStatusText }}</strong></div><p>{{ emptyReturnWarningText }}</p></div>
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<el-tabs v-model="activeTab" class="workbench-tabs density-element-tabs" @tab-change="rememberWorkbenchTab">
<el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📋</span><span>{{ isDomestic ? '送柜报关' : '清关提柜' }}</span></span></template></el-tab-pane>
<el-tab-pane v-if="canViewCurrentLoadingList" label="装柜清单" name="loading-list"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📦</span><span>装柜清单</span></span></template></el-tab-pane>
<el-tab-pane v-if="canViewFirstLegFees" label="国内段费用(头程)" name="first-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>国内段费用(头程)</span></span></template></el-tab-pane>
<el-tab-pane v-if="canViewLastLegFees" label="目的港及海外段费用(尾程)" name="last-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>目的港及海外段费用(尾程)</span></span></template></el-tab-pane>
</el-tabs>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog dense v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ containerChannelLabel(detail) }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
<h2 id="container-voyage-heading">船名航次信息</h2>
<ContainerPortSchedulePanel :container="detail" @saved="load" />
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" :delivery-order-blocked-reason="deliveryOrderBlockedReason" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
</section>
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">{{ customsDepartureState === 'DEPARTED' ? '整柜已确认离仓;展开可查看去重后的完整门禁。' : '全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。' }}</p>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
</article>
</div>
</template>
</div>
</section>
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
</section>
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
</section>
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
</section>
</template>
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
</AdminPage>
</template>
<style scoped>
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:var(--admin-space-3); white-space:nowrap; }
.container-detail-page { display:grid; min-width:0; gap:var(--admin-space-5); }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-4) var(--admin-space-5); }.container-overview__header h1 { margin:0; font-size:var(--admin-font-title); }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary { border-top:1px solid var(--cc-border); display:flex; flex-wrap:wrap; }.container-overview__summary>div { display:flex; min-width:0; padding:var(--admin-space-3) var(--admin-space-5); border-right:1px solid var(--cc-border); align-items:baseline; gap:var(--admin-space-2); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary strong,dd { margin:0; white-space:normal; overflow-wrap:anywhere; }.detail-section{display:grid;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-5) var(--admin-space-5)}.detail-section>h2{margin:0;font-size:var(--admin-font-section)}.detail-fields { padding:0; }.outbound-panel{padding:var(--admin-space-5) 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.outbound-panel__header h2{margin:0;font-size:var(--admin-font-section)}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.outbound-panel__loading{padding:var(--admin-space-3) 0}.gate-summary{display:flex;align-items:center;gap:var(--admin-space-5);margin-top:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:var(--admin-space-4);border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-2) var(--admin-space-5);margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:var(--admin-space-2) var(--admin-space-3);min-width:0;padding:var(--admin-space-2) var(--admin-space-3);border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal;overflow-wrap:anywhere}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:var(--admin-space-1)}@media(max-width:1200px){.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); }
.detail-section__heading h2 { margin:0; font-size:var(--admin-font-section); }
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-6);padding:9px var(--admin-space-5);border-top:1px solid var(--cc-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:var(--admin-space-4)}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--cc-muted);font-size:var(--admin-font-caption)}.overseas-stage-strip strong{color:var(--cc-primary);font-size:var(--admin-font-section)}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}.container-overview__header h1 { overflow-wrap:anywhere; }.workbench-tab-label { gap:var(--admin-space-2); }
</style>
@@ -0,0 +1,33 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
const mocks = vi.hoisted(() => ({ permissions: new Set<string>(), push: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => mocks.permissions.has(permission) }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: mocks.push }) }))
vi.mock('./ContainersView.vue', () => ({ default: { props: ['workbench'], template: '<div data-testid="workbench">{{ workbench }}</div>' } }))
function render(stage: 'DOMESTIC' | 'OVERSEAS' = 'DOMESTIC') {
return mount(ContainerWorkbenchView, { props: { stage }, global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
}
describe('container stage entry permissions', () => {
beforeEach(() => { mocks.permissions.clear(); mocks.push.mockReset() })
it('opens overseas operations for an overseas-only user entering the common menu', () => {
mocks.permissions.add('platform:firstleg:terminal-drayage:list')
const wrapper = render()
expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS')
expect(wrapper.text()).not.toContain('国内装柜')
})
it('preserves a direct overseas visit and allows switching back to domestic', async () => {
mocks.permissions.add('platform:firstleg:terminal-drayage:list')
mocks.permissions.add('platform:firstleg:container:list')
const wrapper = render('OVERSEAS')
expect(wrapper.get('[data-testid="workbench"]').text()).toBe('OVERSEAS')
await wrapper.findAll('button')[0]!.trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ name: 'domestic-container-workbench' })
})
it('does not mount data consumers without either list permission', () => {
expect(render().find('[data-testid="workbench"]').exists()).toBe(false)
})
})
@@ -0,0 +1,24 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ContainersView from './ContainersView.vue'
const props = defineProps<{ stage: 'DOMESTIC' | 'OVERSEAS' }>()
const access = useAccessPreviewStore()
const router = useRouter()
const canDomestic = computed(() => access.can('platform:firstleg:container:list'))
const canOverseas = computed(() => access.can('platform:firstleg:terminal-drayage:list'))
const current = computed(() => props.stage === 'DOMESTIC' && !canDomestic.value && canOverseas.value ? 'OVERSEAS' : props.stage)
</script>
<template>
<div class="admin-density">
<nav class="density-status-tabs" aria-label="货柜业务阶段">
<el-button v-if="canDomestic" :class="{ 'is-active': current === 'DOMESTIC' }" :aria-current="current === 'DOMESTIC' ? 'page' : undefined" @click="router.push({ name: 'domestic-container-workbench' })">📦 国内装柜</el-button>
<el-button v-if="canOverseas" :class="{ 'is-active': current === 'OVERSEAS' }" :aria-current="current === 'OVERSEAS' ? 'page' : undefined" @click="router.push({ name: 'overseas-container-workbench' })">🚢 海外清关提柜</el-button>
</nav>
<ContainersView v-if="canDomestic || canOverseas" :key="current" :workbench="current" />
</div>
</template>
@@ -1,41 +1,306 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainers } from '../api/platformContainerLoading'
import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet, sendTerminalDrayageEmail, getTerminalDrayageEmails } from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival } from '../api/containerArrivalTimes'
import AppDialog from '@/shared/components/AppDialog.vue'
import { containerLoadingRoutes } from '../routes'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() }))
vi.mock('../api/terminalDrayages', () => ({ sendTerminalDrayageEmail: vi.fn(), getTerminalDrayageEmails: vi.fn(), retryTerminalDrayageEmail: vi.fn(), listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() }))
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn(), updateContainerExpectedArrival: vi.fn() }))
const drayage = {
containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, version: 3,
fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00',
estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00',
emptyReturnWarning: { status: 'OVERDUE' as const, deadlineAt: '2026-09-10T10:00:00', overdueHours: 26, overdueDays: 1 },
allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'] as const, updatedAt: '2026-09-11T12:00:00',
}
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', name: 'domestic-container-workbench', component: ContainersView },
{ path: '/container-loading/domestic/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
{ path: '/container-loading/domestic/:containerId', name: 'domestic-container-detail', component: { template: '<div />' } },
{ path: '/container-loading/overseas/:containerId', name: 'overseas-container-detail', component: { template: '<div />' } },
] })
}
async function mountOverseasRoute() {
const router = createRouter({
history: createMemoryHistory(),
routes: containerLoadingRoutes.map((route) => ({ ...route, path: `/${route.path}` })),
})
await router.push('/container-loading/overseas')
await router.isReady()
const wrapper = mount(RouterView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
return wrapper
}
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
accessCan.mockReset(); accessCan.mockReturnValue(true)
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...drayage, allowedActions: [...drayage.allowedActions] })
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
it('loads the real paged contract and renders core container fields', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, scope: 'ACTIVE' }), 'DOMESTIC')
expect(wrapper.text()).toContain('待装柜')
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('宁波 → 长滩')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('报空预警')
})
it('sends pickup mail from the list once while pending and shows disabled delivery in history', async () => {
const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...original, records: original.records.map((row) => ({
...row, allowedActions: ['SEND_PICKUP_EMAIL'], terminalDrayage: { ...drayage, allowedActions: ['SEND_PICKUP_EMAIL'] },
})) })
let resolveSend!: (value: Awaited<ReturnType<typeof sendTerminalDrayageEmail>>) => void
vi.mocked(sendTerminalDrayageEmail).mockReset().mockReturnValue(new Promise((resolve) => { resolveSend = resolve }))
vi.mocked(getTerminalDrayageEmails).mockResolvedValue({ deliveryEnabled: false, records: [] })
const wrapper = await mountOverseasRoute()
const button = wrapper.findAll('.row-actions button').find((item) => item.text().includes('发送邮件'))!
expect(button.exists()).toBe(true)
await button.trigger('click'); await button.trigger('click')
expect(sendTerminalDrayageEmail).toHaveBeenCalledTimes(1)
expect(sendTerminalDrayageEmail).toHaveBeenCalledWith('9001', 3)
resolveSend({ ...drayage, version: 4, allowedActions: ['SEND_PICKUP_EMAIL'] })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')).filter((item) => item.textContent?.includes('邮件记录')).at(-1)!.click()
await flushPromises()
expect(getTerminalDrayageEmails).toHaveBeenCalledWith('9001')
expect(document.body.textContent).toContain('发信尚未启用')
wrapper.unmount()
})
it.each([3, 0])('submits direct empty return with server version %s, including unload without pickup', async (version) => {
const original = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockClear().mockResolvedValue({ ...original, records: original.records.map((row) => ({
...row,
terminalDrayage: { ...drayage, version, actualPickupAt: version ? drayage.actualPickupAt : null,
fleetSupplierId: version ? drayage.fleetSupplierId : null, allowedActions: ['RECORD_EMPTY_RETURN'],
emptyReturnWarning: { status: 'PENDING', deadlineAt: drayage.estimatedEmptyReturnAt, overdueHours: 0, overdueDays: 0 } },
})) })
const wrapper = await mountOverseasRoute()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜'))
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
await returnDialog.find('textarea').setValue('还柜凭证已核对')
const callsBefore = vi.mocked(listContainers).mock.calls.length
const page: Awaited<ReturnType<typeof listContainers>> = await vi.mocked(listContainers).mock.results.at(-1)!.value
vi.mocked(listContainers).mockResolvedValueOnce({ ...page, records: page.records.map((row) => ({ ...row, allowedActions: [], terminalDrayage: { ...drayage, status: 'EMPTY_RETURNED', version: 4, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED', deadlineAt: null, overdueHours: 0, overdueDays: 0 } } })) })
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.includes('确认还柜')).at(-1) as HTMLButtonElement
confirm.click()
await flushPromises()
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: version, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
expect(wrapper.text()).toContain('已还柜')
expect(listContainers).toHaveBeenCalledTimes(callsBefore + 1)
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
wrapper.unmount()
})
it.each(['permission', 'row action', 'nested action', 'nested object', 'already returned', 'historical unloaded snapshot'] as const)(
'keeps empty return unavailable on the real overseas route: %s',
async (missing) => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockClear()
const nested = missing === 'nested object' ? null : {
...drayage,
allowedActions: missing === 'nested action' || missing === 'already returned' ? [] : [...drayage.allowedActions],
...(missing === 'already returned' ? { status: 'EMPTY_RETURNED' as const, actualEmptyReturnAt: '2026-09-11T13:00:00' } : {}),
...(missing === 'historical unloaded snapshot' ? { status: 'ARRIVED' as const, version: 0, actualPickupAt: null, fleetSupplierId: null, fleetSupplierName: null, allowedActions: ['SELECT_FLEET' as const] } : {}),
}
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
...row,
allowedActions: missing === 'row action' || missing === 'already returned' ? [] : ['RECORD_EMPTY_RETURN'],
terminalDrayage: nested,
...(missing === 'historical unloaded snapshot' ? { workbenchStage: 'UNLOADED', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS' as const, 'SELECT_FLEET' as const] } : {}),
})) })
if (missing === 'permission') {
accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm')
}
const wrapper = await mountOverseasRoute()
try {
expect(listContainers).toHaveBeenCalledTimes(1)
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
expect(getTerminalDrayage).not.toHaveBeenCalled()
if (missing === 'historical unloaded snapshot') {
const fleet = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) =>
select.props('placeholder') === '输入车队名称或联系人搜索' && select.element.closest('.el-table__body'),
)
expect(fleet).toBeDefined()
expect(fleet!.props('disabled')).toBe(false)
}
} finally {
wrapper.unmount()
}
},
)
it('cancels empty return without writing and preserves input on failure while preventing duplicate requests', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const open = async () => {
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
await flushPromises()
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
}
const first = await open()
first.vm.$emit('update:modelValue', false)
await flushPromises()
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
const dialog = await open()
const state = wrapper.vm as unknown as { drayageActionAt: string; drayageReason: string; drayageActionOpen: boolean; drayageActionError: { message: string } | null }
state.drayageActionAt = '2026-09-11T13:00:00'
state.drayageReason = '还柜凭证已核对'
let reject!: (reason: Error) => void
vi.mocked(recordTerminalDrayageEmptyReturn).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
dialog.vm.$emit('confirm')
dialog.vm.$emit('confirm')
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 3, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
reject(new Error('还柜时间不符合业务要求'))
await flushPromises()
expect(state.drayageActionOpen).toBe(true)
expect(state.drayageActionAt).toBe('2026-09-11T13:00:00')
expect(state.drayageReason).toBe('还柜凭证已核对')
expect(state.drayageActionError).not.toBeNull()
wrapper.unmount()
})
it('hides the direct empty-return action without permission or without the server action', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm')
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
wrapper.unmount()
accessCan.mockReturnValue(true)
const allowedWrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = allowedWrapper.vm as unknown as { rows: Array<{ allowedActions?: string[] }> }
vm.rows[0].allowedActions = vm.rows[0].allowedActions?.filter((action) => action !== 'RECORD_EMPTY_RETURN')
await allowedWrapper.vm.$nextTick()
expect(allowedWrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
})
it('defers form options until their dialogs open and caches successful loads', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformPartners).not.toHaveBeenCalled()
expect(listSuppliers).not.toHaveBeenCalled()
expect(listOceanBookings).not.toHaveBeenCalled()
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformPartners).not.toHaveBeenCalled()
expect(listSuppliers).not.toHaveBeenCalled()
expect(listOceanBookings).not.toHaveBeenCalled()
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '新增模拟柜')!.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
expect(listSuppliers).toHaveBeenCalledTimes(1)
expect(listOceanBookings).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps the cross-warehouse list usable when lazy warehouse form options fail', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('装柜仓库加载失败')
})
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 0,
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
@@ -46,7 +311,7 @@ describe('ContainersView', () => {
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
@@ -57,4 +322,516 @@ describe('ContainersView', () => {
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
})
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, planStatus: 'EDITING' as const },
{ ...base, id: '9002', planId: '9102', cabinetNo: 'CB-2', planStatus: 'LOADING' as const },
{ ...base, id: '9003', planId: '9103', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const },
],
page: 1, size: 20, total: 3, pages: 1,
})
const router = createTestRouter()
await router.push('/'); await router.isReady()
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined as never)
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('装柜')
expect(wrapper.text()).toContain('预装清单')
expect(wrapper.text()).toContain('装柜清单')
await wrapper.findAll('button').find((button) => button.text().trim() === '装柜')!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'domestic-container-detail', params: { containerId: '9003' }, query: { tab: 'loading-list' } })
})
it('uses active customer names and sends the stable customerId filter', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain("label: customer.tenantName")
expect(source).toContain('customerId: applied.value.customerId || undefined')
expect(source).not.toContain('customer.tenantCode')
})
it('uses the list permission for detail while keeping edit independent', async () => {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:list')
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('does not accept the removed standalone view permission', async () => {
accessCan.mockReturnValue(false)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('uses business selectors and keeps code/name pairs internal', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('placeholder="请选择船公司"')
expect(source).toContain('placeholder="请选择起运港"')
expect(source).toContain('placeholder="请选择目的港"')
expect(source).toContain('placeholder="请选择目的仓"')
expect(source).toContain("selectPort('departure'")
expect(source).toContain('selectDestinationWarehouse')
expect(source).toContain('重新加载业务选项')
expect(source).not.toContain('label="起运港代码"')
expect(source).not.toContain('label="起运港名称"')
expect(source).not.toContain('label="目的仓代码"')
expect(source).not.toContain('label="目的仓名称"')
})
it('keeps query and pagination on a single list request path', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("if (page.value === 1) void loadRows(); else page.value = 1")
expect(source).toContain("if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()")
expect(source).not.toContain('@reset=')
})
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('admin-density-dialog')
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
it('registers a list-authorized independent container detail page', () => {
const routes = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(routes).toContain("path: 'container-loading/domestic/:containerId'")
expect(routes).toContain("name: 'domestic-container-detail'")
expect(routes).toContain("name: 'overseas-container-detail'")
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
expect(detail).toContain('getContainer(containerId.value, props.workbench)')
expect(detail).toContain('getContainerOverview(containerId.value)')
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
expect(detail).toContain("@manage-exception=\"openException\"")
expect(detail).not.toContain("@preview-photo=\"previewPhoto\"")
expect(detail).toContain('<ContainerOverviewPanel')
})
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const labels = ['真实集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
let previous = -1
for (const label of labels) {
const current = source.indexOf(`label="${label}"`, previous + 1)
expect(current, label).toBeGreaterThan(previous)
previous = current
}
})
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500")
expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')")
expect(source).toContain('<RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)"')
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
expect(source).not.toContain('containerNo: optional(form.containerNo)')
expect(source).not.toContain('updateContainerTransport')
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
expect(source).toContain('appearance="integrated" :stripe="false"')
})
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as {
rows: Array<Record<string, unknown>>
searchTerminalFleets: (keyword?: string) => Promise<void>
selectRowFleet: (row: Record<string, unknown>, supplierId: string) => Promise<void>
openDrayageAction: (row: Record<string, unknown>, action: 'RECORD_EMPTY_RETURN') => void
drayageActionAt: string
saveDrayageAction: () => Promise<void>
}
const beforeFleet = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValueOnce({ ...beforeFleet, records: beforeFleet.records.map((row) => ({ ...row, terminalDrayage: { ...drayage, version: 4, fleetSupplierId: '502', allowedActions: [...drayage.allowedActions] } })) })
await vm.searchTerminalFleets('新车队')
await vm.selectRowFleet(vm.rows[0], '502')
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' })
vm.openDrayageAction(vm.rows[0], 'RECORD_EMPTY_RETURN')
vm.drayageActionAt = '2026-09-11T13:00:00'
const page: Awaited<ReturnType<typeof listContainers>> = await vi.mocked(listContainers).mock.results.at(-1)!.value
vi.mocked(listContainers).mockResolvedValueOnce({ ...page, records: page.records.map((row) => ({ ...row, allowedActions: [], terminalDrayage: { ...drayage, status: 'EMPTY_RETURNED', version: 5, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED', deadlineAt: null, overdueHours: 0, overdueDays: 0 } } })) })
await vm.saveDrayageAction()
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', remark: undefined })
expect(wrapper.text()).toContain('已还柜')
wrapper.unmount()
})
it('searches active terminal fleets remotely and ignores an older response', async () => {
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
await flushPromises()
let resolveOld!: (value: Awaited<ReturnType<typeof listSuppliers>>) => void
let resolveLatest!: (value: Awaited<ReturnType<typeof listSuppliers>>) => void
vi.mocked(listSuppliers).mockReset()
.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveLatest = resolve }))
const vm = wrapper.vm as unknown as {
terminalFleetOptions: Array<{ id: string; name: string }>
searchTerminalFleets: (keyword?: string) => Promise<void>
}
const oldSearch = vm.searchTerminalFleets('旧关键词')
const latestSearch = vm.searchTerminalFleets('新车队')
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '旧关键词' })
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '新车队' })
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never, { id: '504', name: '国内车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' } as never], page: 1, size: 20, total: 2, pages: 1 })
await latestSearch
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
await oldSearch
expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })])
wrapper.unmount()
})
it('loads terminal drayage detail and history only when requested', async () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValueOnce([{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: '到港安排', operatorId: '88', operatorName: '管理员', createdAt: '2026-09-07T08:00:00' }])
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const vm = wrapper.vm as unknown as { rows: Array<Record<string, unknown>>; openDrayageHistory: (row: Record<string, unknown>) => Promise<void> }
await vm.openDrayageHistory(vm.rows[0])
await flushPromises()
expect(getTerminalDrayage).toHaveBeenCalledWith('9001')
expect(getTerminalDrayageHistory).toHaveBeenCalledWith('9001')
expect(wrapper.text()).toContain('到港安排')
wrapper.unmount()
})
it('owns the released-booking entry through the shared dialog without a detail redirect query', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />')
expect(source).not.toContain('linkContainerId')
})
it('edits the whole-container expected arrival inline and shows PDA receiving start as actual arrival', async () => {
const wrapper = await mountOverseasRoute()
expect(wrapper.text()).toContain('预计到仓')
expect(wrapper.text()).toContain('实际到仓')
expect(wrapper.text()).toContain('2026-09-26 09:00')
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' })
.find((item) => item.props('placeholder') === '选择预计到仓')
expect(picker).toBeDefined()
const vm = wrapper.findComponent(ContainersView).vm as unknown as {
rows: Array<Record<string, unknown>>
saveExpectedArrival: (row: Record<string, unknown>, value: string) => Promise<void>
}
await vm.saveExpectedArrival(vm.rows[0], '2026-09-25T16:00:00')
await flushPromises()
expect(updateContainerExpectedArrival).toHaveBeenCalledWith('9001', {
estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', expectedVersion: 0,
})
})
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
...row, planStatus, containerNo: null, allowedActions: ['EDIT_CONTAINER'],
})) })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
try {
await flushPromises()
const actions = wrapper.find('.el-table__body .row-actions')
expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '更多'])
await actions.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
const link = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
expect(link).toBeDefined()
link.click()
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
} finally {
wrapper.unmount()
}
})
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(source).not.toContain('uploadLoadingDeclaration')
expect(source).not.toContain('uploadContainerClearance')
expect(source).not.toContain('uploadContainerDocument')
expect(detailSource).not.toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('ContainerCustomsPanel')
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
})
it('creates an internal simulation without asking for real booking transport facts', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const simulationSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerSimulationCreate.vue'), 'utf8')
expect(source).toContain('ContainerSimulationCreate')
expect(source).not.toContain('新增货柜')
expect(simulationSource).toContain('新增模拟柜')
expect(simulationSource).toContain('createContainerSimulation({ ...simulationForm })')
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
expect(source).toContain('CONTAINER_SPECIFICATIONS')
expect(simulationSource).toContain('selectSimulationContainerType')
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
expect(source).not.toContain('v-model="form.cabinetNo"')
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
expect(source).not.toContain('openCreate')
expect(source).not.toContain('20GP')
expect(source).not.toContain('40HC')
expect(source).not.toContain('40NR')
expect(source).not.toContain('45HC')
expect(source).not.toContain('v-model="form.capacityCbm"')
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: unknown[] }).candidates).toEqual([])
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/OceanBookingLinkDialog.vue'), 'utf8'))
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
expect(getOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => {
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315',
containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡',
}], page: 1, size: 200, total: 1, pages: 1 } as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
const label = `OB-OPTIONAL-CUSTOMER · 提单号 BL-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
expect(document.body.textContent).toContain(label)
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
wrapper.unmount()
})
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
const linkAction = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
linkAction.click()
await flushPromises()
linkAction.click()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', billOfLadingNo: 'BL-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1')
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
wrapper.unmount()
})
it('discards a candidate response from a closed link session', async () => {
let resolveFirst!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
let resolveSecond!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listOceanBookingLinkCandidates)
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve }))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
await flushPromises()
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', billOfLadingNo: 'BL-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', billOfLadingNo: 'BL-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
const candidateLabels = (wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates.map((candidate) => candidate.label)
expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true)
expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false)
wrapper.unmount()
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
terminalTruckingCompanyId: '9007199254740998',
terminalTruckingCompany: '测试拖车供应商',
}))
})
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
truckingSelect!.vm.$emit('update:modelValue', '')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
terminalTruckingCompanyId: null,
terminalTruckingCompany: null,
}))
})
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(updateContainer).mockClear()
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
await flushPromises()
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
expect(truckingSelect?.props('modelValue')).toBe('')
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('原拖车供应商已不可用,请重新选择合作中的拖车供应商')
})
})
describe('ContainersView trucking supplier contract', () => {
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
const current = source.indexOf(`key: '${field}'`, previous + 1)
expect(current, field).toBeGreaterThan(previous)
previous = current
}
expect(source).toContain("MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成'")
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(detail).toContain("detail.planNo || '—'")
expect(detail).toContain("detail.exceptionActive ? '异常' : '正常'")
})
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })")
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined")
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE'")
expect(source).toContain('filterable remote reserve-keyword remote-show-suffix')
expect(source).toContain(':remote-method="searchTerminalFleets"')
expect(source).toContain('selectTruckingSupplier')
expect(source).toContain('请选择有效的合作中拖车供应商')
expect(source).not.toContain('allow-create')
})
})
@@ -1,96 +1,317 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Eye, Pencil, Plus, Sparkles } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createContainer, getContainer, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerPlanStatus, ContainerSaveRequest } from '../types'
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
import { containerChannelLabel } from '../containerChannelLabel'
import {
getTerminalDrayageEmails,
sendTerminalDrayageEmail,
retryTerminalDrayageEmail,
type TerminalDrayageEmail,
getTerminalDrayage,
getTerminalDrayageHistory,
recordTerminalDrayageEmptyReturn,
selectTerminalDrayageFleet,
type TerminalDrayageAction,
type TerminalDrayageHistory,
type TerminalDrayageView,
} from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
type Filters = QueryModel & { keyword: string; warehouseId: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
const router = useRouter()
const route = useRoute()
const access = useAccessPreviewStore()
const rows = ref<ContainerBookingResource[]>([])
const rows = ref<ContainerWorkbenchRow[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const destinationWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const truckingSuppliers = ref<Supplier[]>([])
const terminalFleetOptions = ref<Supplier[]>([])
const terminalFleetLoading = ref(false)
const oceanBookings = ref<OceanBookingResource[]>([])
const loading = ref(false)
const warehouseLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const warehouseError = ref<ApiErrorDetails | null>(null)
const businessOptionsLoading = ref(false)
const businessOptionsError = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = ref<Filters>({ keyword: '', warehouseId: '', containerType: '', status: '', sailingRange: [] })
const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE')
const filters = ref<Filters>({ customerId: '', keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<ContainerBookingResource | null>(null)
const detailTarget = ref<ContainerBookingResource | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const linkOpen = ref(false)
const linkTarget = ref<ContainerBookingResource | null>(null)
const selectedTruckingSupplierId = ref('')
const unresolvedTruckingSupplierName = ref<string | null>(null)
const drayages = ref<Record<string, TerminalDrayageView>>({})
const arrivalTimes = ref<Record<string, ContainerArrivalTime>>({})
const arrivalInputs = ref<Record<string, string>>({})
const arrivalSavingIds = ref<string[]>([])
const drayageSavingIds = ref<string[]>([])
const drayageActionOpen = ref(false)
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>>('SCHEDULE_PICKUP')
const drayageTarget = ref<ContainerBookingResource | null>(null)
const drayageActionAt = ref('')
const drayageReason = ref('')
const drayageActionSaving = ref(false)
const drayageActionError = ref<ApiErrorDetails | null>(null)
const drayageHistoryOpen = ref(false)
const drayageHistory = ref<TerminalDrayageHistory[]>([])
const drayageHistoryLoading = ref(false)
const drayageHistoryError = ref<ApiErrorDetails | null>(null)
const emailHistoryOpen = ref(false)
const emailTarget = ref<ContainerBookingResource | null>(null)
const emails = ref<TerminalDrayageEmail[]>([])
const emailsLoading = ref(false)
const emailsError = ref<ApiErrorDetails | null>(null)
const emailDeliveryEnabled = ref(false)
const retryingEmail = ref('')
let emailSequence = 0
const emailColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'emailActions' },
{ key: 'kind', label: '类型', width: 100, formatter: (row: TerminalDrayageEmail) => row.kind === 'CANCEL_PICKUP' ? '取消提柜' : '请求提柜' },
{ key: 'fleetName', label: '车队', minWidth: 130 },
{ key: 'recipient', label: '收件邮箱', minWidth: 210 },
{ key: 'status', label: '发送状态', width: 100, formatter: (row: TerminalDrayageEmail) => ({ PENDING: '待发送', SENDING: '发送中', SENT: '已发送', FAILED: '失败待核对' })[row.status] },
{ key: 'content', label: '邮件内容', minWidth: 270, slotName: 'emailContent' },
{ key: 'failureReason', label: '失败说明', minWidth: 200 },
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.createdAt) },
{ key: 'sentAt', label: '发送时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.sentAt) },
]
async function sendPickupEmail(row: ContainerWorkbenchRow) {
const current = drayageFor(row.id)
if (!current || !canDrayage(row, 'SEND_PICKUP_EMAIL') || drayageSavingIds.value.includes(row.id)) return
drayageSavingIds.value.push(row.id)
try {
replaceDrayage(await sendTerminalDrayageEmail(row.id, current.version))
ElMessage.success('提柜邮件已加入发送队列,可在邮件记录中查看结果')
await loadRows()
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '提交提柜邮件失败').message)
await loadRows()
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
}
async function openEmailHistory(row: ContainerBookingResource) {
const sequence = ++emailSequence
emailTarget.value = row; emailHistoryOpen.value = true; emailsLoading.value = true
emailsError.value = null; emails.value = []
try {
const result = await getTerminalDrayageEmails(row.id)
if (sequence !== emailSequence) return
emails.value = result.records; emailDeliveryEnabled.value = result.deliveryEnabled
} catch (error) {
if (sequence === emailSequence) emailsError.value = getApiErrorDetails(error, '邮件记录加载失败')
} finally { if (sequence === emailSequence) emailsLoading.value = false }
}
async function retryEmail(mail: TerminalDrayageEmail) {
const target = emailTarget.value
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
retryingEmail.value = mail.id
try {
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
await retryTerminalDrayageEmail(target.id, mail.id)
ElMessage.success('邮件已重新加入发送队列')
await openEmailHistory(target)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '重试邮件失败').message)
} finally { retryingEmail.value = '' }
}
let listSequence = 0
let detailSequence = 0
let warehousesLoaded = false
let warehouseOptionsPromise: Promise<void> | null = null
let partnersLoaded = false
let truckingSuppliersLoaded = false
let truckingSuppliersPromise: Promise<Supplier[]> | null = null
let terminalFleetSearchSequence = 0
let oceanBookingsLoaded = false
let businessOptionsPromise: Promise<void> | null = null
function emptyForm(): ContainerForm {
return {
id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null,
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', shippingCompanyId: null,
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: DEFAULT_CONTAINER_TYPE, shippingCompanyId: null,
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null,
capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
}
const form = reactive<ContainerForm>(emptyForm())
const rules: FormRules<ContainerForm> = {
cabinetNo: [{ required: true, message: '请输入柜次号', trigger: 'blur' }],
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
}
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const canView = computed(() => isDomestic.value ? access.can('platform:firstleg:container:list') : access.can('platform:firstleg:terminal-drayage:list'))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const canOverseasDocumentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:document:edit'))
const canFleetEdit = computed(() => access.can('platform:firstleg:terminal-drayage:fleet:edit'))
const canPickupAppointmentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit'))
const canExpectedArrivalEdit = computed(() => access.can('platform:overseas-warehouse:last-mile-order:edit'))
const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm'))
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const editorTitle = computed(() => form.id ? (detail.value?.planStatus === 'LOADING' ? '修改运输信息' : '编辑货柜') : '新增货柜')
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
interface PortOption { value: string; code: string | null; name: string | null }
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
function collectPorts(direction: 'departure' | 'destination'): PortOption[] {
const entries = oceanBookings.value.map((booking) => direction === 'departure'
? { code: booking.departurePortCode, name: booking.departurePortName }
: { code: booking.destinationPortCode, name: booking.destinationPortName })
const current = direction === 'departure'
? { code: form.departurePortCode, name: form.departurePortName }
: { code: form.destinationPortCode, name: form.destinationPortName }
entries.push(current)
const options = new Map<string, PortOption>()
for (const entry of entries) {
if (!entry.code && !entry.name) continue
const value = portValue(entry.code, entry.name)
options.set(value, { value, code: entry.code || null, name: entry.name || null })
}
return [...options.values()].sort((left, right) => (left.name || left.code || '').localeCompare(right.name || right.code || '', 'zh-CN'))
}
const departurePorts = computed(() => collectPorts('departure'))
const destinationPorts = computed(() => collectPorts('destination'))
const departurePortValue = computed(() => portValue(form.departurePortCode, form.departurePortName))
const destinationPortValue = computed(() => portValue(form.destinationPortCode, form.destinationPortName))
const shippingCompanyValue = computed(() => form.shippingCompanyId || (form.shippingCompanyName ? `name:${form.shippingCompanyName}` : ''))
const destinationWarehouseValue = computed(() => form.destinationWarehouseCode || (form.destinationWarehouseName ? `name:${form.destinationWarehouseName}` : ''))
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'warehouseId', type: 'select', label: '装柜仓库', width: 220, options: warehouses.value.map((warehouse) => ({ label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`, value: String(warehouse.id) })), props: { 'aria-label': '装柜仓库', filterable: true, loading: warehouseLoading.value } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '编辑中', value: 'EDITING' }, { label: '装柜中', value: 'LOADING' }, { label: '已完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, placeholder: '请选择箱型', options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '装柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '装柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'cabinetNo', label: '柜次', minWidth: 180, slotName: 'cabinet' },
const allColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
{ key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' },
{ key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => containerChannelLabel(row) },
{ key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' },
{ key: 'pickupAppointmentAt', label: '预计到仓', width: 210, slotName: 'pickupAppointmentAt' },
{ key: 'actualPickupAt', label: '实际到仓', width: 165, slotName: 'actualPickupAt' },
{ key: 'estimatedEmptyReturnAt', label: '预计还柜', width: 165, slotName: 'estimatedEmptyReturnAt' },
{ key: 'actualEmptyReturnAt', label: '实际还柜', width: 165, slotName: 'actualEmptyReturnAt' },
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
{ key: 'route', label: '航线', minWidth: 220, slotName: 'route' },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 100 },
{ key: 'capacity', label: '容量上限', minWidth: 150, slotName: 'capacity' },
{ key: 'vessel', label: '船名/航次', minWidth: 180, slotName: 'vessel' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150 },
{ key: 'loadingAt', label: '装柜时间', minWidth: 160, slotName: 'loadingAt' },
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
{ key: 'actualSailingAt', label: '实际开船', minWidth: 160, slotName: 'actualSailingAt' },
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'channelGroupCode', 'channelName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
const columns = computed(() => allColumns
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))
// 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。
.map((column) => isDomestic.value && column.key === 'actions' ? { ...column, width: 290 } : column))
const drayageHistoryColumns: DataTableColumn[] = [
{ key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) },
{ key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` },
{ key: 'version', label: '版本', width: 100, formatter: (row: TerminalDrayageHistory) => `${row.fromVersion} → ${row.toVersion}` },
{ key: 'operatorName', label: '操作人', width: 130, formatter: (row: TerminalDrayageHistory) => row.operatorName || '—' },
{ key: 'createdAt', label: '操作时间', width: 170, formatter: (row: TerminalDrayageHistory) => formatTime(row.createdAt) },
{ key: 'reason', label: '原因', minWidth: 200 },
]
function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) }
function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) {
const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : canEmptyReturnConfirm.value
return Boolean(isOverseas.value && permission && hasAction(row, action) && drayageFor(row.id)?.allowedActions.includes(action))
}
function drayageWarningText(row: ContainerBookingResource) {
const warning = drayageFor(row.id)?.emptyReturnWarning
if (!warning || warning.status === 'NOT_APPLICABLE') return '—'
if (warning.status === 'RESOLVED') return '已还柜'
if (warning.status === 'OVERDUE') return warning.overdueDays > 0 ? `超时 ${warning.overdueDays} 天` : `超时 ${warning.overdueHours} 小时`
return '待还柜'
}
function drayageWarningType(row: ContainerBookingResource) {
const status = drayageFor(row.id)?.emptyReturnWarning.status
return status === 'OVERDUE' ? 'danger' : status === 'RESOLVED' ? 'success' : status === 'PENDING' ? 'warning' : 'info'
}
function drayageWarningTitle(row: ContainerBookingResource) {
const warning = drayageFor(row.id)?.emptyReturnWarning
if (!warning) return ''
const deadline = warning.deadlineAt ? `还柜截止:${formatTime(warning.deadlineAt)}` : ''
return [drayageWarningText(row), deadline].filter(Boolean).join(';')
}
function localDateTimeValue() {
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
return now.toISOString().slice(0, 19)
}
function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
.find((value) => typeof value === 'string' && value.trim())
@@ -98,11 +319,34 @@ function containerViewLabel(row: ContainerBookingResource) {
}
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
const specification = containerSpecification(form.containerType)
if (specification) applyContainerSpecification(form, specification.type)
reconcileTruckingSupplier()
}
function reconcileTruckingSupplier() {
const supplierId = optional(form.terminalTruckingCompanyId)
const supplierName = optional(form.terminalTruckingCompany) || unresolvedTruckingSupplierName.value
const supplier = supplierId
? truckingCompanies.value.find((item) => item.id === supplierId)
: truckingCompanies.value.find((item) => item.name === supplierName)
selectedTruckingSupplierId.value = supplier?.id || ''
unresolvedTruckingSupplierName.value = supplier ? null : supplierName
if (supplier) {
form.terminalTruckingCompanyId = supplier.id
form.terminalTruckingCompany = supplier.name
} else if (!supplierName) {
form.terminalTruckingCompanyId = null
form.terminalTruckingCompany = null
} else {
form.terminalTruckingCompanyId = null
form.terminalTruckingCompany = supplierName
}
}
function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
return {
containerNo: optional(form.containerNo), cabinetNo: form.cabinetNo.trim(),
containerNo: optional(detail.value?.containerNo || null),
warehouseId: form.warehouseId,
warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName),
billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo),
@@ -111,18 +355,253 @@ function payload(): ContainerSaveRequest {
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg, terminalTruckingCompany: optional(form.terminalTruckingCompany),
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg,
terminalTruckingCompanyId: selectedTruckingSupplier?.id || null,
terminalTruckingCompany: selectedTruckingSupplier?.name || null,
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
}
}
function partnerLabel(partner: PlatformPartnerResource) {
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
}
function selectTruckingSupplier(supplierId: string) {
if (!supplierId && unresolvedTruckingSupplierName.value) return
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
selectedTruckingSupplierId.value = supplier?.id || ''
unresolvedTruckingSupplierName.value = null
form.terminalTruckingCompanyId = supplier?.id || null
form.terminalTruckingCompany = supplier?.name || null
}
function portLabel(port: PortOption) {
return port.name && port.code ? `${port.name}(${port.code})` : port.name || port.code || '未命名港口'
}
function selectShippingCompany(partnerId: string) {
if (partnerId.startsWith('name:')) {
form.shippingCompanyId = null
form.shippingCompanyName = partnerId.slice(5) || null
return
}
const partner = partners.value.find((item) => String(item.id) === partnerId)
form.shippingCompanyId = partnerId || null
form.shippingCompanyName = partner?.partnerName || null
}
function selectPort(direction: 'departure' | 'destination', value: string) {
const option = (direction === 'departure' ? departurePorts.value : destinationPorts.value).find((item) => item.value === value)
if (direction === 'departure') {
form.departurePortCode = option?.code || null
form.departurePortName = option?.name || null
} else {
form.destinationPortCode = option?.code || null
form.destinationPortName = option?.name || null
}
}
function selectDestinationWarehouse(warehouseCode: string) {
if (warehouseCode.startsWith('name:')) {
form.destinationWarehouseCode = null
form.destinationWarehouseName = warehouseCode.slice(5) || null
return
}
const warehouse = destinationWarehouses.value.find((item) => item.warehouseCode === warehouseCode)
form.destinationWarehouseCode = warehouseCode || null
form.destinationWarehouseName = warehouse?.warehouseName || null
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try { warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) }
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
if (warehousesLoaded) return
if (warehouseOptionsPromise) return warehouseOptionsPromise
warehouseOptionsPromise = (async () => {
warehouseLoading.value = true; warehouseError.value = null
try {
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
warehousesLoaded = true
}
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
})()
try { await warehouseOptionsPromise }
finally { warehouseOptionsPromise = null }
}
async function loadCustomers() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customers.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customers.value = []
} finally {
customersLoading.value = false
}
}
async function loadBusinessOptions() {
if (partnersLoaded && truckingSuppliersLoaded && oceanBookingsLoaded) return
if (businessOptionsPromise) return businessOptionsPromise
businessOptionsPromise = (async () => {
businessOptionsLoading.value = true
businessOptionsError.value = ''
const errors: string[] = []
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
partnersLoaded ? Promise.resolve(partners.value) : loadAllPartners(),
loadTruckingSuppliers(),
oceanBookingsLoaded ? Promise.resolve(oceanBookings.value) : loadAllOceanBookings(),
])
if (partnerResult.status === 'fulfilled') { partners.value = partnerResult.value; partnersLoaded = true }
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (supplierResult.status === 'fulfilled') {
if (form.id) reconcileTruckingSupplier()
}
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') { oceanBookings.value = bookingResult.value; oceanBookingsLoaded = true }
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
businessOptionsError.value = errors.join(';')
businessOptionsLoading.value = false
})()
try { await businessOptionsPromise }
finally { businessOptionsPromise = null }
}
async function loadTruckingSuppliers(): Promise<Supplier[]> {
if (truckingSuppliersLoaded) return truckingSuppliers.value
if (truckingSuppliersPromise) return truckingSuppliersPromise
truckingSuppliersPromise = (async () => {
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE')
truckingSuppliers.value = records
truckingSuppliersLoaded = true
return records
})()
try { return await truckingSuppliersPromise }
finally { truckingSuppliersPromise = null }
}
async function searchTerminalFleets(keyword = '') {
const sequence = ++terminalFleetSearchSequence
terminalFleetLoading.value = true
try {
const result = await listSuppliers({
page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined,
})
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE')
} catch (error) {
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = []
ElMessage.error(getApiErrorDetails(error, '码头车队搜索失败').message)
} finally {
if (sequence === terminalFleetSearchSequence) terminalFleetLoading.value = false
}
}
function handleTerminalFleetVisible(open: boolean) {
if (open) void searchTerminalFleets()
else { terminalFleetSearchSequence++; terminalFleetLoading.value = false }
}
async function loadAllPartners() {
const firstPage = await listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' })
if (firstPage.pages <= 1) return firstPage.records
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listPlatformPartners({ page: index + 2, size: 200, status: 'ACTIVE' })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
async function loadAllOceanBookings() {
const firstPage = await listOceanBookings({ page: 1, size: 200 })
if (firstPage.pages <= 1) return firstPage.records
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200 })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
async function loadSimulationWarehouses() {
await loadWarehouses()
if (warehouseError.value) throw warehouseError.value
return warehouses.value
}
function openLink(source: object) {
const row = source as ContainerBookingResource
linkTarget.value = row
linkOpen.value = true
}
function replaceDrayage(value: TerminalDrayageView) {
drayages.value = { ...drayages.value, [value.containerBookingId]: value }
}
async function refreshDrayage(containerBookingId: string) {
const value = await getTerminalDrayage(containerBookingId)
replaceDrayage(value)
return value
}
async function selectRowFleet(source: object, supplierIdValue: unknown) {
const row = source as ContainerBookingResource
const current = drayageFor(row.id)
const supplierId = String(supplierIdValue || '')
const supplier = terminalFleetOptions.value.find((item) => item.id === supplierId)
|| truckingCompanies.value.find((item) => item.id === supplierId)
if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
try {
replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name }))
ElMessage.success(current.fleetSupplierId && current.fleetSupplierId !== supplier.id
? '码头车队已更新,新车队提柜邮件已入队;原车队已下发过邮件时同时加入取消邮件'
: '码头车队已更新')
await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '码头车队更新失败')
if (details.code === 'A0409') {
try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ }
ElMessage.warning('运输信息已被其他人修改,已刷新当前柜最新状态')
} else ElMessage.error(details.message)
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
}
function openDrayageAction(row: ContainerBookingResource, action: Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>) {
if (!canDrayage(row, action)) return
drayageTarget.value = row; drayageAction.value = action; drayageActionAt.value = localDateTimeValue(); drayageReason.value = ''
drayageActionError.value = null; drayageActionOpen.value = true
}
async function saveDrayageAction() {
const row = drayageTarget.value
const current = row ? drayageFor(row.id) : null
if (!row || !current || !drayageActionAt.value || !canDrayage(row, drayageAction.value) || drayageActionSaving.value) return
drayageActionSaving.value = true; drayageActionError.value = null
try {
const reason = drayageReason.value.trim() || undefined
const next = await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason })
replaceDrayage(next); drayageActionOpen.value = false
ElMessage.success(`${drayageActionText(drayageAction.value)}成功`)
await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, `${drayageActionText(drayageAction.value)}失败`)
if (details.code === 'A0409') {
try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ }
drayageActionError.value = { ...details, message: '运输信息已被其他人修改,已刷新最新状态;请重新确认后提交' }
} else drayageActionError.value = details
} finally { drayageActionSaving.value = false }
}
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
const current = arrivalTimes.value[row.id]
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
arrivalSavingIds.value.push(row.id)
try {
const updated = await updateContainerExpectedArrival(row.id, {
estimatedWarehouseArrivalAt: value,
expectedVersion: current.version,
})
arrivalTimes.value = { ...arrivalTimes.value, [row.id]: updated }
arrivalInputs.value = { ...arrivalInputs.value, [row.id]: updated.estimatedWarehouseArrivalAt || '' }
ElMessage.success('预计到仓时间已同步整柜,后续入库计划将自动带出')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '预计到仓时间保存失败').message)
const latest = await listContainerArrivalTimes([row.id]).catch(() => [])
if (latest[0]) {
arrivalTimes.value = { ...arrivalTimes.value, [row.id]: latest[0] }
arrivalInputs.value = { ...arrivalInputs.value, [row.id]: latest[0].estimatedWarehouseArrivalAt || '' }
}
} finally { arrivalSavingIds.value = arrivalSavingIds.value.filter((id) => id !== row.id) }
}
async function openDrayageHistory(row: ContainerBookingResource) {
drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true
try {
const [current, history] = await Promise.all([getTerminalDrayage(row.id), getTerminalDrayageHistory(row.id)])
if (drayageTarget.value?.id !== row.id) return
replaceDrayage(current); drayageHistory.value = history
} catch (error) { if (drayageTarget.value?.id === row.id) drayageHistoryError.value = getApiErrorDetails(error, '码头拖车记录加载失败') }
finally { if (drayageTarget.value?.id === row.id) drayageHistoryLoading.value = false }
}
async function loadRows() {
const sequence = ++listSequence
@@ -130,119 +609,225 @@ async function loadRows() {
const [from, to] = applied.value.sailingRange || []
try {
const result = await listContainers({
page: page.value, size: pageSize.value, keyword: applied.value.keyword.trim() || undefined,
warehouseId: applied.value.warehouseId || undefined, containerType: applied.value.containerType || undefined,
page: page.value, size: pageSize.value, customerId: applied.value.customerId || undefined, keyword: applied.value.keyword.trim() || undefined,
containerType: applied.value.containerType || undefined,
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
})
estimatedSailingTo: to ? `${to}T23:59:59` : undefined, scope: scope.value,
}, props.workbench)
if (sequence !== listSequence) return
rows.value = result.records; total.value = result.total
rows.value = result.records as ContainerWorkbenchRow[]; total.value = result.total
drayages.value = Object.fromEntries(rows.value.flatMap((row) => row.terminalDrayage ? [[row.id, row.terminalDrayage as TerminalDrayageView]] : []))
const arrivalValues = isOverseas.value ? await listContainerArrivalTimes(rows.value.map((row) => row.id)) : []
if (sequence !== listSequence) return
arrivalTimes.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item]))
arrivalInputs.value = Object.fromEntries(arrivalValues.map((item) => [item.sourceContainerBookingId, item.estimatedWarehouseArrivalAt || '']))
} catch (error) {
if (sequence !== listSequence) return
rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试')
} finally { if (sequence === listSequence) loading.value = false }
}
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
async function openDetail(source: object) {
function openDetail(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
detailOpen.value = true; detail.value = null; detailError.value = null; detailLoading.value = true
const sequence = ++detailSequence
try { const result = await getContainer(row.id); if (sequence === detailSequence) detail.value = result }
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
finally { if (sequence === detailSequence) detailLoading.value = false }
void router.push({ name: props.workbench === 'DOMESTIC' ? 'domestic-container-detail' : 'overseas-container-detail', params: { containerId: row.id } })
}
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'link') { void openLink(row); return }
if (command === 'overview') { openDetail(row); return }
if (command === 'email-history') { void openEmailHistory(row); return }
if (command === 'drayage-history') { void openDrayageHistory(row); return }
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>); return }
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
}
function containerRowActions(row: ContainerWorkbenchRow): RowActionItem[] {
const processLabel = row.planStatus === 'EDITING' ? '装柜' : row.planStatus === 'LOADING' ? '预装清单' : row.planStatus === 'COMPLETED' ? '装柜清单' : ''
const canOpenProcess = row.planStatus === 'EDITING' ? canSmartLoading.value : row.planStatus === 'LOADING' ? canViewPreload.value : row.planStatus === 'COMPLETED' ? canViewLoadingList.value : false
return [
{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500, visible: canView.value },
{ key: 'edit', label: '编辑', priority: 450, visible: isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') },
{ key: 'process', label: processLabel, priority: 400, tone: 'primary', visible: isDomestic.value && Boolean(processLabel) && canOpenProcess },
{ key: 'empty-return', label: '确认还柜', priority: 350, tone: 'primary', visible: canDrayage(row, 'RECORD_EMPTY_RETURN') },
{ key: 'send-email', label: '发送邮件', priority: 300, tone: 'primary', visible: canDrayage(row, 'SEND_PICKUP_EMAIL'), loading: drayageSavingIds.value.includes(row.id) },
{ key: 'overview', label: '轨迹与日志', priority: 200, visible: canView.value },
{ key: 'email-history', label: '邮件记录', priority: 150, visible: canView.value && isOverseas.value },
{ key: 'drayage-history', label: '码头拖车记录', priority: 140, visible: canView.value && isOverseas.value },
{ key: 'documents', label: '清关 / DO / PU', priority: 130, visible: canView.value && isOverseas.value && canOverseasDocumentEdit.value && hasAction(row, 'MANAGE_DESTINATION_DOCUMENTS') },
{ key: 'link', label: '关联已放舱订舱', priority: 120, visible: canView.value && isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId },
]
}
function handleContainerRowAction(command: string, row: ContainerWorkbenchRow) {
if (command === 'view') openDetail(row)
else if (command === 'edit') void openEdit(row)
else if (command === 'process') goContainerProcess(row)
else if (command === 'empty-return') openDrayageAction(row, 'RECORD_EMPTY_RETURN')
else if (command === 'send-email') void sendPickupEmail(row)
else handleMoreAction(command, row)
}
function openCreate() { detail.value = null; editorError.value = null; replaceForm(null); editorOpen.value = true }
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
try { detail.value = await getContainer(row.id); replaceForm(detail.value); editorOpen.value = true }
try {
const [latest] = await Promise.all([getContainer(row.id, props.workbench), loadWarehouses(), loadBusinessOptions()])
if (!isDomestic.value || !hasAction(latest, 'EDIT_CONTAINER')) return
detail.value = latest
replaceForm(latest)
editorOpen.value = true
}
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
if (unresolvedTruckingSupplierName.value) {
editorError.value = { message: '原拖车供应商已不可用,请重新选择合作中的拖车供应商' }
return
}
if (!(await formRef.value?.validate().catch(() => false))) return
saving.value = true
editorError.value = null
try {
if (!form.id) await createContainer(payload())
else if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
else await updateContainer(form.id, form.version, payload())
ElMessage.success(form.id ? '货柜已更新' : '货柜已新增'); editorOpen.value = false; await loadRows()
await updateContainer(form.id, form.version, payload())
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '保存失败')
editorError.value = { ...details, message: details.code === 'A0409' ? '数据已被其他人修改,请刷新最新数据后重试' : details.message }
ElMessage.error(details.code === 'A0409' ? '数据已被其他人修改,请刷新列表后重试' : details.message)
if (details.code === 'A0409' && form.id) {
const submitted = { ...form }
try {
const latest = await getContainer(form.id, props.workbench)
detail.value = latest
replaceForm(latest)
Object.assign(form, submitted, { id: latest.id, version: latest.version })
editorError.value = { ...details, message: '数据已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
ElMessage.warning(editorError.value.message)
} catch (refreshError) {
editorError.value = getApiErrorDetails(refreshError, '数据已被其他人修改,且最新数据刷新失败,请稍后重试')
ElMessage.error(editorError.value.message)
}
} else {
editorError.value = details
ElMessage.error(details.message)
}
} finally { saving.value = false }
}
function goWorkbench(source: object) { const row = source as ContainerBookingResource; void router.push({ name: 'smart-loading-workbench', params: { containerId: row.id } }) }
function goContainerProcess(source: object) {
const row = source as ContainerBookingResource
if (row.planStatus === 'EDITING') {
void router.push({ name: 'smart-loading-workbench', params: { containerId: row.id } })
return
}
void router.push({
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail',
params: row.planStatus === 'COMPLETED' ? { containerId: row.id } : undefined,
query: row.planStatus === 'COMPLETED' ? { tab: 'loading-list' } : { planId: row.planId || undefined, warehouseId: row.warehouseId },
})
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
watch(scope, () => { if (page.value === 1) void loadRows(); else page.value = 1 })
onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
</script>
<template>
<AdminPage as="div">
<AdminPage class="admin-density" as="div">
<section class="container-page">
<ListPageLayout ariaLabel="货柜列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
<section id="container-workbench-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-start><h1>{{ isDomestic ? '货柜列表' : '清关提柜' }}</h1></template>
<template #filters>
<div class="container-query-panel integrated-query-group">
<div class="container-scope-tabs density-status-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
</div>
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</div>
</template>
<template v-if="warehouseError || loadError" #state>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-else-if="loadError" title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template>
<section v-if="!warehouseError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus === 'EDITING'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
<template #notices><template v-if="loadError">
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template></template>
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
</DataTable>
</section>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions>
</AppDrawer>
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="装柜中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" :disabled="transportOnly" /></el-form-item>
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="开始装柜前必须填写" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select v-model="form.containerType" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="容量上限(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" :disabled="transportOnly" /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" :disabled="transportOnly" /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船公司"><el-input v-model="form.shippingCompanyName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.departurePortCode" maxlength="32" :disabled="transportOnly" /></el-form-item>
<el-form-item label="起运港名称"><el-input v-model="form.departurePortName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.destinationPortCode" maxlength="32" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的港名称"><el-input v-model="form.destinationPortName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的仓代码"><el-input v-model="form.destinationWarehouseCode" maxlength="64" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的仓名称"><el-input v-model="form.destinationWarehouseName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="拖车公司"><el-input v-model="form.terminalTruckingCompany" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
</el-form>
</AppDrawer>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
</DataTable>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 16px; margin-top: 16px; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }@media(max-width:720px){.form-grid { grid-template-columns: 1fr; }.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
</style>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainerDetailView from './ContainerDetailView.vue'
</script>
<template><ContainerDetailView workbench="DOMESTIC" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
</script>
<template><ContainerWorkbenchView stage="DOMESTIC" /></template>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainerDetailView from './ContainerDetailView.vue'
</script>
<template><ContainerDetailView workbench="OVERSEAS" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import ContainerWorkbenchView from './ContainerWorkbenchView.vue'
</script>
<template><ContainerWorkbenchView stage="OVERSEAS" /></template>
@@ -1,33 +1,66 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { AxiosError } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates } from '../api/platformContainerLoading'
import { assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), assignSmartLoadingPallet: vi.fn(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), removeSmartLoadingGroup: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockResolvedValue(new Map()) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 19500,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const planResponse = {
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], palletMembers: [], items: [],
}
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }
function candidatePage(records: unknown[]) {
return {
records: records.map((record) => {
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
}),
page: 1, size: 200, total: records.length, pages: records.length ? 1 : 0,
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
}
describe('SmartLoadingWorkbenchView', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
expect(source).toContain('.candidate-group :deep(.line-clamp-text) { width: 100%;')
expect(source).toContain('<template #goods="{ row }"><div class="goods-cell"><LineClampText')
expect(source).toContain('<template #goodsSummary="{ row }"><LineClampText')
expect(source).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
})
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.mocked(getContainer).mockResolvedValue(container)
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(planResponse)
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([candidate]))
})
it('restores the real draft and explains candidate capacity', async () => {
@@ -37,9 +70,187 @@ describe('SmartLoadingWorkbenchView', () => {
await flushPromises()
expect(getContainer).toHaveBeenCalledWith(container.id)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith('8')
expect(wrapper.text()).toContain('LP-99')
expect(wrapper.text()).toContain('ORD-1')
expect(wrapper.text()).toContain('按 110% 上限')
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: '8', page: 1, size: 200 })
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
expect(wrapper.text()).not.toContain('LP-99')
expect(wrapper.text()).toContain('IN-1')
expect(wrapper.text()).not.toContain('ORD-1')
expect(wrapper.text()).toContain('150% 容量上限')
})
it('shows the full inbound composition for an assigned sealed pallet', async () => {
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...planResponse,
items: [{
id: '101', loadingPlanId: '99', accountId: '1', inboundId: '180', inboundDetailId: '201', orderId: '280',
orderNo: 'O180', inboundCode: 'I180', customerName: '客户', destinationWarehouseId: '9',
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '货物',
unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-260914-0025', indivisible: true,
packageCount: 11, productQuantity: 110, grossWeightKg: 109.2, volumeCbm: 1.87, loadSequence: 1,
version: 1, pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
}],
palletMembers: [{ palletId: '801', members: [
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
] }],
})
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
const row = wrapper.findAll('.el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
expect(row).toBeDefined()
expect(row!.text()).toContain('I180:6 箱')
expect(row!.text()).toContain('I181:5 箱')
})
it('uses loading plan capabilities to keep PDA increments editable', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], warehouseId: container.warehouseId, page: 1, size: 200 })
expect(wrapper.text()).toContain('已下发 PDA,仍可维护未完成的装柜明细')
expect(wrapper.text()).toContain('刷新 PDA 明细与版本')
expect(wrapper.text()).not.toContain('智能推荐')
expect(wrapper.text()).toContain('IN-1')
})
it('refreshes the latest plan after A0409', async () => {
vi.mocked(getSmartLoadingPlanByBooking)
.mockResolvedValueOnce(planResponse)
.mockResolvedValueOnce({ ...planResponse, version: 3 })
vi.mocked(recommendSmartLoading).mockRejectedValueOnce(new AxiosError(
'conflict', 'ERR_BAD_REQUEST', undefined, undefined,
{ data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
)).mockResolvedValueOnce({ ...planResponse, version: 4 })
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
await flushPromises()
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 2)
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 3)
expect(wrapper.text()).not.toContain('装柜操作失败')
})
it('uses bordered row actions and has no manual preload push', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" type="danger" plain')
expect(source).not.toContain('canPush')
expect(source).not.toContain('推送预装')
expect(source).not.toContain('关联并放舱真实订舱')
expect(source).not.toContain('订舱放舱')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
expect(source).not.toContain("label: '目的仓'")
expect(source).not.toContain('plan.planNo')
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
expect(source).toContain('<span>柜次号</span>')
expect(source).toContain('<span>集装箱号</span>')
expect(source).not.toContain('当前柜号')
})
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
expect(source).toContain('<div v-else class="package-count-editor"><el-input-number')
expect(source).toContain(':controls="false"')
expect(source).toContain('@blur="adjust(row)" @keyup.enter="adjust(row)"')
expect(source).not.toContain('<Save')
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
})
it('aggregates a two-order group and calls only the atomic whole-group assignment', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, inboundId: '201', orderId: '301', inboundCode: 'IN-201', availablePackageCount: 12, availableVolumeCbm: 2.4, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 12, locked: false, blockReason: null,
groupMembers: [
{ accountId: '1', inboundId: '201', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '示例企业', availablePackageCount: 4, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '1', inboundId: '203', orderId: '301', orderNo: 'ORD-1', inboundCode: 'IN-203', customerName: '示例企业', availablePackageCount: 2, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
{ accountId: '2', inboundId: '202', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', customerName: '另一企业', availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
],
}]))
vi.mocked(assignSmartLoadingGroup).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9007199254740999 · 2 票')
expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
expect(wrapper.text()).toContain('整组 12 箱')
expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
await flushPromises()
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows an exact three-member group block and prevents individual assignment', async () => {
const members = ['401', '402', '403'].map((inboundId, index) => ({ accountId: `${index + 1}`, inboundId, orderId: `5${index}`, orderNo: `ORD-${index}`, inboundCode: `IN-${inboundId}`, customerName: `客户${index}`, availablePackageCount: 6, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null }))
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, inboundId: '401', orderId: '50', inboundCode: 'IN-401', availablePackageCount: 18, coLoadGroupId: '9900', groupNo: 'CLG-9900',
memberCount: 3, groupTotalBoxes: 18, locked: false, blockReason: '同柜不可拆组成员候选不齐,缺少 1 票', groupMembers: members,
}]))
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
expect(button.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('本次计划装柜箱数')
})
it('keeps an ordinary ungrouped inbound on the existing partial-count assignment', async () => {
vi.mocked(assignSmartLoadingItem).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
wrapper.unmount()
})
it('assigns a sealed pallet atomically without a package-count input', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
...candidate, unitType: 'SEALED_PALLET', palletId: '9007199254740998', palletNo: 'DP-260908-0001',
indivisible: true, availablePackageCount: 12,
}]))
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(planResponse)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('DP-260908-0001')
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
await flushPromises()
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(planResponse.id, { palletId: '9007199254740998', expectedVersion: planResponse.version })
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -1,7 +1,9 @@
<script setup lang="ts">
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ArrowLeft, ListChecks, RefreshCw, Sparkles } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -9,13 +11,17 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
recommendSmartLoading, removeSmartLoadingItem,
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingItem, SmartLoadingPlan } from '../types'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingCandidateGroupMember, SmartLoadingPlan } from '../types'
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
const route = useRoute()
const router = useRouter()
@@ -29,142 +35,314 @@ const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const editingQuantityId = ref('')
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const editable = computed(() => plan.value?.status === 'EDITING')
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editable = computed(() => plan.value?.canEdit === true)
const assignable = computed(() => plan.value?.canAssign === true)
const assignedInboundIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundId)))
const assignedPalletIds = computed(() => new Set((plan.value?.items || []).map((item) => item.palletId).filter(Boolean)))
type SmartLoadingCandidateRow = SmartLoadingCandidate & { memberInboundIds: string[]; memberInboundCodes: string[]; memberOrderSummaries: string[]; memberOrderCount: number; inboundFragmentCount: number }
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
}
return [...orders.values()].map((order) => `${order.label}:${order.inboundCodes.join('、')}`)
}
function groupMemberLabel(group: SmartLoadingPlanCoLoadGroup) {
return group.inboundFragmentCount === group.memberOrderCount
? `${group.memberOrderCount} 票`
: `${group.memberOrderCount} 票 · ${group.inboundFragmentCount} 个入仓片段`
}
const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
return candidates.value.map((candidate) => {
const members: Array<{ inboundId: string | null; orderId: string; orderNo?: string | null; inboundCode?: string | null }> = candidate.groupMembers.length
? candidate.groupMembers
: [candidate]
return {
...candidate,
memberInboundIds: members.map((member) => member.inboundId).filter((id): id is string => Boolean(id)),
memberInboundCodes: members.map((member) => member.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(members),
memberOrderCount: new Set(members.map((member) => member.orderId)).size,
inboundFragmentCount: Number(candidate.memberCount || members.length),
}
})
})
const planPalletMembersByPalletId = computed(() => new Map(
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
))
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
const grouped = new Map<string, SmartLoadingInboundItem[]>()
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
}
for (const rows of grouped.values()) {
const first = rows[0]
const group = planGroupsByInboundId.value.get(first.inboundId)!
result.push({
...first,
id: `coload:${group.groupId}`,
inboundCode: rows.map((row) => row.inboundCode).filter(Boolean).join('、'),
customerName: [...new Set(rows.map((row) => row.customerName).filter(Boolean))].join('、'),
skuCode: [...new Set(rows.map((row) => row.skuCode).filter(Boolean))].join('、'),
productNameZh: [...new Set(rows.map((row) => row.productNameZh).filter(Boolean))].join('、'),
packageCount: rows.reduce((sum, row) => sum + row.packageCount, 0),
productQuantity: rows.reduce((sum, row) => sum + Number(row.productQuantity || 0), 0),
grossWeightKg: rows.reduce((sum, row) => sum + Number(row.grossWeightKg || 0), 0),
volumeCbm: rows.reduce((sum, row) => sum + Number(row.volumeCbm || 0), 0),
pickedPackageCount: rows.reduce((sum, row) => sum + row.pickedPackageCount, 0),
minPackageCount: rows.reduce((sum, row) => sum + row.minPackageCount, 0),
canAdjust: false,
canRemove: rows.every((row) => row.canRemove),
sourceItems: rows.flatMap((row) => row.sourceItems),
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
coLoadGroup: group,
palletMembers: [],
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
})
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1)
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.5)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.5)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 120, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 130, slotName: 'weight' },
{ key: 'destination', label: '目的仓', minWidth: 160, slotName: 'destination' },
]
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 180 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 120, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 120, slotName: 'unitWeight' },
{ key: 'route', label: '目的仓匹配', minWidth: 170, slotName: 'route' },
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'customerName' },
{ key: 'goodsSummary', prop: 'goodsSummary', label: '货物摘要', minWidth: 180, slotName: 'goodsSummary' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 90 },
{ key: 'cartonIdentityStatus', label: '箱号归属', width: 110, slotName: 'cartonIdentityStatus' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 120, slotName: 'averageUnitVolume' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 120, slotName: 'unitGrossWeight' },
]
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toFixed(digits) : '0.000' }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function routeMatches(source: object) {
const candidate = candidateRow(source)
const target = container.value?.destinationWarehouseCode
return !target || target === candidate.destinationWarehouseCode
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
function candidateRow(source: object) { return source as SmartLoadingCandidateRow }
function itemRow(source: object) { return source as SmartLoadingInboundItem }
function adjustmentMax(source: object) {
const item = itemRow(source)
return Math.max(item.minPackageCount, item.totalPackageCount || item.packageCount)
}
function startQuantityEdit(source: object) {
const item = itemRow(source)
quantityDrafts[item.id] = item.packageCount
editingQuantityId.value = item.id
}
function explainCandidate(source: object) {
const candidate = candidateRow(source)
if (!routeMatches(candidate)) return `目的仓不一致(候选:${candidate.destinationWarehouseCode || '未设置'})`
const volume = Number(candidate.unitVolumeCbm || 0)
const weight = Number(candidate.unitWeightKg || 0)
const volume = Number(candidate.averageUnitVolumeCbm || 0)
const weight = Number(candidate.unitGrossWeightKg || 0)
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
}
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成'
const cabinetNo = String(value.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetNo) ? '待生成' : cabinetNo || '待生成'
}
function displayContainerNo(value: ContainerBookingResource | null | undefined) {
const containerNo = String(value?.containerNo || '').trim()
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待补录'
}
function syncDrafts(preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
for (const item of displayedPlanItems.value) quantityDrafts[item.id] = preserved[item.id] ?? item.packageCount
}
function acceptPlan(next: SmartLoadingPlan, preserved: Record<string, number> = {}) { plan.value = next; noDraft.value = false; syncDrafts(preserved) }
async function acceptPlanWithGroups(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
acceptPlan(next, preserved)
planGroupsByInboundId.value = await loadSmartLoadingPlanCoLoadGroups(next)
syncDrafts(preserved)
}
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
async function load() {
loading.value = true; loadError.value = null; noDraft.value = false
planGroupsByInboundId.value = new Map()
try {
container.value = await getContainer(containerId.value)
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
if (container.value.planStatus === 'COMPLETED') {
plan.value = null
candidates.value = []
return
}
try { await acceptPlanWithGroups(await getSmartLoadingPlanByBooking(containerId.value)) }
catch (error) {
const detail = getApiErrorDetails(error)
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
else throw error
}
candidates.value = await listSmartLoadingCandidates(container.value.warehouseId)
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
const channelGroup = getSmartLoadingChannelGroup(container.value.channelGroupCode)
candidates.value = plan.value?.canAssign && channelGroup
? (await listSmartLoadingCandidates({ channelGroupCodes: [channelGroup.code], warehouseId: container.value.warehouseId, page: 1, size: 200 })).records
: []
} catch (error) { loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败') }
finally { loading.value = false }
}
async function createPlan() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
catch (error) { handleMutationError(error, '创建排柜计划失败') }
finally { mutating.value = false }
function openOperationalList() {
if (!container.value || container.value.planStatus === 'EDITING') return
void router.push({
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail',
params: container.value.planStatus === 'COMPLETED' ? { containerId: container.value.id } : undefined,
query: container.value.planStatus === 'COMPLETED' ? { tab: 'loading-list' } : { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
})
}
function openAssign(source: object) {
const candidate = candidateRow(source)
if (candidateBlocked(candidate)) return
selectedCandidate.value = candidate
assignForm.packageCount = candidate.coLoadGroupId ? Number(candidate.groupTotalBoxes || candidate.availablePackageCount) : Math.min(candidate.availablePackageCount, 1)
assignOpen.value = true
}
function openAssign(source: object) { const candidate = candidateRow(source); selectedCandidate.value = candidate; assignForm.packageCount = Math.min(candidate.availablePackageCount, 1); assignOpen.value = true }
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
const currentPlan = plan.value
const selected = selectedCandidate.value
const inboundId = selected?.inboundId
if (!currentPlan || !selected || !inboundId) return
if (selected.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
if (selected.coLoadGroupId && (selected.blockReason || selected.locked)) return
mutating.value = true
mutationError.value = null
try {
acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, inboundDetailId: selectedCandidate.value.inboundDetailId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }))
assignOpen.value = false; ElMessage.success('货物已分配')
} catch (error) { handleMutationError(error, '分配失败') }
const next = selected.unitType === 'SEALED_PALLET' && selected.palletId
? await assignSmartLoadingPallet(currentPlan.id, { palletId: selected.palletId, expectedVersion: currentPlan.version })
: selected.coLoadGroupId
? await assignSmartLoadingGroup(currentPlan.id, inboundId, currentPlan.version)
: await assignSmartLoadingItem(currentPlan.id, { inboundId, packageCount: assignForm.packageCount, expectedVersion: currentPlan.version })
await acceptPlanWithGroups(next)
assignOpen.value = false
ElMessage.success(selected.coLoadGroupId ? `同柜不可拆组 ${selected.groupNo} 已整组分配` : '货物已分配')
} catch (error) { await handleMutationError(error, '分配失败') }
finally { mutating.value = false }
}
function candidateBlocked(candidate: SmartLoadingCandidateRow) {
if (candidate.unitType === 'SEALED_PALLET') {
return Boolean(candidate.blockReason) || candidate.locked === true || !candidate.palletId
|| !candidate.indivisible || assignedPalletIds.value.has(candidate.palletId)
}
return Boolean(candidate.blockReason) || candidate.locked === true
|| !candidate.inboundId
|| candidate.memberInboundIds.some((inboundId) => assignedInboundIds.value.has(inboundId))
|| candidate.availablePackageCount < 1
}
async function recommend() {
if (!plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)); ElMessage.success('智能建议已生成,请人工核对后再推送') }
catch (error) { handleMutationError(error, '智能推荐失败') }
try {
try {
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
} catch (error) {
const details = getApiErrorDetails(error, '智能推荐失败')
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
await load()
if (!plan.value) throw error
await acceptPlanWithGroups(await recommendSmartLoading(plan.value.id, plan.value.version))
}
ElMessage.success('智能建议已生成,请人工核对明细')
}
catch (error) { await handleMutationError(error, '智能推荐失败') }
finally { mutating.value = false }
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value) return
if (item.unitType === 'SEALED_PALLET') return
if (!plan.value || mutating.value) return
const count = Number(quantityDrafts[item.id])
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
if (count === item.packageCount) return
if (!Number.isInteger(count) || count < item.minPackageCount) { ElMessage.warning(`分配箱数不能低于已拣下限 ${item.minPackageCount} 箱`); return }
mutating.value = true
mutationError.value = null
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
catch (error) { handleMutationError(error, '调整失败') }
try { await acceptPlanWithGroups(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); editingQuantityId.value = ''; ElMessage.success('箱数已调整') }
catch (error) { await handleMutationError(error, '调整失败') }
finally { mutating.value = false }
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value) return
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!plan.value || !item.canRemove) return
const row = source as SmartLoadingPlanRow
const message = row.coLoadGroup
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
catch (error) { handleMutationError(error, '移除失败') }
try {
if (row.coLoadGroup) {
await acceptPlanWithGroups(await removeSmartLoadingGroup(plan.value!.id, row.coLoadGroup.groupId, plan.value!.version))
await load()
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
return
}
let current = plan.value!
for (const original of item.sourceItems) {
const currentItem = current.items.find((value) => value.id === original.id)
if (!currentItem) continue
current = await removeSmartLoadingItem(current.id, currentItem.id, current.version, currentItem.version)
await acceptPlanWithGroups(current)
}
await load()
ElMessage.success('该入仓码已从装柜方案移除')
}
catch (error) { await handleMutationError(error, '移除失败', true) }
finally { mutating.value = false }
})
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后计划进入装柜流程,不能继续调整排柜明细。确认推送预装清单?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
catch (error) { handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
function handleMutationError(error: unknown, fallback: string) {
async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
const details = getApiErrorDetails(error, fallback)
mutationError.value = { ...details, message: details.code === 'A0409' ? '计划已被其他人修改,请刷新最新计划后重试' : details.message }
if (details.code === 'A0409') {
const preserved = { ...quantityDrafts }
await load()
syncDrafts(preserved)
}
const pickingMessage = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
? '该订单在拣货中不许删除'
: null
mutationError.value = { ...details, message: pickingMessage || details.message || (details.code === 'A0409' ? '计划或 PDA 明细已变化,已刷新最新版本并保留本次输入,请重新操作' : details.message) }
ElMessage.error(mutationError.value.message)
}
@@ -172,70 +350,105 @@ onMounted(load)
</script>
<template>
<AdminPage>
<AdminPage class="admin-density">
<section class="business-page workbench">
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
<div class="workbench-identity">
<span>当前柜次</span>
<strong>{{ container?.cabinetNo || '正在加载' }}</strong>
<small>集装箱号 {{ container?.containerNo || '待录入' }}</small>
<section class="surface-panel workbench-command-bar" aria-label="装柜作业摘要">
<div class="workbench-identities">
<div class="workbench-identity">
<span>柜次号</span>
<strong>{{ loading ? '正在加载' : displayCabinetNo(container) }}</strong>
</div>
<div class="workbench-identity">
<span>集装箱号</span>
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
</div>
</div>
<div class="header-actions">
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
<el-button link class="density-back" aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能装柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
</div>
</section>
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-if="loadError" title="智能装柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else>
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '已推送预装' : plan ? '编辑中' : '未创建' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section v-if="container?.planStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成状态">
<el-tag type="success">装柜完成</el-tag>
<strong>该柜次已完成装柜</strong>
<p>智能装柜方案已锁定,不能继续调整。请进入装柜清单查看完整记录。</p>
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
</section>
<template v-else>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 150% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 150% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该柜次缺少自动生成的预装计划,请刷新后重试" />
<template v-else-if="plan">
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>装柜方案</h2><p>计划 {{ plan.planNo }} · 版本 {{ plan.version }};智能建议必须经人工核对。</p></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" class="density-action" :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)">调整</el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
</DataTable>
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" preference-key="smart-loading-candidates" empty-text="当前仓库暂无可排柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于仓库、目的仓、可用箱数及 110% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && editable" link type="primary" :disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
<template #route="{ row }"><el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '冲突' }}</el-tag><small>{{ explainCandidate(row) }}</small></template>
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroupId" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #averageUnitVolume="{ row }">{{ formatNumber(Number(row.averageUnitVolumeCbm || 0)) }} m³</template><template #unitGrossWeight="{ row }">{{ formatNumber(Number(row.unitGrossWeightKg || 0),1) }} kg</template>
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
</DataTable>
</template>
</template>
</template>
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.workbench { display: grid; gap: 16px; min-width: 0; }
.workbench { display: grid; gap: var(--admin-space-5); min-width: 0; }
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
.workbench-command-bar { justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); }
.workbench-identities { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-7); }
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.workbench-identity span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.workbench-identity strong { font-size: var(--admin-font-title); overflow-wrap: anywhere; }
.workbench h2,.workbench h2 + p { margin: 0; }
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
.workbench h2 { font-size: var(--admin-font-section); }
.workbench h2 + p,.workbench small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }
.header-actions,.row-actions { flex-wrap: wrap; gap: var(--admin-space-3); }
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
.summary-band span { color: var(--cc-muted); font-size: 12px; }
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
.summary-band { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
.summary-band>div { display: grid; gap: 5px; padding: var(--admin-space-5); background: var(--admin-surface); }
.summary-band span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.readonly-status-card { display: grid; justify-items: start; gap: var(--admin-space-4); padding: var(--admin-space-6); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.readonly-status-card strong { font-size: var(--admin-font-section); }
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
.meter-title { justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-4); font-size: var(--admin-font-body); }
.row-actions { min-width: 150px; }
.workbench :deep(.el-input-number) { width: 130px; }
.assign-dialog p { margin: 0 0 10px; }
.goods-cell { display: grid; min-width: 0; gap: 2px; }
.goods-cell :deep(.line-clamp-text) { width: 100%; font-weight: 600; }
.goods-cell :deep(.goods-cell__sku.line-clamp-text) { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
.package-count-editor { display: grid; min-width: 0; gap: var(--admin-space-1); }
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); }
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-group .candidate-block { color: var(--el-color-danger); }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style>
+22 -10
View File
@@ -2,21 +2,33 @@ import type { RouteRecordRaw } from 'vue-router'
export const containerLoadingRoutes = [
{
path: 'container-loading',
name: 'container-loading',
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
path: 'container-loading/domestic',
name: 'domestic-container-workbench',
component: () => import('./pages/DomesticContainersView.vue'),
meta: { showPageHeading: false, title: '货柜列表', description: '管理国内装柜、海外清关提柜与到仓', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
},
{
path: 'container-loading/smart-loading',
name: 'global-smart-loading-workbench',
component: () => import('./pages/GlobalSmartLoadingView.vue'),
meta: { title: '智能装柜', description: '统一调度候选货物、货柜与装柜方案', permission: 'platform:firstleg:smart-loading:list' },
path: 'container-loading/domestic/:containerId',
name: 'domestic-container-detail',
component: () => import('./pages/DomesticContainerDetailView.vue'),
meta: { title: '送柜报关详情', description: '查看送柜、装柜清单、报关与出仓', permission: 'platform:firstleg:container:list', showPageHeading: false },
},
{
path: 'container-loading/:containerId/smart-loading',
path: 'container-loading/domestic/:containerId/smart-loading',
name: 'smart-loading-workbench',
component: () => import('./pages/SmartLoadingWorkbenchView.vue'),
meta: { title: '智能排柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' },
meta: { title: '智能装柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' },
},
{
path: 'container-loading/overseas',
name: 'overseas-container-workbench',
component: () => import('./pages/OverseasContainersView.vue'),
meta: { showPageHeading: false, title: '清关提柜', description: '管理海运轨迹、目的港清关、提柜与还空', permission: 'platform:firstleg:terminal-drayage:list' },
},
{
path: 'container-loading/overseas/:containerId',
name: 'overseas-container-detail',
component: () => import('./pages/OverseasContainerDetailView.vue'),
meta: { title: '清关提柜详情', description: '查看清关资料、码头拖车与还空轨迹', permission: 'platform:firstleg:terminal-drayage:list', showPageHeading: false },
},
] satisfies RouteRecordRaw[]
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
SMART_LOADING_CHANNEL_GROUPS,
SMART_LOADING_WAREHOUSE_GROUPS,
isSeaRailChannel,
resolveSmartLoadingChannelGroup,
resolveSmartLoadingWarehouseGroup,
} from './smartLoadingChannelGroups'
describe('smartLoadingChannelGroups', () => {
it('exposes the nine confirmed display groups in a fixed order', () => {
expect(SMART_LOADING_CHANNEL_GROUPS.map((group) => group.name)).toEqual([
'美森正班', '美森加班', 'EXX', '合德', '洛杉矶普船', '芝加哥普船', '休斯顿普船', '萨凡纳普船', '纽约普船海卡',
])
})
it.each([
['MATSON_CLX_TRUCK', '美森正班海卡', '美森正班'],
['MATSON_CLX_PARCEL', '美森正班海派', '美森正班'],
['BYHP', '美森加班海派', '美森加班'],
['EXX_RAIL_NY', '纽约EXX海铁', 'EXX'],
['HDHK', '合德海卡包税', '合德'],
['OA_LA_FULL_TRUCK', '洛杉矶普船全卡', '洛杉矶普船'],
['OA_PARCEL', '普船海派', '洛杉矶普船'],
['CPHT', '芝加哥普船海铁', '芝加哥普船'],
['HPHK', '休斯顿普船海卡', '休斯顿普船'],
['SPHK', '萨凡纳普船海卡', '萨凡纳普船'],
['NPHK', '纽约普船海卡', '纽约普船海卡'],
])('maps %s / %s into %s', (channelCode, channelName, expected) => {
expect(resolveSmartLoadingChannelGroup(channelCode, channelName)?.name).toBe(expected)
})
it('does not leak an unconfirmed channel into the fixed display groups', () => {
expect(resolveSmartLoadingChannelGroup('UNKNOWN', '未确认新渠道')).toBeNull()
})
it('identifies canonical and legacy sea-rail channel codes', () => {
expect(['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT'].every(isSeaRailChannel)).toBe(true)
expect(isSeaRailChannel('OA_LA_TRUCK')).toBe(false)
})
it('exposes Yiwu and Dongguan as the fixed top-level warehouses', () => {
expect(SMART_LOADING_WAREHOUSE_GROUPS.map((group) => group.name)).toEqual(['义乌仓', '东莞仓'])
expect(resolveSmartLoadingWarehouseGroup('义乌国内仓')?.code).toBe('YIWU')
expect(resolveSmartLoadingWarehouseGroup('东莞仓')?.code).toBe('DONGGUAN')
expect(resolveSmartLoadingWarehouseGroup('其他仓')).toBeNull()
})
})
@@ -0,0 +1,102 @@
export type SmartLoadingChannelGroup = {
code: string
name: string
channelCodes: readonly string[]
}
export type SmartLoadingWarehouseGroup = {
code: string
name: string
nameKeyword: string
}
export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup[] = [
{ code: 'YIWU', name: '义乌仓', nameKeyword: '义乌' },
{ code: 'DONGGUAN', name: '东莞仓', nameKeyword: '东莞' },
] as const
const SEA_RAIL_CHANNEL_CODES = new Set(['EXX_RAIL_NY', 'NYEXX', 'OA_CHICAGO_RAIL', 'CPHT'])
export function isSeaRailChannel(channelCode?: string | null) {
return SEA_RAIL_CHANNEL_CODES.has(String(channelCode || '').trim().toUpperCase())
}
export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] = [
{
code: 'MATSON_CLX',
name: '美森正班',
channelCodes: ['MATSON_CLX_TRUCK', 'MATSON_CLX_PARCEL', 'ZBYHK', 'ZBYHWK', 'ZBYHP'],
},
{
code: 'MATSON_MAX',
name: '美森加班',
channelCodes: ['MATSON_MAX_TRUCK', 'MATSON_MAX_PARCEL', 'BYHK', 'BYHWK', 'BYHP'],
},
{
code: 'EXX',
name: 'EXX',
channelCodes: ['EXX_TRUCK', 'EXX_RAIL_NY', 'EXXHK', 'EXXHWK', 'NYEXX'],
},
{
code: 'HEDE',
name: '合德',
channelCodes: ['HEDE_TRUCK', 'HEDE_PARCEL', 'HDHK', 'HDHWK', 'HDHP'],
},
{
code: 'OA_LA',
name: '洛杉矶普船',
channelCodes: ['OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_PARCEL', 'PCHK', 'PCHWK', 'PCKK', 'PCHP'],
},
{
code: 'OA_CHICAGO',
name: '芝加哥普船',
channelCodes: ['OA_CHICAGO_RAIL', 'CPHT'],
},
{
code: 'OA_HOUSTON',
name: '休斯顿普船',
channelCodes: ['OA_HOUSTON_TRUCK', 'HPHK'],
},
{
code: 'OA_SAVANNAH',
name: '萨凡纳普船',
channelCodes: ['OA_SAVANNAH_TRUCK', 'SPHK'],
},
{
code: 'OA_NEW_YORK_TRUCK',
name: '纽约普船海卡',
channelCodes: ['OA_NEW_YORK_TRUCK', 'NPHK', 'NPHWK'],
},
] as const
export function getSmartLoadingChannelGroup(groupCode?: string | null) {
const normalizedCode = String(groupCode || '').trim().toUpperCase()
return SMART_LOADING_CHANNEL_GROUPS.find((group) => group.code === normalizedCode) || null
}
export function resolveSmartLoadingChannelGroup(channelCode?: string | null, channelName?: string | null) {
const normalizedCode = String(channelCode || '').trim().toUpperCase()
if (normalizedCode) {
const codeMatch = SMART_LOADING_CHANNEL_GROUPS.find((group) => group.channelCodes.includes(normalizedCode))
if (codeMatch) return codeMatch
}
const normalizedName = String(channelName || '').replace(/\s+/g, '')
if (!normalizedName) return null
if (normalizedName.includes('美森正班')) return SMART_LOADING_CHANNEL_GROUPS[0]
if (normalizedName.includes('美森加班')) return SMART_LOADING_CHANNEL_GROUPS[1]
if (normalizedName.toUpperCase().includes('EXX')) return SMART_LOADING_CHANNEL_GROUPS[2]
if (normalizedName.includes('合德')) return SMART_LOADING_CHANNEL_GROUPS[3]
if (normalizedName.includes('洛杉矶普船') || normalizedName === '普船海派') return SMART_LOADING_CHANNEL_GROUPS[4]
if (normalizedName.includes('芝加哥普船')) return SMART_LOADING_CHANNEL_GROUPS[5]
if (normalizedName.includes('休斯顿普船')) return SMART_LOADING_CHANNEL_GROUPS[6]
if (normalizedName.includes('萨凡纳普船')) return SMART_LOADING_CHANNEL_GROUPS[7]
if (normalizedName.includes('纽约普船')) return SMART_LOADING_CHANNEL_GROUPS[8]
return null
}
export function resolveSmartLoadingWarehouseGroup(warehouseName?: string | null) {
const normalizedName = String(warehouseName || '').replace(/\s+/g, '')
if (!normalizedName) return null
return SMART_LOADING_WAREHOUSE_GROUPS.find((group) => normalizedName.includes(group.nameKeyword)) || null
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { SmartLoadingItem } from './types'
import { groupSmartLoadingItems } from './smartLoadingItems'
function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
return {
id: '1', loadingPlanId: '10', accountId: '20', inboundId: '30', inboundDetailId: '40',
orderId: '50', orderNo: 'FL-1', inboundCode: 'NYEXX2608180003', customerName: '柏悦物流',
destinationWarehouseId: '60', destinationWarehouseCode: 'LAX', destinationWarehouseName: '洛杉矶仓',
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, packageCount: 2, productQuantity: 20,
grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 2, version: 1,
pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
...overrides,
}
}
describe('groupSmartLoadingItems', () => {
it('keeps every sealed pallet as an independent non-adjustable allocation unit', () => {
const grouped = groupSmartLoadingItems([
item({ id: 'p1', unitType: 'SEALED_PALLET', palletId: '101', palletNo: 'PLT-101', indivisible: true, packageCount: 4 }),
item({ id: 'p2', unitType: 'SEALED_PALLET', palletId: '102', palletNo: 'PLT-102', indivisible: true, packageCount: 6 }),
])
expect(grouped).toHaveLength(2)
expect(grouped.map((row) => row.palletNo)).toEqual(['PLT-101', 'PLT-102'])
expect(grouped.every((row) => row.canAdjust === false)).toBe(true)
})
it('shows multiple specification rows as one inbound-code row with summed quantities', () => {
const grouped = groupSmartLoadingItems([
item(),
item({ id: '2', inboundDetailId: '41', skuCode: 'SKU-2', productNameZh: '货物二', packageCount: 2, grossWeightKg: 662.8, volumeCbm: 0.334, loadSequence: 3 }),
])
expect(grouped).toHaveLength(1)
expect(grouped[0]).toMatchObject({
inboundCode: 'NYEXX2608180003', packageCount: 4, grossWeightKg: 1325.6,
volumeCbm: 0.668, detailCount: 2, canAdjust: false, canRemove: true,
skuCode: 'SKU-1、SKU-2', productNameZh: '货物一、货物二', loadSequence: 2,
})
expect(grouped[0].sourceItems.map((source) => source.id)).toEqual(['1', '2'])
})
it('does not merge different inbound codes and keeps single-detail adjustment', () => {
const grouped = groupSmartLoadingItems([
item(),
item({ id: '2', inboundId: '31', inboundCode: 'NYEXX2608180004' }),
])
expect(grouped).toHaveLength(2)
expect(grouped.every((row) => row.detailCount === 1 && row.canAdjust)).toBe(true)
})
})
@@ -0,0 +1,55 @@
import type { SmartLoadingItem } from './types'
export interface SmartLoadingInboundItem extends SmartLoadingItem {
sourceItems: SmartLoadingItem[]
detailCount: number
}
function sumNullable(items: SmartLoadingItem[], value: (item: SmartLoadingItem) => number | null) {
const values = items.map(value).filter((item): item is number => item != null)
return values.length ? values.reduce((sum, item) => sum + Number(item || 0), 0) : null
}
function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem) => string | null) {
const values = [...new Set(items.map(value).map((item) => String(item || '').trim()).filter(Boolean))]
return values.length ? values.join('、') : null
}
/**
* 后端按规格保存装柜明细以保证重量、体积计算准确;作业页面按入仓码汇总展示。
*/
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
const groups = new Map<string, SmartLoadingItem[]>()
for (const item of items) {
const key = item.unitType === 'SEALED_PALLET'
? `${item.accountId}:${item.inboundId}:pallet:${item.palletId}`
: `${item.accountId}:${item.inboundId}:packages`
groups.set(key, [...(groups.get(key) || []), item])
}
return [...groups.values()].map((sourceItems) => {
const first = sourceItems[0]
const pickedPackageCount = sourceItems.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0)
const loadSequences = sourceItems.map((item) => item.loadSequence).filter((item): item is number => item != null)
return {
...first,
id: sourceItems.length === 1 ? first.id : `inbound:${first.accountId}:${first.inboundId}`,
inboundDetailId: sourceItems.length === 1 ? first.inboundDetailId : '',
skuCode: joinDistinct(sourceItems, (item) => item.skuCode),
productNameZh: joinDistinct(sourceItems, (item) => item.productNameZh),
packageCount: sourceItems.reduce((sum, item) => sum + Number(item.packageCount || 0), 0),
productQuantity: sumNullable(sourceItems, (item) => item.productQuantity),
grossWeightKg: sumNullable(sourceItems, (item) => item.grossWeightKg),
volumeCbm: sumNullable(sourceItems, (item) => item.volumeCbm),
loadSequence: loadSequences.length ? Math.min(...loadSequences) : null,
pickedPackageCount,
minPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.minPackageCount || 0), 0),
totalPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.totalPackageCount || item.packageCount || 0), 0),
remainingPackageCount: sourceItems.reduce((sum, item) => sum + Number(item.remainingPackageCount || 0), 0),
canAdjust: first.unitType !== 'SEALED_PALLET' && sourceItems.length === 1 && first.canAdjust,
canRemove: sourceItems.every((item) => item.canRemove),
sourceItems,
detailCount: sourceItems.length,
}
})
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { loadSmartLoadingPlanCoLoadGroups } from './smartLoadingPlanGroups'
import type { SmartLoadingPlan } from './types'
const item = {
id: '701', loadingPlanId: '99', accountId: '1', inboundId: '201', inboundDetailId: '801', orderId: '301',
orderNo: 'ORD-1', inboundCode: 'IN-201', customerName: '客户一', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9',
skuCode: 'SKU-1', productNameZh: '货物一', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true,
}
const plan = {
id: '99', planNo: 'LP-99', status: 'EDITING', bookingId: '88', containerNo: null, voyageNo: null, version: 2,
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED', pdaTaskId: null,
pdaTaskStatus: null, pdaTaskVersion: null,
coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2,
inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }],
palletMembers: [],
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
} satisfies SmartLoadingPlan
describe('smart loading plan co-load groups', () => {
it('uses the group snapshot already returned with the plan without per-inbound requests', () => {
const result = loadSmartLoadingPlanCoLoadGroups(plan)
expect(result.get('201')).toEqual({
groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2, inboundFragmentCount: 2, fullPackageCount: 12,
})
expect(result.get('202')).toBe(result.get('201'))
})
it('leaves ordinary plan items ungrouped', () => {
const result = loadSmartLoadingPlanCoLoadGroups({ ...plan, coLoadGroups: [] })
expect(result.size).toBe(0)
})
})

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