Compare commits
457
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec1e2d77c | ||
|
|
dd47143d6e | ||
|
|
c90a191d83 | ||
|
|
115461683b | ||
|
|
9c70eab606 | ||
|
|
5946bdfcf7 | ||
|
|
7b0df1db37 | ||
|
|
73af3affac | ||
|
|
3c3a7fcd55 | ||
|
|
8fa397bb5e | ||
|
|
cc37a42298 | ||
|
|
6551b703cb | ||
|
|
f24b592705 | ||
|
|
10da46beab | ||
|
|
d7911ad09d | ||
|
|
cc5ac2e68a | ||
|
|
080042d941 | ||
|
|
9ff2720721 | ||
|
|
e4e78796a1 | ||
|
|
ba6d0ff13f | ||
|
|
2bc095ecac | ||
|
|
2477667c27 | ||
|
|
e8e7465e4e | ||
|
|
04e1d7dbfa | ||
|
|
b4b802a547 | ||
|
|
ca7f6aa414 | ||
|
|
899978e53d | ||
|
|
cb5f0d1bec | ||
|
|
110c2e0be2 | ||
|
|
93f193c5d6 | ||
|
|
578df0f927 | ||
|
|
28eb43893a | ||
|
|
058b0778a2 | ||
|
|
ea24496b11 | ||
|
|
939cc11d7b | ||
|
|
d3a2671fd7 | ||
|
|
cc973c72c0 | ||
|
|
1d73a40a07 | ||
|
|
a8ff0ef1de | ||
|
|
2c2e19c4ea | ||
|
|
caf9fd89d4 | ||
|
|
f4d58e669b | ||
|
|
5c4a381d87 | ||
|
|
9ec1fb549e | ||
|
|
6df6f6dabc | ||
|
|
60dd86ab88 | ||
|
|
049f895b44 | ||
|
|
102194460c | ||
|
|
4dc497c8ca | ||
|
|
9fa73f50d6 | ||
|
|
6a135c3982 | ||
|
|
5641ea0f7e | ||
|
|
3440f9dd11 | ||
|
|
0b5094dd8f | ||
|
|
863ad5249c | ||
|
|
8bae21dea2 | ||
|
|
1a52cc0817 | ||
|
|
fa82710e6b | ||
|
|
8c462cef17 | ||
|
|
f7b84ea887 | ||
|
|
94b8b8b31c | ||
|
|
22dcae616c | ||
|
|
dc35288a1b | ||
|
|
bca08f8749 | ||
|
|
678bccd4d1 | ||
|
|
f50850f001 | ||
|
|
8c51e9c1cf | ||
|
|
34a9861681 | ||
|
|
f8bddeeaf4 | ||
|
|
f14e1a2ea3 | ||
|
|
7edc07d235 | ||
|
|
a3005d1c02 | ||
|
|
2bf4353b1b | ||
|
|
4ead51a5fe | ||
|
|
ae13afd3d7 | ||
|
|
6bada2aca9 | ||
|
|
1324cf16ce | ||
|
|
584b74e5e1 | ||
|
|
5279fc774a | ||
|
|
8fe256372c | ||
|
|
ea35a12f8b | ||
|
|
f89a04ad2f | ||
|
|
5ad8c5c462 | ||
|
|
ab5a64c6f7 | ||
|
|
a903020610 | ||
|
|
d7489a20be | ||
|
|
365a7f7bc4 | ||
|
|
0798143bad | ||
|
|
8c0daaa5db | ||
|
|
86601fd45f | ||
|
|
e1b2f5155a | ||
|
|
db920660bc | ||
|
|
e962577eca | ||
|
|
730defabca | ||
|
|
fad49a07a8 | ||
|
|
ffe91a1936 | ||
|
|
5cc1512a28 | ||
|
|
3cf676cbbd | ||
|
|
5a90503542 | ||
|
|
62c9eb58be | ||
|
|
524a23b488 | ||
|
|
517aad5e5e | ||
|
|
a34feddd4a | ||
|
|
855d8c0d5d | ||
|
|
29cb0bd57a | ||
|
|
45e1e9b509 | ||
|
|
ec6de70e67 | ||
|
|
4a0dd3b597 | ||
|
|
47bd39d2d5 | ||
|
|
d2e063ea3f | ||
|
|
391369a3d0 | ||
|
|
945a3d3230 | ||
|
|
4e37dc3330 | ||
|
|
3815eab85e | ||
|
|
2934a795ad | ||
|
|
ff0f388daf | ||
|
|
c25aaaca53 | ||
|
|
9da23280e9 | ||
|
|
6b7a4a097d | ||
|
|
60eff4f8ab | ||
|
|
409d8b3f57 | ||
|
|
10e20f896a | ||
|
|
db92d1dfe1 | ||
|
|
841741dc83 | ||
|
|
5035e02357 | ||
|
|
4dd1b83a6b | ||
|
|
f0ee25ca18 | ||
|
|
fed257a720 | ||
|
|
33fc30911e | ||
|
|
4391a69c7d | ||
|
|
3b37249c47 | ||
|
|
358ad38335 | ||
|
|
2e2f3ed2d2 | ||
|
|
3cb8b5b70e | ||
|
|
12dc5dfd82 | ||
|
|
e93d00b79e | ||
|
|
e6771e8e86 | ||
|
|
3e8fa3c5d7 | ||
|
|
73dd411763 | ||
|
|
b9e5bf3fec | ||
|
|
b529430ea2 | ||
|
|
3f78e1d087 | ||
|
|
7586f9d783 | ||
|
|
1cc8ae44c5 | ||
|
|
6e0144d7cd | ||
|
|
592f01eb1f | ||
|
|
f24ed30ae8 | ||
|
|
db9e496b35 | ||
|
|
2e9e8819b6 | ||
|
|
6bcfb6ba0b | ||
|
|
be0f712dd6 | ||
|
|
aefcd2565f | ||
|
|
22853336b6 | ||
|
|
940010dcc2 | ||
|
|
53c887d353 | ||
|
|
158ea8c8ea | ||
|
|
810b4e11f5 | ||
|
|
60a41af3ac | ||
|
|
19a64fbaa5 | ||
|
|
1305753b71 | ||
|
|
ec4b31a46f | ||
|
|
e94915ced3 | ||
|
|
3fff8c8ea0 | ||
|
|
ad8e1fd2c2 | ||
|
|
3054096995 | ||
|
|
3475cdfe49 | ||
|
|
7e3cd93425 | ||
|
|
92ec1e11b1 | ||
|
|
1dd139c1c6 | ||
|
|
24fa3d1c9d | ||
|
|
e10d0534ae | ||
|
|
f64dedfb52 | ||
|
|
b51a847306 | ||
|
|
0ab2302720 | ||
|
|
d77d145405 | ||
|
|
451663b6ff | ||
|
|
efba5d9f14 | ||
|
|
84e768f41a | ||
|
|
f2d5b2d8e4 | ||
|
|
9f1ea75504 | ||
|
|
579a4b7353 | ||
|
|
b6a839a3c6 | ||
|
|
dbbaa44f92 | ||
|
|
9c9cb91593 | ||
|
|
e665877737 | ||
|
|
d54f857929 | ||
|
|
b78cea3caf | ||
|
|
5b6d369e65 | ||
|
|
61c8cf13f7 | ||
|
|
f0d10389d0 | ||
|
|
61562e59f0 | ||
|
|
78a0d7916c | ||
|
|
d75e8381b6 | ||
|
|
343a5ea260 | ||
|
|
203366c662 | ||
|
|
93a13e5eb9 | ||
|
|
a7db793c95 | ||
|
|
55576a7c84 | ||
|
|
ccc72be4b9 | ||
|
|
14e31d4679 | ||
|
|
c78ecbef31 | ||
|
|
7d5bee80b8 | ||
|
|
bed46a58d8 | ||
|
|
5d00618c53 | ||
|
|
9fb43ff2d3 | ||
|
|
71ca2253c9 | ||
|
|
e6d9474df1 | ||
|
|
2e57098ad9 | ||
|
|
5d2465d1ea | ||
|
|
ea30e3f37a | ||
|
|
90f97e846f | ||
|
|
ac6b9ef9eb | ||
|
|
a311ec41c3 | ||
|
|
fb41713930 | ||
|
|
bd207b8d88 | ||
|
|
47f2bfdd23 | ||
|
|
ca90b0bc75 | ||
|
|
785194209b | ||
|
|
ff25847b02 | ||
|
|
a807071ed2 | ||
|
|
fb316e4d5a | ||
|
|
4bf66d9571 | ||
|
|
3f998bdb88 | ||
|
|
bd6d54ac33 | ||
|
|
349e8f60df | ||
|
|
66f49b97e5 | ||
|
|
25825685c3 | ||
|
|
7e1b9b4cee | ||
|
|
22c755b02a | ||
|
|
01cbe69eb4 | ||
|
|
604420f07a | ||
|
|
7e29e6c25c | ||
|
|
9e28e10e5f | ||
|
|
c56f60d4ef | ||
|
|
33520e8fd3 | ||
|
|
e3baf69db5 | ||
|
|
877fcc2740 | ||
|
|
b0de9c026f | ||
|
|
23d956ba15 | ||
|
|
afa49bf1f1 | ||
|
|
85b3606eca | ||
|
|
ba31dd0563 | ||
|
|
1da976ecb6 | ||
|
|
bd12b0a594 | ||
|
|
78f31fb9a3 | ||
|
|
89dc3bf671 | ||
|
|
a0e46a05e5 | ||
|
|
8bc86c8567 | ||
|
|
497530d6a9 | ||
|
|
b9330a9674 | ||
|
|
ce399e4153 | ||
|
|
2334bd846a | ||
|
|
737dd41ff1 | ||
|
|
80a4b3ab5e | ||
|
|
3ea1e279c6 | ||
|
|
6d26634b1b | ||
|
|
b673e22cb7 | ||
|
|
e0b1334d2d | ||
|
|
385c5d9e9e | ||
|
|
6b7536b95c | ||
|
|
23e991722c | ||
|
|
f702a819fa | ||
|
|
d92a883f8a | ||
|
|
b7b9835bf9 | ||
|
|
2817ca1d3f | ||
|
|
950967f101 | ||
|
|
b6b91297ae | ||
|
|
781d25de08 | ||
|
|
14907551d7 | ||
|
|
219fd3009f | ||
|
|
5a165add2c | ||
|
|
b57ea2d628 | ||
|
|
27c4ec759c | ||
|
|
5f1a7f04f1 | ||
|
|
0ed913a114 | ||
|
|
4382351569 | ||
|
|
dc1cbfcf4a | ||
|
|
19fed894d9 | ||
|
|
aaca62f461 | ||
|
|
a2f1ecc6b9 | ||
|
|
568f2dea8a | ||
|
|
84a194db80 | ||
|
|
c3d69d74c2 | ||
|
|
33b79edd18 | ||
|
|
2460278cd9 | ||
|
|
60734cbb03 | ||
|
|
10fa397013 | ||
|
|
20e4da5bb5 | ||
|
|
e3b2552d8d | ||
|
|
ae7d8fb22e | ||
|
|
911c6acad2 | ||
|
|
8c9f4468af | ||
|
|
9b5b324fe8 | ||
|
|
6a038c89ca | ||
|
|
fc0226d4cb | ||
|
|
503a4bac89 | ||
|
|
57c987da88 | ||
|
|
8b862da084 | ||
|
|
f5a43c1cfe | ||
|
|
114def1c11 | ||
|
|
108ad5c7b5 | ||
|
|
ae01e066ca | ||
|
|
2fd9c5fcc7 | ||
|
|
a47a7e9767 | ||
|
|
f58c433fa4 | ||
|
|
c5cd39becc | ||
|
|
8f6d4fc03e | ||
|
|
51841fc608 | ||
|
|
a869467efb | ||
|
|
a2515119b7 | ||
|
|
f62a3175f7 | ||
|
|
08a1f1e5e2 | ||
|
|
e428002898 | ||
|
|
28d97d2221 | ||
|
|
41924a438c | ||
|
|
7c34b214af | ||
|
|
2dd777d74a | ||
|
|
c83d03e775 | ||
|
|
4ccad3bc3c | ||
|
|
d6a69ea157 | ||
|
|
7b75f40e28 | ||
|
|
fa3a199e78 | ||
|
|
87fc08b06e | ||
|
|
89d237e129 | ||
|
|
239733a33e | ||
|
|
229489271d | ||
|
|
1249263b94 | ||
|
|
e643e43828 | ||
|
|
64340036a3 | ||
|
|
cf8727a809 | ||
|
|
695db084e2 | ||
|
|
5068c13c37 | ||
|
|
43604b951d | ||
|
|
9a61f57f9e | ||
|
|
653ce1c511 | ||
|
|
9570e0fd18 | ||
|
|
f25839c4b5 | ||
|
|
d55cfb3737 | ||
|
|
c3ae936caa | ||
|
|
8910e88563 | ||
|
|
6b43583cad | ||
|
|
2f571c6eda | ||
|
|
296a8246ad | ||
|
|
34f0ec4980 | ||
|
|
cf612ce11e | ||
|
|
44420919f6 | ||
|
|
dd9b8f7a63 | ||
|
|
7ba81007a8 | ||
|
|
2dc357e55b | ||
|
|
cb7bb90eb6 | ||
|
|
cbc5521958 | ||
|
|
fbdb7b6c01 | ||
|
|
ce7422a506 | ||
|
|
2b987bdc3d | ||
|
|
8d8df2ab81 | ||
|
|
463b054a0a | ||
|
|
74d883a443 | ||
|
|
36d34b7d1d | ||
|
|
d4dc81a932 | ||
|
|
e3a37d6ad0 | ||
|
|
86ba48c415 | ||
|
|
36278517dd | ||
|
|
fdaf98499f | ||
|
|
dbd51b7fc8 | ||
|
|
8ccfb9eea4 | ||
|
|
d488e9ab40 | ||
|
|
189d1eae11 | ||
|
|
60f430ced8 | ||
|
|
3b15c4b597 | ||
|
|
116f5dc494 | ||
|
|
33e72927f9 | ||
|
|
657dd7c6f5 | ||
|
|
eda8dba8bc | ||
|
|
b31c37951f | ||
|
|
12e63b1dcb | ||
|
|
8029c235e4 | ||
|
|
d310f374b4 | ||
|
|
fedfdd2db9 | ||
|
|
ccf16b823b | ||
|
|
92b24cc9a6 | ||
|
|
fb4cab6a0e | ||
|
|
68fca1bd9f | ||
|
|
48731b227b | ||
|
|
39ade43a72 | ||
|
|
bc6629d400 | ||
|
|
871d0a56b9 | ||
|
|
6a88d21f10 | ||
|
|
ac96249f7d | ||
|
|
3858475c5c | ||
|
|
0f47212477 | ||
|
|
1bc58284a0 | ||
|
|
bd63c356a6 | ||
|
|
420f4a6c66 | ||
|
|
6292cc4130 | ||
|
|
3e59898b09 | ||
|
|
2fc84d249c | ||
|
|
e4ba4cbb2c | ||
|
|
44eb8d17f2 | ||
|
|
7db1d12aed | ||
|
|
01925fc134 | ||
|
|
86fab2c24e | ||
|
|
dd47f3841c | ||
|
|
7d29f3d7ea | ||
|
|
864a3c3cc7 | ||
|
|
7451a713e5 | ||
|
|
bc44cbac46 | ||
|
|
2a185c8c9e | ||
|
|
60fcc58e30 | ||
|
|
d111694185 | ||
|
|
e94ae9b2b7 | ||
|
|
3fd7768e28 | ||
|
|
d90a5db1c4 | ||
|
|
393d32bcd9 | ||
|
|
a9bfa69e67 | ||
|
|
749bcce438 | ||
|
|
f81bf8576a | ||
|
|
52b1fce8c1 | ||
|
|
7175f04609 | ||
|
|
3f397a87a2 | ||
|
|
f50debc5f7 | ||
|
|
a09016835c | ||
|
|
7ec9add043 | ||
|
|
b7985f4a6d | ||
|
|
9377a4ead3 | ||
|
|
f0801c2774 | ||
|
|
f281268efe | ||
|
|
d4b0462a5a | ||
|
|
e31ba2c5a5 | ||
|
|
b3e6971ea8 | ||
|
|
b5b4b41b99 | ||
|
|
fa84291551 | ||
|
|
d49bb5a655 | ||
|
|
f75e08c1bb | ||
|
|
eecf6d8f8a | ||
|
|
28acf1174a | ||
|
|
490b685f3a | ||
|
|
a5d4b7d54a | ||
|
|
440cae9c6d | ||
|
|
519ce1dc96 | ||
|
|
76604da31b | ||
|
|
e42d765f5c | ||
|
|
96467b9468 | ||
|
|
be687f8ded | ||
|
|
c0d6a4eb47 | ||
|
|
d0770517dc | ||
|
|
f7328e7c60 | ||
|
|
462d02efd5 | ||
|
|
9d3862b215 | ||
|
|
ea1d06448b | ||
|
|
f7b8159d30 | ||
|
|
0044c01ed0 | ||
|
|
d4fc74e601 | ||
|
|
f6cd60ec61 | ||
|
|
94a5dfedf6 | ||
|
|
3525e434a4 | ||
|
|
632fc0bdfa | ||
|
|
ae48f46c9f |
+3
-1
@@ -3,6 +3,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules.broken-by-path
|
||||
.env.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
+9
-5
@@ -1,9 +1,13 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。
|
||||
# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
# 海外业务浏览器请求使用独立代理,代理会将 /overseas-api 重写为海外 Gateway 的 /api。
|
||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
||||
# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。
|
||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
||||
# 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=
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
!.env.example
|
||||
|
||||
.playwright-cli/
|
||||
.idea/
|
||||
.impeccable/
|
||||
.codex-temp/
|
||||
.codex-tmp/
|
||||
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-9
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/admin-web.iml" filepath="$PROJECT_DIR$/.idea/admin-web.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -3,9 +3,12 @@
|
||||
## 快速读取顺序
|
||||
|
||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
||||
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.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`。
|
||||
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` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
|
||||
|
||||
## 项目定位与技术基线
|
||||
|
||||
@@ -28,65 +31,75 @@
|
||||
|
||||
- 依赖方向为 `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`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
||||
- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
||||
- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
||||
- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 登录、会话与统一 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;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `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/inbounds/measurement/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算 |
|
||||
| 财务与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;柜费入口 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;预装详情 `pages/PreloadPlanDetailView.vue`,货柜详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 创建柜次时自动生成唯一预装计划,加入入仓码即同步进入该计划,页面不提供创建计划或推送预装操作。预装详情先调用 `/loading-plans/{planId}/lifecycle`。订舱关联和预装确认允许真实集装箱号为空;首次补录可在 `EDITING/LOADING` 进行,PDA 最终完成前必须补齐;已有柜号纠正只允许 `EDITING` 且任务未领取、无物理作业事实。整柜报关逐资料完成,全买单柜使用 `complete-all-buy` |
|
||||
| 入库扣货、解除与凭证历史 | `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`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 三期订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印只对 `RELEASED` 开放。`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` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `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-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
|
||||
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
||||
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/四期/船节点标准化契约.md` |
|
||||
| 海外接柜、入库、库存与库内服务 | `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 与原子推进约束见模块指引 |
|
||||
|
||||
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
|
||||
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
|
||||
|
||||
## 四期海外模块内部快速索引
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 尾程订单与柜级入仓计划进度 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端在分页前排除全部入仓码均已有有效独立计划的柜,只按冻结 `plannedArrivalAt` 升序、空值最后返回仍有待生成计划入仓码的柜。卡片只展示服务端计划统计,详情弹框返回全柜订单且不受列表分页截断,不得在浏览器拼柜或自行推导就绪。整柜生成调用 `/inbound-plans/convert-container`,每票返回独立计划且整批原子;转仓候选只取 `modules/iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档,并提交 ID/编码/名称快照。业务 API `modules/overseas-warehouse/api/lastMileOrders.ts`,Backend `lastleg-service/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
||||
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
|
||||
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
|
||||
| 海外采购 | `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/` |
|
||||
|
||||
- 三个海外模块统一使用 `shared/api/overseasHttp.ts`;页面是否已接真实接口先查对应 `api/` 和挂载测试,不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替当前代码与服务端契约。
|
||||
| 司机档案、驾驶证、到期提醒与资格预览 | `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` |
|
||||
|
||||
## 业务、安全与共享装配
|
||||
|
||||
- 当前只有平台、客户两级;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` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||
@@ -95,10 +108,13 @@
|
||||
## 开发验收身份
|
||||
|
||||
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
|
||||
- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。
|
||||
- 视觉验收由人工操作真实页面并截图,AI 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
|
||||
|
||||
## 命令与质量门禁
|
||||
|
||||
- 国内仓、客户管理密度默认值统一维护在 `src/styles/adminDensityTokens.css`,通用按钮/页签/描述表/弹框规则统一维护在 `adminDensity.css`;页面只选择公共类与语义参数、保留业务布局,不复制通用外观或在页面重新定义 `--admin-*` 默认值。修改前沿实际引用读取模块 CSS,接管后删除冲突旧声明。
|
||||
- 修改密度样式或接入消费者时更新并运行 `src/styles/adminDensity.test.ts` 维护合同;不得为通过检查删除权限、业务提交或错误处理断言。未接入菜单通过显式密度类隔离。
|
||||
|
||||
在 `apps/admin-web` 执行:
|
||||
|
||||
```bash
|
||||
|
||||
Vendored
+39
-84
@@ -1,107 +1,62 @@
|
||||
pipeline {
|
||||
agent {
|
||||
label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}"
|
||||
}
|
||||
|
||||
environment {
|
||||
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
|
||||
DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'
|
||||
REGISTRY_HOST = '172.31.198.7:10082'
|
||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||
NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
|
||||
NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
|
||||
}
|
||||
|
||||
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 {
|
||||
script {
|
||||
env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false'
|
||||
env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
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 'git log -1 --oneline'
|
||||
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('构建并部署测试前端') {
|
||||
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
||||
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
docker compose -f docker-compose.test.yml build --pull=false
|
||||
docker compose -f docker-compose.test.yml up -d --remove-orphans
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并部署开发前端') {
|
||||
when { expression { env.IS_DEVELOPMENT == 'true' } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
image="admin-web-development:${SOURCE_TAG}"
|
||||
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||
--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \
|
||||
-t "$image" .
|
||||
export IMAGE_TAG="$SOURCE_TAG"
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署检查') {
|
||||
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
||||
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 '''
|
||||
if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
|
||||
docker logs --tail=100 admin-web-development || true
|
||||
else
|
||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
||||
fi
|
||||
'''
|
||||
script {
|
||||
if (!env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
|
||||
|
||||
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
|
||||
|
||||
@@ -46,18 +54,18 @@ pnpm test
|
||||
```dotenv
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
||||
VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
```
|
||||
|
||||
国内业务继续使用 `http` 和 `/api` 代理;四期海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。
|
||||
全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。
|
||||
|
||||
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
|
||||
本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。
|
||||
|
||||
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。
|
||||
唯一部署入口是 `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 端口。
|
||||
|
||||
认证请求使用 `/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` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||
容器启动通过 `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 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
|
||||
|
||||
@@ -66,3 +74,7 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
真实接口错误会保留后端返回的业务错误码、`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 真机扫码验收。
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
services:
|
||||
admin-web:
|
||||
image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: admin-web-development
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9001:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"
|
||||
API_UPSTREAM: "http://firstleg-development-gateway:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
|
||||
networks:
|
||||
- development-backend
|
||||
- development-overseas-backend
|
||||
|
||||
networks:
|
||||
development-backend:
|
||||
external: true
|
||||
name: firstleg-development-network
|
||||
development-overseas-backend:
|
||||
external: true
|
||||
name: firstleg-development-overseas-network
|
||||
@@ -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
|
||||
+17
-7
@@ -1,3 +1,4 @@
|
||||
name: firstleg-test-admin-web
|
||||
services:
|
||||
admin-web:
|
||||
build:
|
||||
@@ -6,14 +7,23 @@ services:
|
||||
args:
|
||||
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: admin-web-test:local
|
||||
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"
|
||||
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||
environment:
|
||||
# Runtime URL used by the secure customer-workbench handoff. Each
|
||||
# environment may override it without rebuilding the Admin image.
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"
|
||||
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
|
||||
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
|
||||
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}"
|
||||
|
||||
@@ -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;
|
||||
@@ -30,11 +45,11 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
||||
location /overseas-api/ {
|
||||
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。
|
||||
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
|
||||
location /openapi/ {
|
||||
client_max_body_size 55m;
|
||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -42,6 +57,18 @@ server {
|
||||
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 {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs",
|
||||
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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
|
||||
+6
-18
@@ -1,4 +1,3 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function option(name) {
|
||||
@@ -15,22 +14,13 @@ function normalizeOrigin(value, name) {
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function acceptancePassword() {
|
||||
if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
|
||||
const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env'
|
||||
if (!existsSync(file)) return ''
|
||||
const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m)
|
||||
return match?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
const environment = option('--environment')
|
||||
if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development')
|
||||
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway')
|
||||
const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway')
|
||||
const password = acceptancePassword()
|
||||
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件')
|
||||
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-development.config.ts'], {
|
||||
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
@@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p
|
||||
ADMIN_PHASE4_LIVE: '1',
|
||||
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
|
||||
ADMIN_ACCEPTANCE_PASSWORD: password,
|
||||
ADMIN_DEVELOPMENT_GATEWAY: gateway,
|
||||
ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway,
|
||||
ADMIN_TEST_GATEWAY: gateway,
|
||||
VITE_API_BASE_URL: `${gateway}/api`,
|
||||
VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`,
|
||||
},
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
+6
-6
@@ -2,14 +2,14 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8')
|
||||
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('development runner requires an explicit development environment and gateways', () => {
|
||||
test('test runner requires an explicit test environment and unified gateway', () => {
|
||||
assert.match(runner, /--environment/)
|
||||
assert.match(runner, /environment !== 'development'/)
|
||||
assert.match(runner, /environment !== 'test'/)
|
||||
assert.match(runner, /--gateway/)
|
||||
assert.match(runner, /--overseas-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*['"][^'"]+['"]/)
|
||||
@@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa
|
||||
|
||||
test('live suite uses production auth clients and domain adapters', () => {
|
||||
assert.match(suite, /useAuthStore/)
|
||||
assert.match(suite, /clearOverseasAccessToken/)
|
||||
assert.match(suite, /refreshAuthenticatedSession/)
|
||||
assert.match(suite, /createQuote/)
|
||||
assert.match(suite, /createUsTruckOrder/)
|
||||
assert.match(suite, /createLocalLooseboardOrder/)
|
||||
+128
-18
@@ -8,7 +8,7 @@ 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 { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { refreshAuthenticatedSession } from '@/shared/api/http'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
|
||||
import {
|
||||
@@ -37,8 +37,15 @@ import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersV
|
||||
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 = 'domestic' | 'overseas'
|
||||
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 }
|
||||
|
||||
@@ -143,7 +150,7 @@ async function masterDataLifecycle() {
|
||||
}
|
||||
|
||||
async function cleanupTransport(path: string, customerId: string, version: number) {
|
||||
await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
|
||||
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')
|
||||
@@ -238,32 +245,135 @@ async function mountPage(component: Parameters<typeof mount>[0], method: string,
|
||||
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() {
|
||||
await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
|
||||
await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
|
||||
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
|
||||
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
|
||||
await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
|
||||
await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
|
||||
await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
|
||||
await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
|
||||
await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
|
||||
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 development frontend acceptance', () => {
|
||||
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, 'domestic'); installTracingAdapter(overseasHttp, 'overseas')
|
||||
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()
|
||||
clearOverseasAccessToken()
|
||||
await refreshAuthenticatedSession()
|
||||
await listVehicles({ page: 1, size: 1 })
|
||||
expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true)
|
||||
expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true)
|
||||
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()
|
||||
@@ -278,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', (
|
||||
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-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
|
||||
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}`)
|
||||
})
|
||||
@@ -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"
|
||||
+5
-5
@@ -13,15 +13,15 @@
|
||||
- 服务端字段变化先核对 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)源码复用。
|
||||
|
||||
## 契约与验证
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => {
|
||||
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'"))
|
||||
.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+/)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ 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)', '<Eye :size="15" />详情', '@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) {
|
||||
|
||||
@@ -60,12 +60,15 @@ describe('AdminSidebarMenu', () => {
|
||||
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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -57,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
|
||||
@@ -76,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 {
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('admin router access metadata', () => {
|
||||
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/:containerId/smart-loading')
|
||||
expect(detail?.path).toBe('/container-loading/domestic/:containerId/smart-loading')
|
||||
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import AppLayout from '@/app/layouts/AppLayout.vue'
|
||||
@@ -30,6 +31,8 @@ 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(),
|
||||
@@ -74,6 +77,8 @@ const router = createRouter({
|
||||
...masterDataRoutes,
|
||||
...downloadRoutes,
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
@@ -99,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)
|
||||
|
||||
+104
-23
@@ -60,13 +60,11 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
|
||||
expiresIn: 1800,
|
||||
refreshToken,
|
||||
refreshExpiresIn: 604800,
|
||||
overseasAccessToken: 'overseas-token',
|
||||
overseasExpiresIn: 900,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function domesticOnlyLoginResponse() {
|
||||
function unifiedLoginResponse() {
|
||||
return {
|
||||
code: '200',
|
||||
message: 'success',
|
||||
@@ -91,8 +89,8 @@ describe('admin auth session', () => {
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
||||
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
||||
it('accepts the unified access and refresh token response', async () => {
|
||||
mocks.post.mockResolvedValue(unifiedLoginResponse())
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -116,8 +114,6 @@ describe('admin auth session', () => {
|
||||
expiresIn: '7200',
|
||||
refreshToken: 'refresh-serialized',
|
||||
refreshExpiresIn: '604800',
|
||||
overseasAccessToken: null,
|
||||
overseasExpiresIn: '0',
|
||||
},
|
||||
})
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
@@ -184,29 +180,17 @@ 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 () => {
|
||||
@@ -257,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()
|
||||
})
|
||||
})
|
||||
|
||||
+30
-22
@@ -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,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
type ApiEnvelope,
|
||||
} from '@/shared/api/http'
|
||||
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||
@@ -24,8 +24,6 @@ interface LoginResponse {
|
||||
expiresIn: number | string
|
||||
refreshToken: string
|
||||
refreshExpiresIn: number | string
|
||||
overseasAccessToken?: string | null
|
||||
overseasExpiresIn?: number | string
|
||||
}
|
||||
|
||||
class ExpiredAccessTokenError extends Error {}
|
||||
@@ -100,10 +98,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
async function applySession(result: LoginResponse, expectedGeneration: number) {
|
||||
const session = sessionFromToken(result.accessToken)
|
||||
ensureCurrentGeneration(expectedGeneration)
|
||||
replaceOverseasAccessToken(
|
||||
result.overseasAccessToken ?? null,
|
||||
parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'),
|
||||
)
|
||||
token.value = result.accessToken
|
||||
refreshToken.value = result.refreshToken
|
||||
user.value = session.user
|
||||
@@ -123,7 +117,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
|
||||
function clearSession() {
|
||||
sessionGeneration += 1
|
||||
clearOverseasAccessToken()
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
user.value = null
|
||||
@@ -151,7 +144,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
try {
|
||||
const session = sessionFromToken(storedToken)
|
||||
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
||||
if (token.value !== storedToken) clearOverseasAccessToken()
|
||||
token.value = storedToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
user.value = session.user
|
||||
@@ -166,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) {
|
||||
@@ -224,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()
|
||||
@@ -258,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,75 +8,30 @@ 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 === 'customer-service')?.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([
|
||||
'产品信息',
|
||||
'费用项目',
|
||||
'汇率管理',
|
||||
'仓库管理',
|
||||
'海外仓报价',
|
||||
'船期价格表',
|
||||
'车辆列表',
|
||||
'下载中心',
|
||||
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()
|
||||
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
|
||||
'用户管理',
|
||||
'角色权限',
|
||||
'租户管理',
|
||||
'品牌设置',
|
||||
'菜单权限',
|
||||
])
|
||||
})
|
||||
|
||||
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
|
||||
@@ -92,7 +47,8 @@ describe('adminNavigation', () => {
|
||||
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/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')
|
||||
@@ -168,7 +124,6 @@ describe('adminNavigation', () => {
|
||||
'/overseas-warehouse/outbounds',
|
||||
'/overseas-warehouse/delivery-orders',
|
||||
'/overseas-warehouse/exception-orders',
|
||||
'/overseas-warehouse/customer-visits',
|
||||
'/overseas-warehouse/relabel-orders',
|
||||
'/overseas-warehouse/pallets',
|
||||
'/overseas-warehouse/inventory',
|
||||
@@ -208,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')
|
||||
@@ -233,26 +186,28 @@ describe('adminNavigation', () => {
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
||||
})
|
||||
|
||||
it('shows the public supplier list only with the list 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(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||
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 container list and downstream lists without a standalone smart-loading menu', () => {
|
||||
it('shows split container workbenches without a standalone smart-loading menu', () => {
|
||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:preload:list',
|
||||
'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
]))
|
||||
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||
'/dashboard',
|
||||
'/loading-plans',
|
||||
'/container-loading',
|
||||
'/container-loading/domestic',
|
||||
'/loading-lists',
|
||||
'/loading-plans',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ContactRound,
|
||||
ClipboardList,
|
||||
CircleDollarSign,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from '@lucide/vue'
|
||||
import {
|
||||
PrototypeContainerIcon,
|
||||
PrototypeCustomerServiceIcon,
|
||||
PrototypeCustomerIcon,
|
||||
PrototypeDashboardIcon,
|
||||
PrototypeDeliveryIcon,
|
||||
@@ -25,8 +27,8 @@ import {
|
||||
PrototypeOverseasBusinessIcon,
|
||||
PrototypeOverseasWarehouseIcon,
|
||||
PrototypeSalesIcon,
|
||||
PrototypeSupplierIcon,
|
||||
PrototypeSystemIcon,
|
||||
PrototypeVehicleIcon,
|
||||
} from './prototypeMenuIcons'
|
||||
|
||||
export interface AdminNavigationNode {
|
||||
@@ -61,16 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
},
|
||||
{
|
||||
key: 'domestic-warehouse',
|
||||
title: '国内仓',
|
||||
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: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
|
||||
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
|
||||
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -85,16 +87,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'supplier-center',
|
||||
title: '供应商中心',
|
||||
icon: PrototypeSupplierIcon,
|
||||
children: [
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ 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: '货柜管理',
|
||||
@@ -102,8 +94,41 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
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: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container: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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -116,28 +141,13 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
|
||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
||||
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
|
||||
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'customer-service',
|
||||
title: '客户服务',
|
||||
icon: ContactRound,
|
||||
icon: PrototypeCustomerServiceIcon,
|
||||
children: [
|
||||
{ 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' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -168,14 +178,17 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
title: '通用',
|
||||
icon: PrototypeGeneralIcon,
|
||||
children: [
|
||||
{ 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: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,6 +201,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO
|
||||
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' }),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearNavigationRecoveryAttempt,
|
||||
createNavigationRecoveryHandler,
|
||||
installAssetRecoveryListeners,
|
||||
isStaleRouteAssetError,
|
||||
recoverNavigationError,
|
||||
reloadNavigationTarget,
|
||||
type NavigationRecoveryDependencies,
|
||||
} from './navigationRecovery'
|
||||
|
||||
@@ -29,11 +31,30 @@ function dependencies(overrides: Partial<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)
|
||||
})
|
||||
@@ -58,7 +79,7 @@ describe('navigationRecovery', () => {
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
|
||||
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')
|
||||
@@ -66,9 +87,12 @@ describe('navigationRecovery', () => {
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
||||
clearNavigationRecoveryAttempt(storage)
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
expect(deps.navigate).toHaveBeenCalledTimes(2)
|
||||
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 () => {
|
||||
@@ -77,4 +101,83 @@ describe('navigationRecovery', () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [
|
||||
/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' | 'removeItem'>
|
||||
storage: Pick<Storage, 'getItem' | 'setItem'>
|
||||
now: () => number
|
||||
hasUnsavedWork: () => boolean
|
||||
confirmDiscard: () => Promise<boolean>
|
||||
@@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) {
|
||||
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
||||
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
|
||||
return Boolean(parsed
|
||||
&& parsed.target === target
|
||||
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverNavigationError(
|
||||
error: unknown,
|
||||
target: string,
|
||||
@@ -54,21 +44,96 @@ export async function recoverNavigationError(
|
||||
dependencies.showError('页面切换失败,请重试')
|
||||
return false
|
||||
}
|
||||
if (recentAttempt(dependencies, target)) {
|
||||
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||
// 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
|
||||
|
||||
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||
target,
|
||||
attemptedAt: dependencies.now(),
|
||||
} satisfies RecoveryAttempt))
|
||||
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 clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
|
||||
storage.removeItem(RECOVERY_STORAGE_KEY)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +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 testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
|
||||
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
|
||||
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
|
||||
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), '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 50 MiB files plus multipart overhead through both bounded API proxies', () => {
|
||||
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(2)
|
||||
expect(config).not.toContain('client_max_body_size 12m;')
|
||||
expect(config).not.toContain('client_max_body_size 0;')
|
||||
})
|
||||
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;/)
|
||||
})
|
||||
|
||||
it('proxies overseas API requests to the gateway API prefix', () => {
|
||||
expect(config).toContain('location /overseas-api/')
|
||||
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
|
||||
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
|
||||
})
|
||||
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:)/)
|
||||
})
|
||||
|
||||
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
||||
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
|
||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
|
||||
expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"')
|
||||
expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
|
||||
expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
|
||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
|
||||
expect(config).toContain('proxy_set_header Host $proxy_host;')
|
||||
})
|
||||
|
||||
it('runs each deployment on its target node without controller-local SSH files', () => {
|
||||
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'")
|
||||
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
|
||||
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
|
||||
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
|
||||
expect(jenkinsfile).not.toContain('docker save')
|
||||
expect(jenkinsfile).not.toMatch(/\bssh\b/)
|
||||
})
|
||||
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/)
|
||||
})
|
||||
|
||||
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
|
||||
expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine')
|
||||
expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine')
|
||||
expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build')
|
||||
expect(dockerfile).toContain('FROM ${NGINX_IMAGE}')
|
||||
expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'")
|
||||
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
|
||||
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
|
||||
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
|
||||
expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
|
||||
})
|
||||
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/)
|
||||
})
|
||||
|
||||
+39
-25
@@ -1,3 +1,4 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, createApp } from 'vue'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -7,7 +8,7 @@ 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 { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
|
||||
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'
|
||||
@@ -21,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
|
||||
@@ -38,31 +37,41 @@ 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 recoverNavigationError(error, to.fullPath, {
|
||||
storage: sessionStorage,
|
||||
now: Date.now,
|
||||
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||
confirmDiscard: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||
'重新加载页面?',
|
||||
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
discardUnsavedWork: () => unsaved.clear(),
|
||||
navigate: (target) => window.location.assign(target),
|
||||
showError: (message) => ElMessage.error(message),
|
||||
})
|
||||
void recoverAssetError(error, to.fullPath)
|
||||
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||
})
|
||||
router.afterEach((_to, _from, failure) => {
|
||||
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
|
||||
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
|
||||
@@ -71,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')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref, watch } from 'vue'
|
||||
import { CircleCheck, CircleX, Eye, ScrollText } from '@lucide/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'
|
||||
@@ -125,14 +126,14 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="customer-audit-panel">
|
||||
<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 link type="primary" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)"><Eye :size="15" />详情</el-button></template>
|
||||
<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>
|
||||
@@ -144,7 +145,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
|
||||
<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'">
|
||||
@@ -172,10 +173,13 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:16px; }.module-empty { display:flex; min-height:260px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:13px; }.module-empty p { margin:0; color:var(--cc-muted); font-size: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: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:16px 18px; border:1px solid #d1fadf; border-radius:10px; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; 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:12px; }.hero-copy h3 { margin:3px 0 4px; color:#101828; font-size:17px; }.hero-copy p { margin:0; color:#475467; font-size:13px; line-height:1.55; }
|
||||
.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:16px; overflow:hidden; border:1px solid #e4e7ec; border-radius:10px; background:#fff; }.audit-detail-grid > div { display:flex; min-width:0; min-height:82px; flex-direction:column; justify-content:center; gap:5px; padding:13px 16px; 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:12px; }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:14px; font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:11px; }.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:16px; }
|
||||
.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-top:18px; }.snapshot-grid section { min-width:0; padding:15px; border:1px solid #e4e7ec; border-radius:10px; background:#fff; }.snapshot-grid h3 { margin:0; color:#344054; font-size:14px; }.snapshot-grid p { margin:4px 0 10px; color:#98a2b3; font-size:11px; }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:#f8fafc; color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
|
||||
|
||||
|
||||
.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>
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -1,27 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { completeAllBuyContainerCustoms, downloadContainerCustomsCompletion, downloadContainerCustomsDocument, getContainerCustoms, startContainerCustoms, uploadContainerCustomsCompletion } from './platformContainerCustoms'
|
||||
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', version: '3', taxRefundCount: 1, coveredTaxRefundCount: 1,
|
||||
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, completion: null }],
|
||||
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)
|
||||
@@ -31,14 +55,21 @@ describe('platform container customs API', () => {
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
})
|
||||
|
||||
it('uploads one completion file with final-confirmation query parameters', async () => {
|
||||
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 uploadContainerCustomsCompletion('2092438843630391407', completion, 0, true)
|
||||
expect(post.mock.calls[0]?.[0]).toBe('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/upload')
|
||||
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, finalConfirmation: true }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
|
||||
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 () => {
|
||||
@@ -48,11 +79,11 @@ describe('platform container customs API', () => {
|
||||
const documentId = '2092438843630391407'
|
||||
|
||||
await expect(downloadContainerCustomsDocument(documentId)).resolves.toBe(source)
|
||||
await expect(downloadContainerCustomsCompletion(documentId)).resolves.toBe(completion)
|
||||
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}/completion/download`, { responseType: 'blob' }],
|
||||
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,36 @@ export interface ContainerCustomsFile {
|
||||
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
|
||||
@@ -30,7 +60,12 @@ export interface ContainerCustomsDocument {
|
||||
inboundIds: string[]
|
||||
inboundCodes: string[]
|
||||
frozen: boolean
|
||||
completion: ContainerCustomsFile | null
|
||||
receipt: ContainerCustomsFile | null
|
||||
receiptStatus?: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
|
||||
receiptRejectReason?: string | null
|
||||
finalDocument: ContainerCustomsFile | null
|
||||
receiptHistory?: ContainerCustomsReceiptVersion[]
|
||||
confirmationHistory: ContainerCustomsConfirmationHistory[]
|
||||
}
|
||||
|
||||
export interface ContainerCustomsView {
|
||||
@@ -41,13 +76,53 @@ export interface ContainerCustomsView {
|
||||
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'> {
|
||||
@@ -56,7 +131,7 @@ interface RawContainerCustomsView extends Omit<ContainerCustomsView, 'aggregateI
|
||||
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' | 'completion'> & { documentId: string | number; tenantId: string | number; source: ContainerCustomsFile; completion: ContainerCustomsFile | null }>
|
||||
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'
|
||||
@@ -69,24 +144,85 @@ function normalize(value: RawContainerCustomsView): ContainerCustomsView {
|
||||
...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) })),
|
||||
documents: value.documents.map((item) => ({ ...item, documentId: String(item.documentId), tenantId: String(item.tenantId), source: file(item.source)!, completion: file(item.completion) })),
|
||||
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>, ApiEnvelope<RawContainerCustomsView>>(`${basePath}/${encodeURIComponent(loadingPlanId)}`)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
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 downloadContainerCustomsCompletion(documentId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`, { responseType: 'blob' })
|
||||
export function downloadContainerCustomsReceipt(documentId: string) {
|
||||
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
async function action(loadingPlanId: string, name: 'start' | 'complete-all-buy', expectedVersion: number, remark?: string) {
|
||||
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 },
|
||||
@@ -99,18 +235,34 @@ export function startContainerCustoms(loadingPlanId: string, expectedVersion: nu
|
||||
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 async function uploadContainerCustomsCompletion(documentId: string, fileValue: File, expectedVersion: number, finalConfirmation: boolean) {
|
||||
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)}/completion/upload`,
|
||||
`${basePath}/documents/${encodeURIComponent(documentId)}/${kind}/upload`,
|
||||
body,
|
||||
{ params: { expectedVersion, finalConfirmation }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': operationId('container-customs-completion') } },
|
||||
{ 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)!, completion: file(result.completion) }
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
adjustSmartLoadingItem, assignPendingReviewSmartLoadingOrder, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
|
||||
listContainers, listSmartLoadingCandidates, listSmartLoadingWorkbenchContainers, removeAllUnpickedSmartLoadingItems, removeSmartLoadingGroup, removeSmartLoadingItem, removeSmartLoadingPallet,
|
||||
recommendSmartLoading,
|
||||
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
|
||||
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
getContainerOverview, updateContainerPickupCode, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
|
||||
getCustomerContainerTransitSummary,
|
||||
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
|
||||
updateContainer,
|
||||
@@ -17,7 +18,7 @@ vi.mock('@/shared/api/http', () => ({
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', 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: '76', maxWeightKg: '28000' }
|
||||
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',
|
||||
@@ -38,37 +39,66 @@ describe('platform container loading API', () => {
|
||||
})
|
||||
|
||||
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' } })
|
||||
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', pickupOrderDocumentStatus: 'MISSING', 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: 76 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { 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 })
|
||||
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', 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: 76, maxWeightKg: 28000, terminalTruckingCompanyId: 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)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
|
||||
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({ channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
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', { channelCode: 'NY_EXX', warehouseId: '8', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
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: { 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', coLoadGroupId: 9007199254740999n, groupNo: 'CLG-9007199254740999', memberCount: '3', groupTotalBoxes: '21', locked: false, blockReason: null, groupMembers: [] }], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
.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({ 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, coLoadGroupId: '9007199254740999', groupNo: 'CLG-9007199254740999', memberCount: 3, groupTotalBoxes: 21, locked: false, blockReason: null }], total: 1 })
|
||||
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)
|
||||
@@ -91,6 +121,47 @@ describe('platform container loading API', () => {
|
||||
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
@@ -111,11 +182,11 @@ describe('platform container loading API', () => {
|
||||
|
||||
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: '40HC', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
|
||||
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', orderStatuses: ['WAIT_SPLIT'], page: 1, size: 200 })
|
||||
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
|
||||
@@ -124,6 +195,8 @@ describe('platform container loading API', () => {
|
||||
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)
|
||||
@@ -201,6 +274,13 @@ describe('platform container loading API', () => {
|
||||
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'
|
||||
|
||||
@@ -25,6 +25,10 @@ import type {
|
||||
ContainerDocumentType,
|
||||
ContainerOverview,
|
||||
ContainerOverviewDocument,
|
||||
ContainerWorkbenchKind,
|
||||
ContainerWorkbenchRow,
|
||||
ContainerWorkbenchItem,
|
||||
ContainerWorkbenchPage,
|
||||
} from '../types'
|
||||
|
||||
const containerPath = '/platform/v1/containers'
|
||||
@@ -49,7 +53,7 @@ function overviewDocument(raw: ContainerOverviewDocument): ContainerOverviewDocu
|
||||
function overview(raw: ContainerOverview): ContainerOverview {
|
||||
return {
|
||||
...raw,
|
||||
container: { ...raw.container, id: id(raw.container.id), warehouseId: id(raw.container.warehouseId), version: numberValue(raw.container.version) },
|
||||
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),
|
||||
@@ -87,6 +91,7 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
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),
|
||||
@@ -95,12 +100,37 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function item(raw: SmartLoadingItem): SmartLoadingItem {
|
||||
return {
|
||||
...raw,
|
||||
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),
|
||||
@@ -129,14 +159,23 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
|
||||
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 getCustomerContainerTransitSummary(customerId: string): Promise<CustomerContainerTransitSummary> {
|
||||
@@ -151,9 +190,9 @@ export async function getCustomerContainerTransitSummary(customerId: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainer(containerId: string) {
|
||||
const response = await http.get<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`)
|
||||
return container(unwrapApiResponse(response))
|
||||
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) {
|
||||
@@ -161,6 +200,14 @@ export async function getContainerOverview(containerId: string) {
|
||||
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)
|
||||
@@ -171,7 +218,7 @@ export async function cancelContainerException(containerId: string, expectedVers
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, file: File, expectedVersion: number, operationReason?: string | null) {
|
||||
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))
|
||||
@@ -180,7 +227,7 @@ export async function uploadContainerDocument(containerId: string, documentType:
|
||||
return document(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function downloadContainerDocument(containerId: string, documentType: Extract<ContainerDocumentType, 'DELIVERY_ORDER' | 'PICKUP_ORDER'>, fileId: string) {
|
||||
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' })
|
||||
}
|
||||
|
||||
@@ -247,6 +294,7 @@ function smartLoadingQueryParams(query: SmartLoadingCandidateQuery, includeOrder
|
||||
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))
|
||||
@@ -266,7 +314,8 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
|
||||
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),
|
||||
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),
|
||||
@@ -275,6 +324,7 @@ export async function listSmartLoadingCandidates(query: SmartLoadingCandidateQue
|
||||
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),
|
||||
@@ -315,6 +365,14 @@ export async function assignSmartLoadingItem(planId: string, payload: { inboundI
|
||||
return plan(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
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 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`,
|
||||
@@ -330,8 +388,20 @@ export async function assignSmartLoadingGroup(planId: string, inboundId: string,
|
||||
)
|
||||
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 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))
|
||||
}
|
||||
|
||||
@@ -345,6 +415,14 @@ export async function removeSmartLoadingItem(planId: string, itemId: string, exp
|
||||
return plan(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
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)}`,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -2,24 +2,31 @@ 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 } from '../api/platformContainerCustoms'
|
||||
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(),
|
||||
downloadCompletion: vi.fn(),
|
||||
downloadReceipt: vi.fn(),
|
||||
downloadFinal: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/platformContainerCustoms', () => ({
|
||||
getContainerCustoms: api.get,
|
||||
startContainerCustoms: api.start,
|
||||
completeAllBuyContainerCustoms: api.completeAllBuy,
|
||||
uploadContainerCustomsCompletion: api.upload,
|
||||
uploadContainerCustomsReceipt: api.upload,
|
||||
downloadContainerCustomsDocument: api.downloadSource,
|
||||
downloadContainerCustomsCompletion: api.downloadCompletion,
|
||||
downloadContainerCustomsReceipt: api.downloadReceipt,
|
||||
downloadContainerCustomsFinal: api.downloadFinal,
|
||||
submitContainerCustomsToBroker: vi.fn(),
|
||||
sendContainerCustomsToBroker: api.sendToBroker,
|
||||
getDeclarationBatches: api.getDeclarationBatches,
|
||||
}))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
|
||||
@@ -28,14 +35,17 @@ 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, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1,
|
||||
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,
|
||||
completion: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关完成文件.pdf', fileHash: 'completion-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null,
|
||||
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,
|
||||
}
|
||||
@@ -58,51 +68,133 @@ function buttonTexts(wrapper: Awaited<ReturnType<typeof render>>) {
|
||||
describe('ContainerCustomsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getDeclarationBatches.mockResolvedValue([])
|
||||
api.downloadSource.mockResolvedValue(new Blob(['source']))
|
||||
api.downloadCompletion.mockResolvedValue(new Blob(['completion']))
|
||||
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()).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('上传报关行回执')
|
||||
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(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('报关完成文件.pdf')
|
||||
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('上传报关行回执')
|
||||
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('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关完成文件')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关完成文件')
|
||||
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
|
||||
expect(buttonTexts(wrapper)).not.toContain('提交')
|
||||
})
|
||||
|
||||
@@ -111,13 +203,16 @@ describe('ContainerCustomsPanel', () => {
|
||||
await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
|
||||
expect(api.downloadCompletion).not.toHaveBeenCalled()
|
||||
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[aria-label="下载报关完成文件 报关完成文件.pdf"]').trigger('click')
|
||||
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.downloadCompletion).toHaveBeenCalledWith('2092438843630391407')
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(2)
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -132,6 +227,6 @@ describe('ContainerCustomsPanel', () => {
|
||||
expect(wrapper.text()).toContain('CUSTOMS_FILE_404')
|
||||
expect(wrapper.text()).toContain('request-customs-download')
|
||||
expect(wrapper.text()).toContain('trace-customs-download')
|
||||
expect(api.downloadCompletion).not.toHaveBeenCalled()
|
||||
expect(api.downloadReceipt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -9,36 +9,67 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import {
|
||||
completeAllBuyContainerCustoms,
|
||||
downloadContainerCustomsCompletion,
|
||||
createDeclarationBatch,
|
||||
downloadContainerCustomsReceipt,
|
||||
downloadContainerCustomsFinal,
|
||||
downloadContainerCustomsDocument,
|
||||
getContainerCustoms,
|
||||
getDeclarationBatches,
|
||||
recordClearanceResult,
|
||||
resolveClearanceResult,
|
||||
submitDeclarationBatch,
|
||||
startContainerCustoms,
|
||||
uploadContainerCustomsCompletion,
|
||||
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'
|
||||
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 completionFiles = reactive<Record<string, File | null>>({})
|
||||
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?.taxRefundCount === 0)
|
||||
const missingCompletionCount = computed(() => customs.value?.documents.filter((item) => !item.completion).length || 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: '报关已完成',
|
||||
}
|
||||
@@ -46,9 +77,25 @@ const statusText: Record<ContainerCustomsStatus, string> = {
|
||||
function documentCustomers(document: ContainerCustomsDocument) {
|
||||
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
|
||||
}
|
||||
function selectCompletion(documentId: string, upload: UploadFile) {
|
||||
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
|
||||
completionFiles[documentId] = upload.raw || null
|
||||
finalFiles[documentId] = upload.raw || null
|
||||
actionError.value = null
|
||||
}
|
||||
|
||||
@@ -56,15 +103,80 @@ async function load() {
|
||||
if (!props.canList || !props.loadingPlanId) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try { customs.value = await getContainerCustoms(props.loadingPlanId) }
|
||||
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('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
|
||||
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('整柜报关已开始') }
|
||||
@@ -72,24 +184,31 @@ async function start() {
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
async function uploadCompletion(document: ContainerCustomsDocument) {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING') return
|
||||
const selected = completionFiles[document.documentId]
|
||||
if (!selected) { ElMessage.warning('请选择对应的报关完成文件'); return }
|
||||
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
|
||||
if (finalConfirmation) {
|
||||
try {
|
||||
await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
|
||||
} catch { return }
|
||||
}
|
||||
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 uploadContainerCustomsCompletion(document.documentId, selected, document.completion?.version || 0, finalConfirmation)
|
||||
completionFiles[document.documentId] = null
|
||||
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
|
||||
receiptFiles[document.documentId] = null
|
||||
await load()
|
||||
emit('refreshed')
|
||||
ElMessage.success(finalConfirmation ? '报关完成文件已上传,整柜报关已完成' : '报关完成文件已上传')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '报关完成文件上传失败') }
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -102,8 +221,8 @@ function saveBlob(blob: Blob, fileName: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadFile(document: ContainerCustomsDocument, kind: 'source' | 'completion') {
|
||||
const target = kind === 'source' ? document.source : document.completion
|
||||
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
|
||||
@@ -111,9 +230,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
try {
|
||||
const blob = kind === 'source'
|
||||
? await downloadContainerCustomsDocument(document.documentId)
|
||||
: await downloadContainerCustomsCompletion(document.documentId)
|
||||
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
|
||||
saveBlob(blob, target.fileName)
|
||||
ElMessage.success(`${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载已开始`)
|
||||
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
|
||||
} finally {
|
||||
@@ -121,9 +240,27 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
}
|
||||
}
|
||||
|
||||
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('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
@@ -151,14 +288,16 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="container-customs surface-panel" aria-label="整柜报关">
|
||||
<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.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ statusText[customs.status] }}</el-tag>
|
||||
<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.status === 'READY_TO_START'" type="primary" :loading="actionLoading" @click="start">开始报关</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>
|
||||
@@ -166,27 +305,58 @@ onMounted(load)
|
||||
<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">
|
||||
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
|
||||
<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"><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>
|
||||
<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"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
|
||||
<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"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(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 link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
|
||||
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.completion"> / 报关完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }}</td>
|
||||
<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="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : '报关已完成,待其他离仓条件' }}</el-tag></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:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:200px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.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:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
|
||||
.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>
|
||||
@@ -8,7 +8,7 @@ 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: '船一', version: 2 },
|
||||
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 }],
|
||||
@@ -47,13 +47,27 @@ describe('ContainerOverviewPanel', () => {
|
||||
expect(wrapper.text()).not.toContain('9007199254740999')
|
||||
})
|
||||
|
||||
it('renders three compact non-customs document rows with the operation column first', () => {
|
||||
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(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('操作员')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
|
||||
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
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'
|
||||
@@ -12,28 +13,34 @@ const props = defineProps<{
|
||||
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 资料' },
|
||||
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
|
||||
] as const
|
||||
|
||||
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
|
||||
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
||||
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) {
|
||||
@@ -113,24 +120,30 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overview-panel">
|
||||
<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" aria-label="货柜业务资料">
|
||||
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
||||
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
|
||||
<div class="document-table-wrap">
|
||||
<table class="document-table">
|
||||
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
|
||||
<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"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||
<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></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>
|
||||
@@ -147,19 +160,20 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; gap: 12px; }
|
||||
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.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{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important}
|
||||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.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:13px}.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-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}
|
||||
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
|
||||
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||
.metric-grid strong { font-size: 18px; }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:#fff;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:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.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:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.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}
|
||||
.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,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 ? '—' : '待关联订舱')
|
||||
}
|
||||
@@ -1,3 +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()
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,18 @@ 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">报关清关信息',
|
||||
'container-receiving-heading">签收信息',
|
||||
'container-fees-heading">费用明细',
|
||||
'container-customs-heading">{{ isDomestic',
|
||||
'container-receiving-heading">装柜作业与国内出仓',
|
||||
]
|
||||
let previous = -1
|
||||
for (const heading of headings) {
|
||||
@@ -26,9 +29,81 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain('<CarrierScheduleQueryPanel')
|
||||
expect(source).toContain(':sections="[\'documents\']"')
|
||||
expect(source).toContain(':sections="[\'pda\']"')
|
||||
expect(source).toContain('<ContainerFeesPanel')
|
||||
expect(source).toContain('getConfirmedLoadingList')
|
||||
expect(source).toContain('getPreloadPlan')
|
||||
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', () => {
|
||||
@@ -57,6 +132,8 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain("!detail.value.oceanBookingId")
|
||||
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)")
|
||||
expect(source).toContain('关联已放舱订舱')
|
||||
expect(source).toContain("query: { linkContainerId: detail.value.id }")
|
||||
expect(source).toContain('oceanBookingLinkOpen.value = true')
|
||||
expect(source).toContain('<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />')
|
||||
expect(source).not.toContain('linkContainerId')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileUp, Link2 } from '@lucide/vue'
|
||||
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'
|
||||
@@ -10,8 +11,8 @@ 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 } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, type LoadingDetail } from '@/modules/loading-lists'
|
||||
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
|
||||
import {
|
||||
confirmContainerOutboundDispatch,
|
||||
getContainerOutboundDispatch,
|
||||
@@ -19,7 +20,11 @@ import {
|
||||
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,
|
||||
@@ -31,21 +36,79 @@ import {
|
||||
markContainerException,
|
||||
uploadContainerClearance,
|
||||
uploadContainerDocument,
|
||||
updateContainerPickupCode,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types'
|
||||
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 canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
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 canViewContainerFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
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
|
||||
@@ -56,7 +119,7 @@ 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' | 'PICKUP_ORDER'
|
||||
type UploadDocumentType = 'DELIVERY_ORDER'
|
||||
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
||||
const documentFile = ref<File | null>(null)
|
||||
const documentUploadKey = ref(0)
|
||||
@@ -64,6 +127,7 @@ 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)
|
||||
@@ -80,12 +144,50 @@ const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
||||
const outboundError = ref<ApiErrorDetails | null>(null)
|
||||
const showPassedGates = ref(false)
|
||||
const carrierRefreshKey = ref(0)
|
||||
const loadingDetail = ref<LoadingDetail | null>(null)
|
||||
const loadingDetailError = ref<ApiErrorDetails | null>(null)
|
||||
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(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
|
||||
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'])
|
||||
@@ -110,27 +212,15 @@ const outboundGateSummary = computed(() => ({
|
||||
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'>(() =>
|
||||
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||||
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 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
|
||||
void router.push({ name: 'container-loading', query: { linkContainerId: detail.value.id } })
|
||||
oceanBookingLinkOpen.value = true
|
||||
}
|
||||
|
||||
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
||||
@@ -169,19 +259,68 @@ async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
|
||||
|
||||
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] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
||||
detail.value = resource; overview.value = aggregate
|
||||
await loadLoadingDetail(resource, aggregate)
|
||||
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||
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
|
||||
@@ -191,19 +330,12 @@ async function loadOutboundDispatch() {
|
||||
}
|
||||
|
||||
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
|
||||
loadingDetail.value = null; loadingDetailError.value = null
|
||||
if (!canViewContainerFees.value || !aggregate.loadingPlanId || !resource.warehouseId) return
|
||||
try {
|
||||
if (resource.planStatus === 'COMPLETED') {
|
||||
if (!canViewLoadingList.value) return
|
||||
loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)
|
||||
return
|
||||
}
|
||||
if (!canViewPreload.value) return
|
||||
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
|
||||
} catch (cause) {
|
||||
loadingDetailError.value = getApiErrorDetails(cause, '费用归属信息加载失败')
|
||||
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()
|
||||
@@ -215,7 +347,7 @@ async function confirmDeparture() {
|
||||
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||
outboundConfirming.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' })
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
} catch { outboundConfirming.value = false; return }
|
||||
outboundError.value = null
|
||||
try {
|
||||
@@ -242,8 +374,9 @@ function truckReferenceText(status?: string | null) {
|
||||
}
|
||||
|
||||
function openDocument(type: string) {
|
||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !canEdit.value) return
|
||||
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
|
||||
}
|
||||
@@ -280,12 +413,12 @@ async function saveDocument() {
|
||||
}
|
||||
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', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||||
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, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
||||
: await downloadContainerDocument(containerId.value, 'DELIVERY_ORDER', item.fileId)
|
||||
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '资料下载失败')
|
||||
@@ -294,6 +427,20 @@ async function downloadDocument(item: ContainerOverviewDocument) {
|
||||
}
|
||||
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 }
|
||||
@@ -358,40 +505,51 @@ async function completeClearance() {
|
||||
finally { clearanceSaving.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => { await load(); if (route.query.action === 'documents') openDocument('DELIVERY_ORDER'); if (route.query.action === 'exception' && canEdit.value) openException() })
|
||||
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">
|
||||
<AdminPage class="container-detail-page admin-density">
|
||||
<section class="container-overview surface-panel">
|
||||
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></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>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
|
||||
<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 class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<h2 id="container-information-heading">货柜信息</h2>
|
||||
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||
<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 class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||
<div class="detail-fields"><dl><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 :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
<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 class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||
<h2 id="container-customs-heading">报关清关信息</h2>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['documents']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" />
|
||||
<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="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<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 class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
|
||||
<h2 id="container-receiving-heading">签收信息</h2>
|
||||
<div class="detail-fields"><dl><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></div>
|
||||
<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>
|
||||
@@ -400,7 +558,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
<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">全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。</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>
|
||||
@@ -411,21 +569,41 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section surface-panel" aria-labelledby="container-fees-heading">
|
||||
<h2 id="container-fees-heading">费用明细</h2>
|
||||
<LoadErrorState v-if="loadingDetailError" compact title="费用明细加载失败" :message="loadingDetailError.message" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<el-alert v-else-if="!canViewContainerFees" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||
<el-empty v-else description="当前货柜尚无可读取的装柜费用上下文" :image-size="64" />
|
||||
<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>
|
||||
|
||||
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-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 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 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 /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog 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 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>
|
||||
<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>
|
||||
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-section{display:grid;min-width:0;gap:12px;padding:12px 16px}.detail-section>h2{margin:0;font-size:15px}.detail-fields { padding:0; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.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:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.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:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:4px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.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}}
|
||||
.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,6 +1,7 @@
|
||||
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'
|
||||
@@ -10,8 +11,12 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
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('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
|
||||
@@ -21,16 +26,39 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue
|
||||
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: '/', component: ContainersView },
|
||||
{ path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
|
||||
{ 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: '/loading-lists', name: 'loading-lists', 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())
|
||||
@@ -41,8 +69,14 @@ describe('ContainersView', () => {
|
||||
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(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, 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', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
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: [] })
|
||||
})
|
||||
|
||||
@@ -51,32 +85,221 @@ describe('ContainersView', () => {
|
||||
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('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
|
||||
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.text()).not.toContain('装柜仓库加载失败')
|
||||
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: '40HC', planStatus: 'WAIT_LOADING' 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: 76, maxWeightKg: 28000,
|
||||
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,
|
||||
}
|
||||
@@ -102,10 +325,10 @@ describe('ContainersView', () => {
|
||||
|
||||
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: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', version: 0,
|
||||
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: 76, maxWeightKg: 28000,
|
||||
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,
|
||||
}
|
||||
@@ -123,16 +346,16 @@ describe('ContainersView', () => {
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('排柜')
|
||||
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().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: 'loading-lists', query: { planId: '9103', 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', () => {
|
||||
@@ -151,7 +374,7 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="更多货柜操作"]').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)
|
||||
})
|
||||
|
||||
@@ -163,7 +386,7 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
|
||||
expect(wrapper.find('button[aria-label="更多货柜操作"]').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)
|
||||
})
|
||||
|
||||
@@ -191,13 +414,12 @@ describe('ContainersView', () => {
|
||||
|
||||
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("router.push({ name: 'container-detail', params: { containerId: row.id } })")
|
||||
expect(source).toContain("query: { action: command }")
|
||||
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"')
|
||||
expect(source).toContain('max-height: 90vh')
|
||||
expect(source).toContain('overflow-y: auto')
|
||||
expect(source).toContain('grid-template-columns: minmax(0,1fr)')
|
||||
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"')
|
||||
})
|
||||
@@ -205,10 +427,11 @@ describe('ContainersView', () => {
|
||||
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/:containerId'")
|
||||
expect(routes).toContain("name: 'container-detail'")
|
||||
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)')
|
||||
expect(detail).toContain('getContainer(containerId.value, props.workbench)')
|
||||
expect(detail).toContain('getContainerOverview(containerId.value)')
|
||||
expect(detail).toContain('<ContainerOverviewPanel')
|
||||
expect(detail).toContain("@upload-document=\"openDocument\"")
|
||||
@@ -230,82 +453,146 @@ describe('ContainersView', () => {
|
||||
|
||||
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('size="small" type="primary" plain :aria-label="containerViewLabel')
|
||||
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
|
||||
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")
|
||||
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" stripe preference-key="container-loading-table"')
|
||||
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
|
||||
expect(source).toContain('appearance="integrated" :stripe="false"')
|
||||
})
|
||||
|
||||
it('opens the existing released-booking workflow when entered from container detail', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('route.query.linkContainerId')
|
||||
expect(source).toContain('const target = await getContainer(linkContainerId)')
|
||||
expect(source).toContain('await openLink(target)')
|
||||
expect(source).toContain("void router.replace({ name: 'container-loading' })")
|
||||
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('normalizes an optional booking-link container number', async () => {
|
||||
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
|
||||
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 {
|
||||
linkTarget: { id: string; version: number }
|
||||
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
|
||||
linkCandidateKey: string
|
||||
linkContainerNo: string
|
||||
saveLink: () => Promise<void>
|
||||
}
|
||||
vm.linkTarget = { id: '9001', version: 7 }
|
||||
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
|
||||
vm.linkCandidateKey = '101:202'
|
||||
vm.linkContainerNo = ' mscu1234567 '
|
||||
|
||||
await vm.saveLink()
|
||||
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: 'MSCU1234567', expectedVersion: 7,
|
||||
})
|
||||
expect(vm.linkContainerNo).toBe('MSCU1234567')
|
||||
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('links a released booking while leaving the real container number pending until pickup', async () => {
|
||||
vi.mocked(linkContainerOceanBooking).mockResolvedValue({} as never)
|
||||
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, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
linkTarget: { id: string; version: number }
|
||||
linkCandidates: Array<{ key: string; oceanBookingId: string; requirementId: string; disabled: boolean }>
|
||||
linkCandidateKey: string
|
||||
linkContainerNo: string
|
||||
saveLink: () => Promise<void>
|
||||
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()
|
||||
}
|
||||
vm.linkTarget = { id: '9001', version: 7 }
|
||||
vm.linkCandidates = [{ key: '101:202', oceanBookingId: '101', requirementId: '202', disabled: false }]
|
||||
vm.linkCandidateKey = '101:202'
|
||||
vm.linkContainerNo = ''
|
||||
|
||||
await vm.saveLink()
|
||||
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: null, expectedVersion: 7,
|
||||
})
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('真实集装箱号可待司机提柜时录入')
|
||||
expect(source).toContain(':confirm-disabled="!linkCandidateKey"')
|
||||
expect(source).toContain("row.containerNo || '待补录'")
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
|
||||
@@ -322,18 +609,23 @@ describe('ContainersView', () => {
|
||||
|
||||
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(source).toContain('新增模拟柜')
|
||||
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
|
||||
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000")
|
||||
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(source).toContain('selectSimulationContainerType')
|
||||
expect(source).toContain('服务端会生成稳定的柜次号')
|
||||
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','40GP','40HQ','45HQ']")
|
||||
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"')
|
||||
})
|
||||
@@ -341,8 +633,8 @@ describe('ContainersView', () => {
|
||||
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', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'EDITING',
|
||||
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 })
|
||||
@@ -350,17 +642,37 @@ describe('ContainersView', () => {
|
||||
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()
|
||||
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { linkCandidates: unknown[] }).linkCandidates).toEqual([])
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8'))
|
||||
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
|
||||
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -368,8 +680,8 @@ describe('ContainersView', () => {
|
||||
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', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'WAIT_LOADING',
|
||||
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 }))
|
||||
@@ -377,21 +689,22 @@ describe('ContainersView', () => {
|
||||
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 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', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
|
||||
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', requirementId: '45315', containerType: '45HC', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
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 · 45HC × 1')
|
||||
expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -406,7 +719,7 @@ describe('ContainersView', () => {
|
||||
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
|
||||
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()
|
||||
@@ -415,32 +728,32 @@ describe('ContainersView', () => {
|
||||
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
|
||||
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', requirementId: '404', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
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', requirementId: '202', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
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.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.map((candidate) => candidate.label)
|
||||
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', 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', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
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().includes('编辑运输信息'))!.trigger('click')
|
||||
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()
|
||||
@@ -452,15 +765,15 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('clears the trucking supplier id and name together', async () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', 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', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
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().includes('编辑运输信息'))!.trigger('click')
|
||||
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', '')
|
||||
@@ -475,14 +788,14 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
|
||||
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().includes('编辑运输信息'))!.trigger('click')
|
||||
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('')
|
||||
@@ -497,7 +810,7 @@ describe('ContainersView', () => {
|
||||
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', 'pickupOrderDocumentStatus']
|
||||
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)
|
||||
@@ -512,8 +825,11 @@ describe('ContainersView trucking supplier contract', () => {
|
||||
|
||||
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', status: 'ACTIVE' })")
|
||||
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'")
|
||||
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,39 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
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 { listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } 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 & { 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)
|
||||
@@ -44,41 +67,112 @@ const businessOptionsError = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filters = ref<Filters>({ customerId: '', keyword: '', 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 detail = ref<ContainerBookingResource | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const editorError = ref<ApiErrorDetails | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
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>({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
const linkOpen = ref(false)
|
||||
const linkLoading = ref(false)
|
||||
const linkSaving = ref(false)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkTarget = ref<ContainerBookingResource | null>(null)
|
||||
const linkCandidateKey = ref('')
|
||||
const linkContainerNo = ref('')
|
||||
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
|
||||
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 linkSequence = 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: '40HC', 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: 76, maxWeightKg: 28000, terminalTruckingCompanyId: 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,
|
||||
}
|
||||
@@ -90,22 +184,22 @@ const rules: FormRules<ContainerForm> = {
|
||||
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
|
||||
}
|
||||
|
||||
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
||||
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 canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
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'))
|
||||
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
|
||||
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
||||
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
|
||||
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -135,11 +229,12 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ 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: '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: 310, fixed: 'left', slotName: 'actions' },
|
||||
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' },
|
||||
@@ -147,10 +242,16 @@ const columns: DataTableColumn[] = [
|
||||
{ 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: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
|
||||
{ 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: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', 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 },
|
||||
@@ -163,12 +264,54 @@ const columns: DataTableColumn[] = [
|
||||
{ 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 normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
|
||||
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())
|
||||
@@ -267,14 +410,21 @@ function selectDestinationWarehouse(warehouseCode: string) {
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
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')
|
||||
}
|
||||
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
|
||||
@@ -288,25 +438,64 @@ async function loadCustomers() {
|
||||
}
|
||||
}
|
||||
async function loadBusinessOptions() {
|
||||
businessOptionsLoading.value = true
|
||||
businessOptionsError.value = ''
|
||||
const errors: string[] = []
|
||||
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
|
||||
loadAllPartners(),
|
||||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
||||
loadAllOceanBookings(),
|
||||
])
|
||||
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
|
||||
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
|
||||
if (supplierResult.status === 'fulfilled') {
|
||||
truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
||||
if (form.id) reconcileTruckingSupplier()
|
||||
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
|
||||
}
|
||||
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
|
||||
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
|
||||
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
|
||||
businessOptionsError.value = errors.join(';')
|
||||
businessOptionsLoading.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' })
|
||||
@@ -320,93 +509,99 @@ async function loadAllOceanBookings() {
|
||||
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)
|
||||
}
|
||||
function openSimulation() {
|
||||
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
simulationChannels.value = []
|
||||
simulationError.value = null
|
||||
simulationOpen.value = true
|
||||
async function loadSimulationWarehouses() {
|
||||
await loadWarehouses()
|
||||
if (warehouseError.value) throw warehouseError.value
|
||||
return warehouses.value
|
||||
}
|
||||
function selectSimulationContainerType(containerType: string) {
|
||||
applyContainerSpecification(simulationForm, containerType)
|
||||
}
|
||||
async function selectSimulationWarehouse(value: unknown) {
|
||||
simulationForm.warehouseId = String(value || '')
|
||||
simulationForm.channelCode = ''
|
||||
simulationChannels.value = []
|
||||
simulationError.value = null
|
||||
if (!simulationForm.warehouseId) return
|
||||
simulationChannelsLoading.value = true
|
||||
try { simulationChannels.value = await listWarehouseLogisticsChannels(simulationForm.warehouseId) }
|
||||
catch (error) { simulationError.value = getApiErrorDetails(error, '物流渠道加载失败') }
|
||||
finally { simulationChannelsLoading.value = false }
|
||||
}
|
||||
async function saveSimulation() {
|
||||
if (!(await simulationFormRef.value?.validate().catch(() => false))) return
|
||||
simulationSaving.value = true
|
||||
simulationError.value = null
|
||||
try {
|
||||
await createContainerSimulation({ ...simulationForm })
|
||||
simulationOpen.value = false
|
||||
ElMessage.success('模拟柜已创建,可先排货后关联真实订舱柜')
|
||||
await loadRows()
|
||||
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
|
||||
finally { simulationSaving.value = false }
|
||||
}
|
||||
async function openLink(source: object) {
|
||||
if (linkLoading.value) return
|
||||
function openLink(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
const warehouseId = optional(row.warehouseId)
|
||||
const channelCode = optional(row.channelCode)
|
||||
if (!warehouseId || !channelCode || !optional(row.containerType)) {
|
||||
linkTarget.value = row
|
||||
linkCandidates.value = []
|
||||
linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
|
||||
linkOpen.value = true
|
||||
return
|
||||
}
|
||||
const sequence = ++linkSequence
|
||||
linkTarget.value = row
|
||||
linkCandidateKey.value = ''
|
||||
linkContainerNo.value = ''
|
||||
linkCandidates.value = []
|
||||
linkError.value = null
|
||||
linkOpen.value = true
|
||||
linkLoading.value = true
|
||||
try {
|
||||
const result = await listOceanBookingLinkCandidates({
|
||||
warehouseId, channelCode, containerType: row.containerType,
|
||||
page: 1, size: 200,
|
||||
})
|
||||
if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
|
||||
linkCandidates.value = result.records.map((candidate) => ({
|
||||
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
disabled: false,
|
||||
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
|
||||
} finally {
|
||||
if (sequence === linkSequence) linkLoading.value = false
|
||||
}
|
||||
}
|
||||
async function saveLink() {
|
||||
const target = linkTarget.value
|
||||
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
|
||||
const normalized = normalizedContainerNo(linkContainerNo.value)
|
||||
const containerNo = normalized || null
|
||||
linkContainerNo.value = normalized
|
||||
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
|
||||
linkSaving.value = true
|
||||
linkError.value = null
|
||||
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 {
|
||||
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
|
||||
linkOpen.value = false
|
||||
ElMessage.success(containerNo ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
|
||||
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) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
|
||||
finally { linkSaving.value = false }
|
||||
} 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
|
||||
@@ -417,10 +612,15 @@ async function loadRows() {
|
||||
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, '货柜列表加载失败,请稍后重试')
|
||||
@@ -429,23 +629,47 @@ async function loadRows() {
|
||||
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
|
||||
function openDetail(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
void router.push({ name: 'container-detail', params: { containerId: row.id } })
|
||||
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 (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
|
||||
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)
|
||||
}
|
||||
async function openEdit(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
editorError.value = null
|
||||
try {
|
||||
const latest = await getContainer(row.id)
|
||||
if (!['WAIT_LOADING', 'EDITING'].includes(latest.planStatus)) {
|
||||
ElMessage.warning('当前阶段运输信息只读;真实集装箱号如需纠错,请进入预装清单')
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -468,7 +692,7 @@ async function save() {
|
||||
if (details.code === 'A0409' && form.id) {
|
||||
const submitted = { ...form }
|
||||
try {
|
||||
const latest = await getContainer(form.id)
|
||||
const latest = await getContainer(form.id, props.workbench)
|
||||
detail.value = latest
|
||||
replaceForm(latest)
|
||||
Object.assign(form, submitted, { id: latest.id, version: latest.version })
|
||||
@@ -491,91 +715,71 @@ function goContainerProcess(source: object) {
|
||||
return
|
||||
}
|
||||
void router.push({
|
||||
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
||||
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
|
||||
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()
|
||||
})
|
||||
watch(linkOpen, (open) => {
|
||||
if (open) return
|
||||
linkSequence += 1
|
||||
linkLoading.value = false
|
||||
})
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()])
|
||||
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
|
||||
if (!linkContainerId || !canEdit.value) return
|
||||
try {
|
||||
const target = await getContainer(linkContainerId)
|
||||
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !['WAIT_LOADING', 'EDITING'].includes(target.planStatus)) {
|
||||
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
|
||||
return
|
||||
}
|
||||
await openLink(target)
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
|
||||
} finally {
|
||||
void router.replace({ name: 'container-loading' })
|
||||
}
|
||||
})
|
||||
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="loadError" #state>
|
||||
<template #notices><template v-if="loadError">
|
||||
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
</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>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog 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="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></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)?.capacityCbm || '—'} m³ / ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /></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>
|
||||
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
|
||||
|
||||
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
|
||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
|
||||
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号(可选)"><el-input v-model="linkContainerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
|
||||
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
|
||||
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -595,16 +799,35 @@ onMounted(async () => {
|
||||
<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>
|
||||
</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: minmax(0,1fr); gap: 0; }.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) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
:global(.container-editor-dialog .el-dialog__body) { min-height: 0; padding: 28px; overflow-y: auto; }
|
||||
:global(.container-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 16px 28px; border-top: 1px solid #e5eaf1; background: #f8fafc; }
|
||||
@media(max-width:720px){:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.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>
|
||||
@@ -7,12 +7,12 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
assignSmartLoadingGroup,
|
||||
assignSmartLoadingItem,
|
||||
assignSmartLoadingPallet,
|
||||
getSmartLoadingPlanByBooking,
|
||||
listContainers,
|
||||
listSmartLoadingCandidates,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
removeAllUnpickedSmartLoadingItems,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
removeSmartLoadingPallet,
|
||||
} from '../api/platformContainerLoading'
|
||||
|
||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||
@@ -43,12 +44,14 @@ vi.mock('../api/platformContainerLoading', () => ({
|
||||
listSmartLoadingWorkbenchContainers: vi.fn(),
|
||||
createSmartLoadingPlan: vi.fn(),
|
||||
assignSmartLoadingItem: vi.fn(),
|
||||
assignSmartLoadingPallet: vi.fn(),
|
||||
assignPendingReviewSmartLoadingOrder: vi.fn(),
|
||||
assignSmartLoadingGroup: vi.fn(),
|
||||
recommendSmartLoading: vi.fn(),
|
||||
adjustSmartLoadingItem: vi.fn(),
|
||||
removeSmartLoadingItem: vi.fn(),
|
||||
removeSmartLoadingGroup: vi.fn(),
|
||||
removeSmartLoadingPallet: vi.fn(),
|
||||
removeAllUnpickedSmartLoadingItems: vi.fn(),
|
||||
pushSmartLoadingPreload: vi.fn(),
|
||||
}))
|
||||
@@ -62,10 +65,10 @@ vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups:
|
||||
|
||||
const container = {
|
||||
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'EDITING' as const, version: 1,
|
||||
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船',
|
||||
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
||||
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
|
||||
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,
|
||||
}
|
||||
@@ -74,7 +77,8 @@ const plan = {
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
|
||||
coLoadGroups: [],
|
||||
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
|
||||
palletMembers: [],
|
||||
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
|
||||
}
|
||||
const candidate = {
|
||||
accountId: '1', inboundId: '5', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2',
|
||||
@@ -82,14 +86,14 @@ const candidate = {
|
||||
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,
|
||||
availableVolumeCbm: 2.4, estimatedVolumeCbm: 3.5, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null,
|
||||
locked: null, blockReason: null,
|
||||
}
|
||||
function candidatePage(records: unknown[], page = 1, size = 20, total = records.length) {
|
||||
return {
|
||||
records: records.map((record) => {
|
||||
const candidate = record as { accountId?: string; customerId?: string; groupMembers?: unknown[] }
|
||||
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], ...candidate }
|
||||
return { customerId: candidate.customerId || candidate.accountId || '', groupMembers: [], unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, ...candidate }
|
||||
}),
|
||||
page, size, total, pages: total ? Math.ceil(total / size) : 0,
|
||||
} as Awaited<ReturnType<typeof listSmartLoadingCandidates>>
|
||||
@@ -98,7 +102,8 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
|
||||
return {
|
||||
records: records.map((item) => ({
|
||||
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
|
||||
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelCode: item.channelCode || '', channelName: item.channelName || '',
|
||||
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelGroupCode: item.channelGroupCode,
|
||||
channelCode: item.channelCode, channelName: item.channelName,
|
||||
containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
|
||||
planId: item.planId, planNo: null, planStatus: item.planStatus, version: item.version,
|
||||
})),
|
||||
@@ -108,7 +113,7 @@ function containerPage(records: ContainerBookingResource[] = [container]) {
|
||||
function mockContainerSearch(records: ContainerBookingResource[]) {
|
||||
vi.mocked(listSmartLoadingWorkbenchContainers).mockImplementation(async (query) => containerPage(records.filter((item) => (
|
||||
item.planStatus !== 'COMPLETED'
|
||||
&& query.channelGroupCodes.includes(resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.code || '')
|
||||
&& query.channelGroupCodes.includes(item.channelGroupCode)
|
||||
))))
|
||||
}
|
||||
|
||||
@@ -116,7 +121,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
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/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
|
||||
expect(source).toContain('<LineClampText v-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
|
||||
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).not.toContain('<span v-for="summary in row.memberOrderSummaries"')
|
||||
@@ -149,6 +155,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).not.toContain('SKU-1')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
expect(wrapper.text()).toContain('IN-2')
|
||||
expect(wrapper.text()).toContain('预估体积')
|
||||
expect(wrapper.text()).toContain('3.5 m³')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-2')
|
||||
expect(wrapper.get('.candidate-pane').text()).toContain('120 kg')
|
||||
@@ -198,14 +206,14 @@ describe('GlobalSmartLoadingView', () => {
|
||||
containerNo: null,
|
||||
channelName: '美森正班海卡包税',
|
||||
warehouseName: '义乌国内仓',
|
||||
containerType: '40HC',
|
||||
containerType: '40HQ',
|
||||
}],
|
||||
page: 1,
|
||||
size: 100,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
mockContainerSearch([{ ...container, cabinetNo: '2088475410134773762', containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', containerType: '40HC' }])
|
||||
mockContainerSearch([{ ...container, cabinetNo: '2088475410134773762', containerNo: null, channelName: '美森正班海卡包税', warehouseName: '义乌国内仓', containerType: '40HQ' }])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -216,7 +224,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(option.text()).not.toContain('开始装柜前录入集装箱号')
|
||||
expect(option.text()).toContain('渠道:美森正班')
|
||||
expect(option.text()).toContain('仓库:义乌国内仓')
|
||||
expect(option.text()).toContain('柜型:40HC')
|
||||
expect(option.text()).toContain('柜型:40HQ')
|
||||
expect(option.text()).not.toContain('2088475410134773762')
|
||||
expect(wrapper.text()).not.toContain('真实柜号')
|
||||
})
|
||||
@@ -228,6 +236,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
planId: '9007199254740999',
|
||||
cabinetNo: 'CB-2',
|
||||
containerNo: 'MSCU-2',
|
||||
channelGroupCode: 'HEDE',
|
||||
channelCode: 'HDHK',
|
||||
channelName: '合德海卡包税',
|
||||
}
|
||||
@@ -262,6 +271,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
planId: '9007199254740999',
|
||||
cabinetNo: 'CB-2',
|
||||
containerNo: 'MSCU-2',
|
||||
channelGroupCode: 'HEDE',
|
||||
channelCode: 'HDHK',
|
||||
channelName: '合德海卡包税',
|
||||
}
|
||||
@@ -346,6 +356,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
containerNo: 'MSCU-DG-MAX',
|
||||
warehouseId: '18',
|
||||
warehouseName: '东莞仓',
|
||||
channelGroupCode: 'MATSON_MAX',
|
||||
channelCode: 'MATSON_MAX_TRUCK',
|
||||
channelName: '美森加班海卡',
|
||||
}
|
||||
@@ -411,7 +422,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).toContain(': candidate.availablePackageCount')
|
||||
})
|
||||
|
||||
it('filters candidates by creation range and multiple order statuses', async () => {
|
||||
it('filters candidates by creation, expected-arrival range and multiple order statuses', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates)
|
||||
.mockResolvedValueOnce(candidatePage([
|
||||
{ ...candidate, inboundId: '51', inboundCode: 'IN-MATCH', orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: '2026-09-11T08:00:00' },
|
||||
@@ -425,6 +436,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
wrapper.getComponent(QueryToolbar).vm.$emit('search', {
|
||||
customerId: '', inboundCode: '', orderStatuses: ['WAIT_SPLIT', 'LOADING'], createdRange: ['2026-09-10', '2026-09-12'],
|
||||
expectedArrivalRange: ['2026-09-11', '2026-09-15'],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
@@ -432,7 +444,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(candidates).toContain('IN-MATCH')
|
||||
expect(candidates).not.toContain('IN-WRONG-STATUS')
|
||||
expect(candidates).not.toContain('IN-WRONG-DATE')
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', orderStatuses: ['WAIT_SPLIT', 'LOADING'], page: 1, size: 20 })
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], createdStart: '2026-09-10', createdEnd: '2026-09-12', expectedArrivalStart: '2026-09-11', expectedArrivalEnd: '2026-09-15', orderStatuses: ['WAIT_SPLIT', 'LOADING'], page: 1, size: 20 })
|
||||
expect(listSmartLoadingWorkbenchContainers).toHaveBeenCalledTimes(containerRequestCount)
|
||||
expect(listSmartLoadingWorkbenchContainers).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 200 })
|
||||
})
|
||||
@@ -481,13 +493,13 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
|
||||
expect(wrapper.text()).toContain('3 个入仓片段')
|
||||
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
|
||||
const buttons = wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))
|
||||
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
|
||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班海卡包税')
|
||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -504,8 +516,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
inboundId: null,
|
||||
orderStatusSnapshot: 'PENDING_REVIEW',
|
||||
inboundCode: 'EXXHK2609020361',
|
||||
channelCode: 'SLOW',
|
||||
channelName: '慢船',
|
||||
channelCode: 'HEDE_TRUCK',
|
||||
channelName: '合德海卡',
|
||||
unitGrossWeightKg: null,
|
||||
averageUnitVolumeCbm: null,
|
||||
availableVolumeCbm: null,
|
||||
@@ -524,8 +536,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
|
||||
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
||||
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
|
||||
expect(document.body.textContent).toContain('订单渠道:慢船')
|
||||
expect(document.body.textContent).toContain('这是人工排柜,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('订单渠道:合德海卡')
|
||||
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||
;([...document.body.querySelectorAll('button')]
|
||||
.find((button) => button.textContent?.includes('确认加入')) as HTMLButtonElement).click()
|
||||
@@ -549,7 +561,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||||
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))!
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -588,6 +600,73 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(adjustSmartLoadingItem).toHaveBeenCalledWith(plan.id, plan.items[0].id, 4, plan.version, plan.items[0].version)
|
||||
})
|
||||
|
||||
it('shows a shared pallet once with exact members and a text-only join action', async () => {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
...candidate, unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-0025', indivisible: true,
|
||||
availablePackageCount: 13, unitGrossWeightKg: 10,
|
||||
groupMembers: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 10, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
],
|
||||
}]))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter(button => button.text() === '加入')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0].find('svg').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('I180:3 箱')
|
||||
expect(wrapper.text()).toContain('I181:10 箱')
|
||||
expect(wrapper.text()).toContain('130 kg')
|
||||
expect(wrapper.get('.candidate-pane').text()).toContain('整托加入 · 不可拆')
|
||||
expect(wrapper.get('.plan-pane').text()).toContain('按箱加入')
|
||||
expect(wrapper.text()).toContain('装柜规则')
|
||||
expect(wrapper.text()).not.toContain('分配单位')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one loading-rule column and expandable group details in both workbenches', () => {
|
||||
for (const page of ['GlobalSmartLoadingView.vue', 'SmartLoadingWorkbenchView.vue']) {
|
||||
const source = readFileSync(resolve(process.cwd(), `src/modules/container-loading/pages/${page}`), 'utf8')
|
||||
expect(source.match(/label: '装柜规则'/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("label: '分配单位'")
|
||||
expect(source).not.toContain('<template #unitType=')
|
||||
expect(source.match(/<summary>整组加入 · 不可拆<\/summary>/g)).toHaveLength(2)
|
||||
expect(source.match(/'整托加入 · 不可拆' : '按箱加入'/g)).toHaveLength(2)
|
||||
expect(source).toContain("row.memberOrderSummaries.join(';')")
|
||||
}
|
||||
})
|
||||
|
||||
it('searches any shared-pallet inbound, retains its complete composition across pages, and resets the search', async () => {
|
||||
const sharedPallet = {
|
||||
...candidate, inboundId: '180', inboundCode: 'I180', unitType: 'SEALED_PALLET' as const,
|
||||
palletId: '801', palletNo: 'DP-0025', indivisible: true, availablePackageCount: 13,
|
||||
groupMembers: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 10, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
],
|
||||
}
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([sharedPallet], 1, 20, 21))
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const filters = { warehouseId: '', customerId: '', inboundCode: ' I181 ', createdRange: [], expectedArrivalRange: [], orderStatuses: [] }
|
||||
wrapper.getComponent(QueryToolbar).vm.$emit('search', filters)
|
||||
await flushPromises()
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], inboundCode: 'I181', page: 1, size: 20 })
|
||||
const pane = wrapper.get('.candidate-pane')
|
||||
expect(pane.text()).toContain('I180:3 箱')
|
||||
expect(pane.text()).toContain('I181:10 箱')
|
||||
expect(pane.findAll('.el-table__body-wrapper button').filter(button => button.text() === '加入')).toHaveLength(1)
|
||||
|
||||
await pane.get('button[aria-label="下一页"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], inboundCode: 'I181', page: 2, size: 20 })
|
||||
|
||||
wrapper.getComponent(QueryToolbar).vm.$emit('reset', { ...filters, inboundCode: '' })
|
||||
await flushPromises()
|
||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
@@ -596,8 +675,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('0.16%')
|
||||
expect(wrapper.text()).toContain('0.03%')
|
||||
expect(wrapper.text()).toContain('0.18%')
|
||||
expect(wrapper.text()).toContain('0.05%')
|
||||
expect(wrapper.text()).not.toContain('0.169999999')
|
||||
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
|
||||
})
|
||||
@@ -608,7 +687,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await flushPromises()
|
||||
|
||||
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }>; props?: Record<string, unknown> }>
|
||||
expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'orderStatuses'])
|
||||
expect(fields.map((field) => field.key)).toEqual(['warehouseId', 'customerId', 'inboundCode', 'createdRange', 'expectedArrivalRange', 'orderStatuses'])
|
||||
expect(fields.find((field) => field.key === 'warehouseId')?.options).toEqual([
|
||||
{ label: '义乌仓', value: '8' },
|
||||
{ label: '东莞仓', value: '18' },
|
||||
@@ -705,7 +784,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
})
|
||||
|
||||
it('isolates a missing-channel row without hiding another valid container', async () => {
|
||||
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelCode: null, channelName: null }
|
||||
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelGroupCode: '', channelCode: null, channelName: null }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [missingChannel, container], page: 1, size: 100, total: 2, pages: 1 })
|
||||
mockContainerSearch([missingChannel, container])
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
@@ -749,8 +828,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3)
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2, { customerId: '', inboundCode: '' })
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 3, { customerId: '', inboundCode: '' })
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(warningMessage).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||
@@ -852,6 +931,54 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('移除未拣'))).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a sealed pallet row through the whole-pallet command', async () => {
|
||||
const palletItem = {
|
||||
...plan.items[0], unitType: 'SEALED_PALLET' as const, palletId: '801', palletNo: 'DP-260914-0021',
|
||||
indivisible: true, packageCount: 10, pickedPackageCount: 0, minPackageCount: 10,
|
||||
canAdjust: false, canRemove: true,
|
||||
}
|
||||
const palletPlan = { ...plan, items: [palletItem] }
|
||||
const emptyPlan = { ...palletPlan, version: 3, items: [] }
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(palletPlan)
|
||||
vi.mocked(removeSmartLoadingPallet).mockResolvedValue(emptyPlan)
|
||||
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const row = wrapper.findAll('tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0021'))
|
||||
expect(row).toBeDefined()
|
||||
|
||||
await row!.find('.el-button--danger').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeSmartLoadingPallet).toHaveBeenCalledWith(plan.id, '801', 2)
|
||||
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows every inbound code and its box count for an assigned sealed pallet', async () => {
|
||||
const palletItem = {
|
||||
...plan.items[0], inboundId: '180', inboundCode: 'I180', unitType: 'SEALED_PALLET' as const,
|
||||
palletId: '801', palletNo: 'DP-260914-0025', indivisible: true, packageCount: 11,
|
||||
pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
|
||||
}
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
items: [palletItem],
|
||||
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 wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const row = wrapper.findAll('.plan-pane .el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.text()).toContain('2 个入仓码 · 展开')
|
||||
expect(row!.text()).toContain('I180:6 箱')
|
||||
expect(row!.text()).toContain('I181:5 箱')
|
||||
})
|
||||
|
||||
it('renders final completion capability as read-only with the server reason', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
@@ -915,6 +1042,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
|
||||
expect(source).toContain('min-height: 112px')
|
||||
expect(source).toContain("label: '创建日期'")
|
||||
expect(source).toContain("label: '预计到货时间'")
|
||||
expect(source).toContain("label: '订单状态'")
|
||||
expect(source).toContain('.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }')
|
||||
expect(source).toContain('.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }')
|
||||
@@ -925,7 +1053,8 @@ describe('GlobalSmartLoadingView', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain('<el-checkbox-group :model-value="selectedChannelCodes"')
|
||||
expect(source).toContain('<el-checkbox-button v-for="channel in channelOptions"')
|
||||
expect(source).toContain('v-for="channel in channelOptions"')
|
||||
expect(source).toContain('@dblclick.stop.prevent="selectOnlyChannel(channel.code)"')
|
||||
expect(source).toContain('{{ channel.name }}')
|
||||
expect(source).toContain("ElMessage.warning('至少选择一个渠道')")
|
||||
expect(source).toContain('class="compact-summary" aria-label="已选渠道装柜统计"')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
assignSmartLoadingGroup,
|
||||
assignSmartLoadingItem,
|
||||
assignSmartLoadingPallet,
|
||||
getSmartLoadingPlanByBooking,
|
||||
listSmartLoadingCandidates,
|
||||
listSmartLoadingWorkbenchContainers,
|
||||
@@ -25,9 +27,11 @@ import {
|
||||
removeAllUnpickedSmartLoadingItems,
|
||||
removeSmartLoadingGroup,
|
||||
removeSmartLoadingItem,
|
||||
removeSmartLoadingPallet,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type {
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingCandidateGroupMember,
|
||||
SmartLoadingPlan,
|
||||
SmartLoadingWorkbenchContainer,
|
||||
} from '../types'
|
||||
@@ -64,7 +68,7 @@ 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 query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], orderStatuses: [] })
|
||||
const query = ref<QueryModel>({ warehouseId: '', customerId: '', inboundCode: '', createdRange: [], expectedArrivalRange: [], orderStatuses: [] })
|
||||
const appliedQuery = ref<QueryModel>({ ...query.value, orderStatuses: [] })
|
||||
const candidatePage = ref(1)
|
||||
const candidatePageSize = ref(20)
|
||||
@@ -76,19 +80,23 @@ let containerRequest = 0
|
||||
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
|
||||
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
|
||||
const selectedContainer = computed(() => containers.value.find((item) => item.bookingId === selectedContainerId.value) || null)
|
||||
const crossChannelAssignment = computed(() => Boolean(
|
||||
selectedCandidate.value?.channelCode
|
||||
&& selectedContainer.value?.channelCode
|
||||
&& selectedCandidate.value.channelCode !== selectedContainer.value.channelCode,
|
||||
))
|
||||
function containerChannelGroup(container: SmartLoadingWorkbenchContainer | null | undefined) {
|
||||
if (!container) return null
|
||||
return SMART_LOADING_CHANNEL_GROUPS.find((group) => group.code === container.channelGroupCode) || null
|
||||
}
|
||||
const crossChannelAssignment = computed(() => {
|
||||
const candidateGroup = resolveSmartLoadingChannelGroup(selectedCandidate.value?.channelCode, selectedCandidate.value?.channelName)
|
||||
const containerGroup = containerChannelGroup(selectedContainer.value)
|
||||
return Boolean(candidateGroup && containerGroup && candidateGroup.code !== containerGroup.code)
|
||||
})
|
||||
const orchestratableContainers = computed(() => containers.value)
|
||||
const completedContainerCount = computed(() => 0)
|
||||
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
|
||||
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelGroupCode).length)
|
||||
const unmappedChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
|
||||
item.channelCode && !resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
|
||||
item.channelGroupCode && !containerChannelGroup(item)
|
||||
)).length)
|
||||
const mappedWorkbenchContainerCount = computed(() => orchestratableContainers.value.filter((item) => (
|
||||
resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)
|
||||
containerChannelGroup(item)
|
||||
)).length)
|
||||
const channelOptions = computed(() => SMART_LOADING_CHANNEL_GROUPS)
|
||||
const warehouseOptions = computed(() => {
|
||||
@@ -97,6 +105,7 @@ const warehouseOptions = computed(() => {
|
||||
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; totalGrossWeightKg: number | null; totalVolumeCbm: number | null }
|
||||
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
|
||||
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
|
||||
@@ -138,7 +147,10 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
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[] = []
|
||||
@@ -146,7 +158,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
for (const row of inboundRows) {
|
||||
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||
if (!group) {
|
||||
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
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])
|
||||
@@ -170,6 +182,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
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),
|
||||
})
|
||||
@@ -199,6 +212,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 180, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 210 },
|
||||
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '请选择创建日期范围', width: 260 },
|
||||
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到货时间', placeholder: '请选择预计到货时间范围', width: 260 },
|
||||
{ key: 'orderStatuses', type: 'select', label: '订单状态', placeholder: '全部订单状态', width: 210, options: candidateOrderStatusOptions, props: { multiple: true, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 2 } },
|
||||
])
|
||||
|
||||
@@ -219,21 +233,24 @@ function preferredContainerId() {
|
||||
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'candidateInbounds' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
|
||||
{ key: 'orderStatusSnapshot', prop: 'orderStatusSnapshot', label: '订单状态', width: 100, slotName: 'orderStatusSnapshot' },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 80 },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '箱数', width: 80 },
|
||||
// { key: 'cartonIdentityStatus', label: '箱号归属', width: 105, slotName: 'cartonIdentityStatus' },
|
||||
{ key: 'unitGrossWeightKg', label: '单箱毛重', width: 80, slotName: 'unitGrossWeight' },
|
||||
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
|
||||
{ key: 'totalGrossWeightKg', label: '重量', width: 90, slotName: 'candidateTotalWeight' },
|
||||
{ key: 'averageUnitVolumeCbm', label: '单箱体积', width: 80, slotName: 'averageUnitVolume' },
|
||||
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
|
||||
{ key: 'totalVolumeCbm', label: '体积', width: 90, slotName: 'candidateTotalVolume' },
|
||||
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
|
||||
]
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜', minWidth: 80, slotName: 'coLoadGroup' },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'planInbounds' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
|
||||
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
||||
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
|
||||
@@ -300,6 +317,9 @@ function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0
|
||||
const createdRange = Array.isArray(appliedQuery.value.createdRange)
|
||||
? appliedQuery.value.createdRange.map((value) => String(value || '').slice(0, 10))
|
||||
: []
|
||||
const expectedArrivalRange = Array.isArray(appliedQuery.value.expectedArrivalRange)
|
||||
? appliedQuery.value.expectedArrivalRange.map((value) => String(value || '').slice(0, 10))
|
||||
: []
|
||||
const params: Parameters<typeof listSmartLoadingCandidates>[0] = {
|
||||
channelGroupCodes: [...selectedChannelCodes.value],
|
||||
page: candidatePage.value,
|
||||
@@ -318,6 +338,10 @@ function candidateQueryParams(): Parameters<typeof listSmartLoadingCandidates>[0
|
||||
params.createdStart = createdRange[0]
|
||||
params.createdEnd = createdRange[1]
|
||||
}
|
||||
if (expectedArrivalRange.length === 2) {
|
||||
params.expectedArrivalStart = expectedArrivalRange[0]
|
||||
params.expectedArrivalEnd = expectedArrivalRange[1]
|
||||
}
|
||||
if (orderStatuses.length) params.orderStatuses = orderStatuses
|
||||
return params
|
||||
}
|
||||
@@ -481,7 +505,8 @@ function openAssign(source: object) {
|
||||
assignOpen.value = true
|
||||
}
|
||||
async function assign() {
|
||||
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
||||
if (!plan.value || !selectedCandidate.value) return
|
||||
if (selectedCandidate.value.unitType !== 'SEALED_PALLET' && assignForm.packageCount < 1) return
|
||||
if (selectedCandidate.value.coLoadGroupId && (selectedCandidate.value.blockReason || selectedCandidate.value.locked)) return
|
||||
if (!selectedCandidate.value.coLoadGroupId && assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
|
||||
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
|
||||
@@ -489,7 +514,9 @@ async function assign() {
|
||||
}
|
||||
await mutate(async () => {
|
||||
const selected = selectedCandidate.value!
|
||||
acceptPlan(selected.orderStatusSnapshot === 'PENDING_REVIEW'
|
||||
acceptPlan(selected.unitType === 'SEALED_PALLET' && selected.palletId
|
||||
? await assignSmartLoadingPallet(plan.value!.id, { palletId: selected.palletId, expectedVersion: plan.value!.version })
|
||||
: selected.orderStatusSnapshot === 'PENDING_REVIEW'
|
||||
? await assignPendingReviewSmartLoadingOrder(plan.value!.id, {
|
||||
customerId: selected.accountId,
|
||||
orderId: selected.orderId,
|
||||
@@ -510,6 +537,10 @@ async function assign() {
|
||||
}, '分配明细失败', { preserveAssign: true })
|
||||
}
|
||||
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)
|
||||
}
|
||||
if (candidate.orderStatusSnapshot === 'PENDING_REVIEW') {
|
||||
return Boolean(candidate.blockReason) || candidate.locked === true
|
||||
|| !candidate.accountId || !candidate.inboundCode || candidate.availablePackageCount < 1
|
||||
@@ -522,15 +553,19 @@ function candidateBlocked(candidate: SmartLoadingCandidateRow) {
|
||||
}
|
||||
async function recommend() {
|
||||
if (!plan.value) return
|
||||
const scope = {
|
||||
customerId: String(appliedQuery.value.customerId || ''),
|
||||
inboundCode: String(appliedQuery.value.inboundCode || '').trim(),
|
||||
}
|
||||
await mutate(async () => {
|
||||
try {
|
||||
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
|
||||
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version, scope))
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '智能推荐失败')
|
||||
if (details.code !== 'A0409' || !/(版本|并发|已被修改|已变化)/.test(details.message)) throw error
|
||||
await loadSelectedPlan()
|
||||
if (!plan.value) throw error
|
||||
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version))
|
||||
acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version, scope))
|
||||
}
|
||||
await loadCandidateData()
|
||||
ElMessage.success('智能推荐已生成')
|
||||
@@ -538,6 +573,7 @@ async function recommend() {
|
||||
}
|
||||
async function adjust(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (item.unitType === 'SEALED_PALLET') return
|
||||
if (!plan.value || mutating.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (count === item.packageCount) return
|
||||
@@ -558,10 +594,14 @@ async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value || !item.canRemove) return
|
||||
const row = source as SmartLoadingPlanRow
|
||||
const sealedPallet = item.unitType === 'SEALED_PALLET' && Boolean(item.palletId)
|
||||
const confirmed = await ElMessageBox.confirm(row.coLoadGroup
|
||||
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消',
|
||||
: sealedPallet
|
||||
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
|
||||
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
|
||||
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
@@ -571,6 +611,12 @@ async function remove(source: object) {
|
||||
ElMessage.success(`同柜不可拆组 ${row.coLoadGroup.groupNo} 已整组移除`)
|
||||
return
|
||||
}
|
||||
if (sealedPallet && item.palletId) {
|
||||
acceptPlan(await removeSmartLoadingPallet(plan.value!.id, item.palletId, plan.value!.version))
|
||||
await loadCandidateData()
|
||||
ElMessage.success(`托盘 ${item.palletNo || '—'} 已整托移除`)
|
||||
return
|
||||
}
|
||||
let current = plan.value!
|
||||
for (const original of item.sourceItems) {
|
||||
const currentItem = current.items.find((value) => value.id === original.id)
|
||||
@@ -652,8 +698,8 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
|
||||
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的渠道未纳入固定 9 类,已隔离`" />
|
||||
<el-alert v-if="missingChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少计划渠道,已隔离;请重新创建模拟柜`" />
|
||||
<el-alert v-if="unmappedChannelContainerCount && !mappedWorkbenchContainerCount" type="warning" :closable="false" show-icon :title="`${unmappedChannelContainerCount} 个可编排柜次的计划渠道未纳入固定 9 类,已隔离`" />
|
||||
<el-alert v-if="completedContainerCount && !orchestratableContainers.length" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
|
||||
<QueryToolbar
|
||||
v-model="query"
|
||||
@@ -703,15 +749,17 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
plain
|
||||
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
|
||||
@click="openAssign(row)"
|
||||
><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '加入' }}</el-button>
|
||||
>加入</el-button>
|
||||
</template>
|
||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><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="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></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 #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
|
||||
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
|
||||
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
|
||||
<template #averageUnitVolume="{ row }">{{ formatMeasurement(row.averageUnitVolumeCbm, 'm³') }}</template>
|
||||
<template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
|
||||
<template #candidateTotalVolume="{ row }">{{ formatMeasurement(row.totalVolumeCbm, 'm³') }}</template>
|
||||
<template #estimatedVolume="{ row }">{{ row.estimatedVolumeCbm == null ? '—' : `${formatNumber(Number(row.estimatedVolumeCbm))} m³` }}</template>
|
||||
<template #cartonIdentityStatus><el-tag size="small" type="info" effect="plain">未核验</el-tag></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
@@ -736,7 +784,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</span>
|
||||
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
|
||||
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
|
||||
<span>渠道:{{ resolveSmartLoadingChannelGroup(item.channelCode, item.channelName)?.name || '渠道待补充' }}</span>
|
||||
<span>渠道:{{ containerChannelGroup(item)?.name || '渠道待补充' }}</span>
|
||||
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
|
||||
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
|
||||
</button>
|
||||
@@ -778,9 +826,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</div>
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #planInbounds="{ 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 }"><div v-if="row.coLoadGroup" class="candidate-group"><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} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></div><LineClampText v-else content="普通单票" /></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-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" @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" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></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} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></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" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
</DataTable>
|
||||
@@ -788,22 +837,27 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<AppDialog v-model="assignOpen" :title="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||
<AppDialog 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.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
|
||||
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ selectedContainer?.channelName || '—' }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
|
||||
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工排柜,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<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.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
|
||||
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
|
||||
<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-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>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -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>
|
||||
@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
||||
import { assignSmartLoadingGroup, assignSmartLoadingItem, getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading, removeSmartLoadingGroup } 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(), assignSmartLoadingGroup: vi.fn(), recommendSmartLoading: 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()) }))
|
||||
@@ -19,24 +19,24 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
|
||||
|
||||
const container = {
|
||||
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', 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: 76, maxWeightKg: 28000,
|
||||
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: [], items: [],
|
||||
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: [], ...candidate }
|
||||
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>>
|
||||
@@ -46,9 +46,12 @@ 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-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
|
||||
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"')
|
||||
})
|
||||
|
||||
@@ -75,6 +78,33 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
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 })
|
||||
@@ -109,7 +139,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 2)
|
||||
expect(recommendSmartLoading).toHaveBeenCalledWith(planResponse.id, 3)
|
||||
expect(wrapper.text()).not.toContain('排柜操作失败')
|
||||
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||
})
|
||||
|
||||
it('uses bordered row actions and has no manual preload push', () => {
|
||||
@@ -156,8 +186,8 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).toContain('3 个入仓片段')
|
||||
expect(wrapper.text()).toContain('ORD-1:IN-201、IN-203')
|
||||
expect(wrapper.text()).toContain('整组 12 箱')
|
||||
expect(wrapper.findAll('button').filter((button) => button.text().includes('整组分配'))).toHaveLength(1)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('整组分配'))!.trigger('click')
|
||||
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()
|
||||
@@ -178,7 +208,7 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
|
||||
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes('整组分配'))!
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('本次计划装柜箱数')
|
||||
})
|
||||
@@ -199,4 +229,28 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
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, ListChecks, Pencil, Plus, RefreshCw, Sparkles, Trash2 } 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'
|
||||
@@ -12,14 +14,14 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import {
|
||||
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, getContainer,
|
||||
adjustSmartLoadingItem, assignSmartLoadingGroup, assignSmartLoadingItem, assignSmartLoadingPallet, getContainer,
|
||||
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
|
||||
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
|
||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingCandidateGroupMember, SmartLoadingPlan } from '../types'
|
||||
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
|
||||
import { resolveSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -46,6 +48,7 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
|
||||
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[] }>()
|
||||
@@ -77,7 +80,10 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
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[] = []
|
||||
@@ -85,7 +91,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
for (const row of inboundRows) {
|
||||
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||
if (!group) {
|
||||
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
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])
|
||||
@@ -111,6 +117,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
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),
|
||||
})
|
||||
@@ -130,7 +137,8 @@ const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
||||
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
||||
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||
{ 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' },
|
||||
@@ -139,14 +147,15 @@ const itemColumns: DataTableColumn[] = [
|
||||
]
|
||||
const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '同柜不可拆', minWidth: 210, slotName: 'coLoadGroup' },
|
||||
{ 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, showOverflowTooltip: true },
|
||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
||||
{ 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: 145, slotName: 'averageUnitVolume' },
|
||||
{ key: 'unitGrossWeightKg', label: '平均单箱毛重', width: 140, slotName: 'unitGrossWeight' },
|
||||
{ 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.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
|
||||
@@ -206,18 +215,19 @@ async function load() {
|
||||
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
|
||||
else throw error
|
||||
}
|
||||
const channelGroup = resolveSmartLoadingChannelGroup(container.value.channelCode, container.value.channelName)
|
||||
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, '智能排柜工作台加载失败') }
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function openOperationalList() {
|
||||
if (!container.value || container.value.planStatus === 'EDITING') return
|
||||
void router.push({
|
||||
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
||||
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
|
||||
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) {
|
||||
@@ -231,12 +241,15 @@ async function assign() {
|
||||
const currentPlan = plan.value
|
||||
const selected = selectedCandidate.value
|
||||
const inboundId = selected?.inboundId
|
||||
if (!currentPlan || !selected || !inboundId || assignForm.packageCount < 1) return
|
||||
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 {
|
||||
const next = selected.coLoadGroupId
|
||||
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)
|
||||
@@ -246,6 +259,10 @@ async function assign() {
|
||||
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))
|
||||
@@ -272,6 +289,7 @@ async function recommend() {
|
||||
}
|
||||
async function adjust(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (item.unitType === 'SEALED_PALLET') return
|
||||
if (!plan.value || mutating.value) return
|
||||
const count = Number(quantityDrafts[item.id])
|
||||
if (count === item.packageCount) return
|
||||
@@ -289,7 +307,7 @@ async function remove(source: object) {
|
||||
const message = row.coLoadGroup
|
||||
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
|
||||
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
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
|
||||
@@ -332,9 +350,9 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<AdminPage class="admin-density">
|
||||
<section class="business-page workbench">
|
||||
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
|
||||
<section class="surface-panel workbench-command-bar" aria-label="装柜作业摘要">
|
||||
<div class="workbench-identities">
|
||||
<div class="workbench-identity">
|
||||
<span>柜次号</span>
|
||||
@@ -346,14 +364,14 @@ onMounted(load)
|
||||
</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 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>
|
||||
<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>
|
||||
@@ -369,62 +387,68 @@ onMounted(load)
|
||||
<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"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></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 }"><div v-if="row.coLoadGroup" class="candidate-group"><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} 箱`" /></div><LineClampText v-else content="普通单票" /></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-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" @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" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></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 #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>
|
||||
</DataTable>
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
<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 v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></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 #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><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="该组已锁定,不能重复分配" /></div><LineClampText v-else content="普通单票" /></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="selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ 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><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ 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" /><small>只记录数量,具体上柜箱号未核验。</small></template></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-identities { display: flex; min-width: 0; align-items: center; gap: 32px; }
|
||||
.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(2,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; }
|
||||
.readonly-status-card { display: grid; justify-items: start; gap: 10px; padding: 24px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.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: 12px; margin-bottom: 10px; font-size: 13px; }
|
||||
.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; }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
|
||||
.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: 6px; }
|
||||
.package-count-summary strong { font-size: 13px; font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
|
||||
.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 10px; }
|
||||
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
.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: 11px; }
|
||||
.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>
|
||||
|
||||
@@ -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/:containerId',
|
||||
name: 'container-detail',
|
||||
component: () => import('./pages/ContainerDetailView.vue'),
|
||||
meta: { title: '货柜详情', description: '查看货柜资料、履约轨迹、单证与异常', permission: 'platform:firstleg:container:list', showPageHeading: false },
|
||||
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[]
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SMART_LOADING_CHANNEL_GROUPS,
|
||||
SMART_LOADING_WAREHOUSE_GROUPS,
|
||||
isSeaRailChannel,
|
||||
resolveSmartLoadingChannelGroup,
|
||||
resolveSmartLoadingWarehouseGroup,
|
||||
} from './smartLoadingChannelGroups'
|
||||
@@ -33,6 +34,11 @@ describe('smartLoadingChannelGroups', () => {
|
||||
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')
|
||||
|
||||
@@ -15,6 +15,12 @@ export const SMART_LOADING_WAREHOUSE_GROUPS: readonly SmartLoadingWarehouseGroup
|
||||
{ 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',
|
||||
@@ -63,6 +69,11 @@ export const SMART_LOADING_CHANNEL_GROUPS: readonly SmartLoadingChannelGroup[] =
|
||||
},
|
||||
] 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) {
|
||||
|
||||
@@ -7,7 +7,7 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
|
||||
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: '货物一', packageCount: 2, productQuantity: 20,
|
||||
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,
|
||||
@@ -15,6 +15,17 @@ function item(overrides: Partial<SmartLoadingItem> = {}): SmartLoadingItem {
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@@ -21,7 +21,9 @@ function joinDistinct(items: SmartLoadingItem[], value: (item: SmartLoadingItem)
|
||||
export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingInboundItem[] {
|
||||
const groups = new Map<string, SmartLoadingItem[]>()
|
||||
for (const item of items) {
|
||||
const key = `${item.accountId}:${item.inboundId}`
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -44,7 +46,7 @@ export function groupSmartLoadingItems(items: SmartLoadingItem[]): SmartLoadingI
|
||||
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: sourceItems.length === 1 && first.canAdjust,
|
||||
canAdjust: first.unitType !== 'SEALED_PALLET' && sourceItems.length === 1 && first.canAdjust,
|
||||
canRemove: sourceItems.every((item) => item.canRemove),
|
||||
sourceItems,
|
||||
detailCount: sourceItems.length,
|
||||
|
||||
@@ -5,7 +5,7 @@ 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: '货物一', packageCount: 5, productQuantity: 50, grossWeightKg: 50, volumeCbm: 1,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const plan = {
|
||||
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
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ export interface ContainerBookingResource {
|
||||
mergedIntoContainerBookingId?: string | null
|
||||
warehouseId: string
|
||||
warehouseName: string | null
|
||||
channelGroupCode: string
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
oceanBookingId?: string | null
|
||||
currentPortScheduleId?: string | null
|
||||
oceanBookingRequirementId?: string | null
|
||||
customerId?: string | null
|
||||
customerName?: string | null
|
||||
@@ -46,12 +48,16 @@ export interface ContainerBookingResource {
|
||||
estimatedEmptyReportedAt?: string | null
|
||||
actualEmptyReportedAt?: string | null
|
||||
emptyReportSourceVersion?: string | null
|
||||
pickupCode?: string | null
|
||||
pickupCodeUpdatedBy?: string | null
|
||||
pickupCodeUpdatedByName?: string | null
|
||||
pickupCodeUpdatedAt?: string | null
|
||||
planStatus: ContainerPlanStatus
|
||||
exceptionActive?: boolean
|
||||
destinationClearanceDocumentStatus?: ContainerDocumentStatus
|
||||
customsDeclarationDocumentStatus?: ContainerDocumentStatus
|
||||
deliveryOrderDocumentStatus?: ContainerDocumentStatus
|
||||
pickupOrderDocumentStatus?: ContainerDocumentStatus
|
||||
outboundConfirmedAt?: string | null
|
||||
version: number
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
@@ -59,7 +65,7 @@ export interface ContainerBookingResource {
|
||||
}
|
||||
|
||||
export interface ContainerSimulationCreateRequest {
|
||||
channelCode: string
|
||||
channelGroupCode: string
|
||||
warehouseId: string
|
||||
containerType: string
|
||||
capacityCbm: number
|
||||
@@ -88,7 +94,7 @@ export interface BusinessDocument {
|
||||
version: number
|
||||
}
|
||||
|
||||
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
export type ContainerDocumentType = 'DESTINATION_CLEARANCE' | 'DELIVERY_ORDER'
|
||||
export type ContainerDocumentStatus = 'MISSING' | 'UPLOADED' | 'COMPLETED'
|
||||
|
||||
export interface ContainerOverviewDocument {
|
||||
@@ -169,7 +175,7 @@ export interface ContainerTimelineEntry {
|
||||
}
|
||||
|
||||
export interface ContainerOverview {
|
||||
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'version'> & { status: string }
|
||||
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
|
||||
loadingPlanId: string | null
|
||||
pda: ContainerPdaSummary | null
|
||||
documents: ContainerOverviewDocument[]
|
||||
@@ -179,7 +185,7 @@ export interface ContainerOverview {
|
||||
}
|
||||
|
||||
export interface ContainerPage {
|
||||
records: ContainerBookingResource[]
|
||||
records: ContainerWorkbenchRow[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
@@ -198,6 +204,71 @@ export interface ContainerQuery {
|
||||
status?: ContainerPlanStatus | ''
|
||||
estimatedSailingFrom?: string
|
||||
estimatedSailingTo?: string
|
||||
scope?: ContainerWorkbenchScope
|
||||
}
|
||||
|
||||
export type ContainerWorkbenchKind = 'DOMESTIC' | 'OVERSEAS'
|
||||
export type ContainerWorkbenchScope = 'ACTIVE' | 'HISTORY'
|
||||
export type ContainerWorkbenchAction = 'EDIT_CONTAINER' | 'MANAGE_CUSTOMS' | 'CONFIRM_OUTBOUND' | 'MANAGE_DESTINATION_DOCUMENTS' | 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL'
|
||||
|
||||
export interface ContainerWorkbenchStatusSummary {
|
||||
loadingStatus?: string | null
|
||||
customsStatus?: string | null
|
||||
outboundStatus?: string | null
|
||||
destinationClearanceStatus?: string | null
|
||||
deliveryOrderStatus?: string | null
|
||||
pickupCodeStatus?: string | null
|
||||
latestActualNode?: string | null
|
||||
latestActualAt?: string | null
|
||||
terminalDrayageStatus?: string | null
|
||||
emptyReturnWarningStatus?: string | null
|
||||
}
|
||||
|
||||
export interface ContainerWorkbenchActualFacts {
|
||||
actualSailingAt?: string | null
|
||||
actualPortArrivalAt?: string | null
|
||||
actualWarehouseArrivalAt?: string | null
|
||||
unloadingCompletedAt?: string | null
|
||||
actualEmptyReturnAt?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ContainerWorkbenchTerminalDrayage {
|
||||
containerBookingId: string
|
||||
status: string
|
||||
version: number
|
||||
fleetSupplierId: string | null
|
||||
fleetSupplierName: string | null
|
||||
pickupAppointmentAt: string | null
|
||||
actualPickupAt: string | null
|
||||
estimatedEmptyReturnAt: string | null
|
||||
actualEmptyReturnAt: string | null
|
||||
unloadingCompletedAt: string | null
|
||||
emptyReturnWarning: { status: 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED'; deadlineAt: string | null; overdueHours: number; overdueDays: number }
|
||||
allowedActions: ReadonlyArray<'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_EMPTY_RETURN' | 'SEND_PICKUP_EMAIL'>
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface ContainerWorkbenchRow extends ContainerBookingResource {
|
||||
workbenchStage?: string | null
|
||||
statusSummary?: ContainerWorkbenchStatusSummary
|
||||
allowedActions?: ContainerWorkbenchAction[]
|
||||
actualFacts?: ContainerWorkbenchActualFacts
|
||||
terminalDrayage?: ContainerWorkbenchTerminalDrayage | null
|
||||
}
|
||||
|
||||
/** Wire shape returned by the domestic and overseas workbench query endpoints. */
|
||||
export interface ContainerWorkbenchItem {
|
||||
container: ContainerBookingResource
|
||||
workbenchStage: string
|
||||
statusSummary: ContainerWorkbenchStatusSummary
|
||||
allowedActions: ContainerWorkbenchAction[]
|
||||
actualFacts: ContainerWorkbenchActualFacts | null
|
||||
terminalDrayage: ContainerWorkbenchTerminalDrayage | null
|
||||
}
|
||||
|
||||
export interface ContainerWorkbenchPage extends Omit<ContainerPage, 'records'> {
|
||||
records: ContainerWorkbenchItem[]
|
||||
}
|
||||
|
||||
export interface ContainerSaveRequest {
|
||||
@@ -246,17 +317,23 @@ export interface SmartLoadingCandidate {
|
||||
destinationWarehouseName: string | null
|
||||
customerName: string | null
|
||||
goodsSummary: string | null
|
||||
unitType: 'PACKAGE' | 'SEALED_PALLET'
|
||||
palletId: string | null
|
||||
palletNo: string | null
|
||||
indivisible: boolean
|
||||
availablePackageCount: number
|
||||
unitGrossWeightKg: number | null
|
||||
unitNetWeightKg: number | null
|
||||
averageUnitVolumeCbm: number | null
|
||||
availableVolumeCbm: number | null
|
||||
estimatedVolumeCbm: number | null
|
||||
coLoadGroupId: string | null
|
||||
groupNo: string | null
|
||||
memberCount: number | null
|
||||
groupTotalBoxes: number | null
|
||||
locked: boolean | null
|
||||
blockReason: string | null
|
||||
/** Complete co-load group or sealed-pallet membership; pallet member counts are inbound-specific. */
|
||||
groupMembers: SmartLoadingCandidateGroupMember[]
|
||||
}
|
||||
|
||||
@@ -279,6 +356,8 @@ export interface SmartLoadingCandidateQuery {
|
||||
inboundCode?: string
|
||||
createdStart?: string
|
||||
createdEnd?: string
|
||||
expectedArrivalStart?: string
|
||||
expectedArrivalEnd?: string
|
||||
orderStatuses?: Array<'PENDING_REVIEW' | 'WAIT_MEASURE' | 'WAIT_SPLIT' | 'LOADING'>
|
||||
page: number
|
||||
size: number
|
||||
@@ -299,8 +378,9 @@ export interface SmartLoadingWorkbenchContainer {
|
||||
containerNo: string | null
|
||||
warehouseId: string
|
||||
warehouseName: string
|
||||
channelCode: string
|
||||
channelName: string
|
||||
channelGroupCode: string
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
containerType: string
|
||||
capacityCbm: number | null
|
||||
maxWeightKg: number | null
|
||||
@@ -333,6 +413,10 @@ export interface SmartLoadingItem {
|
||||
destinationWarehouseName: string | null
|
||||
skuCode: string | null
|
||||
productNameZh: string | null
|
||||
unitType: 'PACKAGE' | 'SEALED_PALLET'
|
||||
palletId: string | null
|
||||
palletNo: string | null
|
||||
indivisible: boolean
|
||||
packageCount: number
|
||||
productQuantity: number | null
|
||||
grossWeightKg: number | null
|
||||
@@ -371,5 +455,9 @@ export interface SmartLoadingPlan {
|
||||
fullPackageCount: number
|
||||
memberInboundIds: string[]
|
||||
}>
|
||||
palletMembers: Array<{
|
||||
palletId: string
|
||||
members: SmartLoadingCandidateGroupMember[]
|
||||
}>
|
||||
items: SmartLoadingItem[]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||
import { Pencil, Plus, RefreshCw, Save, Trash2 } from '@lucide/vue'
|
||||
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -45,7 +47,7 @@ async function openEditor() {
|
||||
}
|
||||
async function closeEditor() {
|
||||
if (saving.value) return
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
visible.value = false
|
||||
}
|
||||
function validate() {
|
||||
@@ -69,25 +71,25 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overseas-fee-panel">
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor"><Pencil :size="16" />编辑费用</el-button></header>
|
||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load"><RefreshCw :size="16" />重新加载</el-button></div>
|
||||
<section class="overseas-fee-panel admin-density customer-density">
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
|
||||
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
||||
<template v-else-if="config">
|
||||
<div class="fee-meta"><span>币种:<strong>{{ config.currency }}</strong></span><span>按整组保存,版本 {{ config.version }}</span></div>
|
||||
<div class="fee-groups"><div><h3>仓租({{ config.currency }}/CBM/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div><div><h3>退货仓储费({{ config.currency }}/托/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'RETURN_STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div></div>
|
||||
</template>
|
||||
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
||||
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)"><Trash2 :size="15" />删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存费用</el-button></div></template>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入价格" /></td></tr></tbody></table></div></div></el-form>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups > div,.edit-groups > div { min-width:0; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.edit-groups table { table-layout:fixed; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child,.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||
.edit-group-heading h3 { min-width:0; }
|
||||
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .edit-groups { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
|
||||
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
|
||||
|
||||
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn(), updateCustomerDimensionDefaultCalculationMode: vi.fn() }))
|
||||
vi.mock('@/modules/fba-pricing', () => ({
|
||||
@@ -21,6 +22,7 @@ vi.mock('@/modules/fba-pricing', () => ({
|
||||
retireCustomerChargeRule: vi.fn(),
|
||||
setCustomerChargeRuleDefault: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/modules/iam', () => ({ getPlatformTenantDimensionInitializationStatus: vi.fn() }))
|
||||
|
||||
const boxes = [
|
||||
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
|
||||
@@ -64,6 +66,10 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
vi.mocked(updateCustomerDimensionDefaultCalculationMode).mockResolvedValue(config)
|
||||
vi.mocked(listCustomerChargeRules).mockResolvedValue([chargeRule])
|
||||
vi.mocked(createCustomerChargeRule).mockResolvedValue(chargeRule)
|
||||
vi.mocked(getPlatformTenantDimensionInitializationStatus).mockResolvedValue({
|
||||
tenantId: '8', status: 'PENDING', attemptCount: 0, nextRetryAt: null,
|
||||
lastErrorCode: null, lastErrorMessage: null, updatedAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the four fixed carton dimension rows and calculated values', async () => {
|
||||
@@ -93,6 +99,42 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
expect(getCustomerDimensionConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not request billing rules before customer defaults are ready', async () => {
|
||||
mount(CustomerBillingRulesPanel, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
customerId: '8',
|
||||
customerName: '柏悦物流',
|
||||
dimensionInitializationStatus: 'PENDING',
|
||||
},
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('客户计费规则正在初始化')
|
||||
expect(document.body.textContent).not.toContain('箱规尺寸规则加载失败')
|
||||
expect(getCustomerDimensionConfig).not.toHaveBeenCalled()
|
||||
expect(listCustomerChargeRules).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads billing rules automatically when asynchronous initialization becomes ready', async () => {
|
||||
vi.mocked(getPlatformTenantDimensionInitializationStatus).mockResolvedValueOnce({
|
||||
tenantId: '8', status: 'READY', attemptCount: 1, nextRetryAt: null,
|
||||
lastErrorCode: null, lastErrorMessage: null, updatedAt: '2026-09-10T20:04:06',
|
||||
})
|
||||
mount(CustomerBillingRulesPanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '8', customerName: '柏悦物流', dimensionInitializationStatus: 'PENDING' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformTenantDimensionInitializationStatus).toHaveBeenCalledWith('8')
|
||||
expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8')
|
||||
expect(listCustomerChargeRules).toHaveBeenCalledWith('8')
|
||||
expect(document.body.textContent).not.toContain('箱规尺寸规则加载失败')
|
||||
})
|
||||
|
||||
it('submits all four rows in one update request', async () => {
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Pencil, Ruler, Save } from '@lucide/vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Ruler } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
@@ -18,13 +20,24 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
|
||||
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>()
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
customerName: string
|
||||
customerCode?: string
|
||||
dimensionInitializationStatus?: string
|
||||
}>()
|
||||
const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('firstleg:dimension-config:view'))
|
||||
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
|
||||
const canChargeView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
|
||||
const canChargeEdit = computed(() => canChargeView.value && access.can('firstleg:fba-pricing:rule:edit'))
|
||||
const observedInitializationStatus = ref(props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY')
|
||||
const initializationStatus = computed(() => observedInitializationStatus.value)
|
||||
const initializationPending = computed(() => ['PENDING', 'SENDING'].includes(initializationStatus.value))
|
||||
const initializationFailed = computed(() => initializationStatus.value === 'FAILED')
|
||||
const initializationReady = computed(() => !initializationPending.value && !initializationFailed.value)
|
||||
const config = ref<CustomerDimensionConfig | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -34,6 +47,8 @@ const editorDirty = ref(false)
|
||||
const defaultCalculationMode = ref<'ACTUAL' | 'DIMENSION' | 'WEIGHT'>('ACTUAL')
|
||||
const defaultModeSaving = ref(false)
|
||||
const editorForm = ref<FormInstance>()
|
||||
let initializationPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let initializationPollGeneration = 0
|
||||
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
|
||||
const chargeRules = ref<CustomerChargeRule[]>([])
|
||||
const visibleChargeRules = computed(() => chargeRules.value.filter((rule) => rule.status === 'ACTIVE'))
|
||||
@@ -90,18 +105,64 @@ watch(form, () => {
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.customerId, () => {
|
||||
stopInitializationPolling()
|
||||
observedInitializationStatus.value = props.dimensionInitializationStatus?.trim().toUpperCase() || 'READY'
|
||||
editorVisible.value = false
|
||||
defaultCalculationMode.value = 'ACTUAL'
|
||||
void loadConfig()
|
||||
if (initializationReady.value) {
|
||||
void loadConfig()
|
||||
void loadChargeRules()
|
||||
} else if (initializationPending.value) {
|
||||
startInitializationPolling()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.dimensionInitializationStatus, (status) => {
|
||||
observedInitializationStatus.value = status?.trim().toUpperCase() || 'READY'
|
||||
if (initializationPending.value) startInitializationPolling()
|
||||
else stopInitializationPolling()
|
||||
})
|
||||
|
||||
watch(initializationReady, (ready, previous) => {
|
||||
if (ready && !previous) {
|
||||
void loadConfig()
|
||||
void loadChargeRules()
|
||||
}
|
||||
})
|
||||
|
||||
function stopInitializationPolling() {
|
||||
initializationPollGeneration += 1
|
||||
if (initializationPollTimer) clearTimeout(initializationPollTimer)
|
||||
initializationPollTimer = null
|
||||
}
|
||||
|
||||
function startInitializationPolling() {
|
||||
if (initializationPollTimer) return
|
||||
const generation = ++initializationPollGeneration
|
||||
const tenantId = props.customerId
|
||||
const poll = async () => {
|
||||
initializationPollTimer = null
|
||||
try {
|
||||
const result = await getPlatformTenantDimensionInitializationStatus(tenantId)
|
||||
if (generation !== initializationPollGeneration || tenantId !== props.customerId) return
|
||||
observedInitializationStatus.value = result.status?.trim().toUpperCase() || 'PENDING'
|
||||
} catch {
|
||||
// Initialization is retried by IAM. A transient diagnostic failure must not trigger the dimension API early.
|
||||
}
|
||||
if (generation === initializationPollGeneration && tenantId === props.customerId && initializationPending.value) {
|
||||
initializationPollTimer = setTimeout(poll, 2_000)
|
||||
}
|
||||
}
|
||||
void poll()
|
||||
}
|
||||
|
||||
function sortRules(rules: CustomerDimensionRule[]) {
|
||||
return [...rules].sort((a, b) => BOX_ORDER.indexOf(a.boxType) - BOX_ORDER.indexOf(b.boxType))
|
||||
.map((rule) => ({ ...rule, boxTypeName: BOX_NAMES[rule.boxType] || rule.boxTypeName }))
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
if (!canView.value) return
|
||||
if (!canView.value || !initializationReady.value) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
@@ -136,7 +197,7 @@ async function openEditor() {
|
||||
async function confirmAbandon(): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', '放弃编辑', {
|
||||
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
@@ -266,7 +327,7 @@ function formatDate(value: string | null) {
|
||||
}
|
||||
|
||||
async function loadChargeRules() {
|
||||
if (!canChargeView.value) return
|
||||
if (!canChargeView.value || !initializationReady.value) return
|
||||
chargeLoading.value = true
|
||||
try {
|
||||
chargeRules.value = await listCustomerChargeRules(props.customerId)
|
||||
@@ -287,23 +348,41 @@ async function loadChargeRules() {
|
||||
finally { chargeLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { void loadConfig(); void loadChargeRules() })
|
||||
onMounted(() => {
|
||||
if (initializationPending.value) {
|
||||
startInitializationPolling()
|
||||
return
|
||||
}
|
||||
if (!initializationReady.value) return
|
||||
void loadConfig()
|
||||
void loadChargeRules()
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopInitializationPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dimension-rule-panel">
|
||||
<div v-if="!canView || loadError" class="panel-title rule-heading">
|
||||
<div class="dimension-rule-panel admin-density customer-density">
|
||||
<div v-if="initializationPending" class="rule-state initialization-state" role="status">
|
||||
<Ruler :size="30" />
|
||||
<strong>客户计费规则正在初始化</strong>
|
||||
<p>默认箱规和权重系数正在后台准备,完成后本页会自动加载。</p>
|
||||
</div>
|
||||
<div v-else-if="initializationFailed" class="rule-error initialization-state" role="alert">
|
||||
<div><strong>客户计费规则初始化失败</strong><p>请在客户管理中重试初始化,成功后再查看计费规则。</p></div>
|
||||
</div>
|
||||
<div v-if="initializationReady && (!canView || loadError)" class="panel-title rule-heading">
|
||||
<div><h2>按箱规尺寸计算</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的尺寸计算规则</p></div>
|
||||
</div>
|
||||
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
|
||||
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
|
||||
<section v-else-if="config" class="rule-card">
|
||||
<div v-if="initializationReady && !canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>请联系管理员申请尺寸计算规则查看权限。</p></div>
|
||||
<div v-else-if="initializationReady && loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
|
||||
<section v-else-if="initializationReady && config" class="rule-card">
|
||||
<header class="rule-card-heading">
|
||||
<div class="rule-title-row">
|
||||
<h2>按箱规尺寸计算</h2>
|
||||
<el-checkbox :model-value="defaultCalculationMode === 'DIMENSION'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('DIMENSION', Boolean($event))">设为默认规则</el-checkbox>
|
||||
</div>
|
||||
<el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button>
|
||||
<el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor">编辑规则</el-button>
|
||||
</header>
|
||||
<div class="formula-band"><span>计算方式:<strong>长、宽、高各加 2X = 外径尺寸</strong>(X 为单边加宽,按实测尺寸匹配箱规)</span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>低于最低档使用最低档,超出当前档自动匹配下一档,超出最大档使用最大档</span></div>
|
||||
<DataTable :data="config.rules" :columns="columns" :loading="loading" row-key="boxType" empty-text="当前客户暂无箱规尺寸规则">
|
||||
@@ -315,9 +394,9 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })
|
||||
<template #empty><div class="rule-state"><Ruler :size="28" /><strong>当前客户暂无箱规尺寸规则</strong></div></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
<div v-else v-loading="loading" class="loading-state" />
|
||||
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
|
||||
|
||||
<section v-if="canChargeView" class="rule-card charge-rule-card">
|
||||
<section v-if="initializationReady && canChargeView" class="rule-card charge-rule-card">
|
||||
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算</h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div><p class="section-hint">勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></div></header>
|
||||
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
|
||||
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
|
||||
@@ -331,17 +410,17 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<AppDialog v-model="editorVisible" title="编辑箱规尺寸 / 计算规则" width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" title="编辑箱规尺寸 / 计算规则" width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<div class="editor-content">
|
||||
<p class="editor-formula">计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||
<el-form ref="editorForm" :model="form" label-position="top">
|
||||
<div class="editor-table-wrap">
|
||||
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr>
|
||||
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" placeholder="请输入边长加值" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="rule-save-row"><el-button type="primary" :loading="editorSaving" @click="saveConfig"><Save :size="16" />保存规则</el-button></div>
|
||||
<section v-if="canChargeView" class="combined-charge-editor"><h3>箱规按权重系数计算</h3><p class="editor-formula">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="charge-rule-editor"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th>{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr></tbody></table></div><div class="rule-save-row"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig"><Save :size="16" />保存系数规则</el-button></div></section>
|
||||
<div class="rule-save-row"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
|
||||
<section v-if="canChargeView" class="combined-charge-editor"><h3>箱规按权重系数计算</h3><p class="editor-formula">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="charge-rule-editor"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th>{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||
@@ -352,23 +431,23 @@ onMounted(() => { void loadConfig(); void loadChargeRules() })
|
||||
<style scoped>
|
||||
.dimension-rule-panel { min-width: 0; }
|
||||
.rule-heading { align-items: center; }
|
||||
.rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.heading-actions,.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.heading-actions :deep(.el-button),.dialog-actions :deep(.el-button) { margin-left: 0; }
|
||||
.rule-card { min-width: 0; }.rule-card-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.formula-band { display: flex; min-height: 52px; align-items: center; flex-wrap: wrap; gap: 12px 30px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 13px; }.formula-band strong { color: #344054; }.loading-state { min-height: 280px; }
|
||||
.rule-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }.rule-title-row :deep(.el-checkbox) { height: 30px; margin-right: 0; padding: 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }.rule-title-row :deep(.el-checkbox.is-checked) { border-color: var(--cc-primary); background: var(--cc-primary-soft); }
|
||||
.rule-state { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: 13px; }.rule-state p { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.rule-error { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: 12px; }
|
||||
.volume-value { color: #1570ef; }.editor-content { display: grid; gap: 20px; }.editor-formula { margin: 0; padding: 13px 15px; color: #475467; font-size: 13px; line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th,.editor-table td { padding: 10px 12px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: 12px; }.editor-table tbody th { color: var(--cc-text); font-size: 13px; }.editor-table :deep(.el-input-number),.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }
|
||||
.charge-rule-card { margin-top: 18px; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; font-weight: 400; }.charge-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }.charge-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.charge-form :deep(.el-input-number), .charge-form :deep(.el-input) { width: 100%; }
|
||||
.rule-heading h2 { margin: 0; color: var(--cc-text); }.rule-heading p { margin: var(--admin-space-2, 6px) 0 0; color: var(--cc-muted); }
|
||||
.heading-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||||
.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }.heading-actions :deep(.el-button) { margin-left: 0; }.dialog-actions :deep(.el-button) { margin-left: 0; }
|
||||
.rule-card { min-width: 0; }.rule-card-heading { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); }.formula-band { display: flex; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--cc-border); background: var(--admin-label-bg, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-body, 13px); }.formula-band strong { color: #344054; }
|
||||
.rule-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }.rule-title-row :deep(.el-checkbox) { height: 30px; margin-right: 0; padding: 0 var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-radius: 6px; background: var(--admin-surface, #fff); }.rule-title-row :deep(.el-checkbox.is-checked) { border-color: var(--cc-primary); background: var(--cc-primary-soft); }
|
||||
.rule-state { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: var(--admin-space-3, 8px); color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: var(--admin-font-body, 13px); }.rule-state p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.rule-error { display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: var(--admin-font-caption, 12px); }
|
||||
.volume-value { color: #1570ef; }.editor-content { display: grid; }.editor-formula { margin: 0; color: #475467; font-size: var(--admin-font-body, 13px); line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table td { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: var(--admin-font-caption, 12px); }.editor-table tbody th { color: var(--cc-text); font-size: var(--admin-font-body, 13px); }.editor-table :deep(.el-input-number) { width: 140px; }.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }.charge-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }.charge-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.charge-form :deep(.el-input-number) { width: 100%; }.charge-form :deep(.el-input) { width: 100%; }
|
||||
.charge-form--simple { grid-template-columns: 1fr; }.charge-form--simple :deep(.el-form-item) { grid-column: 1; }
|
||||
.charge-rule-editor-wrap { overflow-x: auto; }.charge-rule-editor { width: 100%; min-width: 920px; border-collapse: collapse; }.charge-rule-editor th,.charge-rule-editor td { padding: 9px 10px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor thead th { background: #eef0fa; color: #475467; font-size: 12px; }.charge-rule-editor tbody th { color: var(--cc-text); font-size: 13px; }.charge-rule-editor :deep(.el-input-number),.charge-rule-editor :deep(.el-input) { width: 118px; }.charge-rule-editor :deep(.el-input-number .el-input__wrapper) { width: 100%; }
|
||||
.charge-rule-editor-wrap { overflow-x: auto; }.charge-rule-editor { width: 100%; min-width: 920px; border-collapse: collapse; }.charge-rule-editor th { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor td { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor thead th { background: #eef0fa; color: #475467; font-size: var(--admin-font-caption, 12px); }.charge-rule-editor tbody th { color: var(--cc-text); font-size: var(--admin-font-body, 13px); }.charge-rule-editor :deep(.el-input-number) { width: 118px; }.charge-rule-editor :deep(.el-input) { width: 118px; }.charge-rule-editor :deep(.el-input-number .el-input__wrapper) { width: 100%; }
|
||||
.charge-box-cell { min-height: 34px; display: flex; align-items: center; border-bottom: 1px solid var(--cc-border); }.charge-box-cell:last-child { border-bottom: 0; }
|
||||
.rule-save-row { display: flex; justify-content: flex-end; padding: 12px 0 4px; }
|
||||
.dialog-actions { justify-content: center; width: 100%; }
|
||||
.rule-save-row { display: flex; justify-content: flex-end; padding: var(--admin-space-5, 12px) 0 var(--admin-space-1, 4px); }
|
||||
.dialog-actions { width: 100%; }
|
||||
.dialog-actions :deep(.el-button) { min-width: 112px; min-height: 46px; font-size: 15px; }
|
||||
.charge-rule-card :deep(.el-table) { color: #344054; font-size: 13px; }
|
||||
.charge-rule-card :deep(.el-table th.el-table__cell) { background: #f8fafc; color: #475467; font-size: 13px; font-weight: 650; }
|
||||
.charge-rule-card :deep(.el-table td.el-table__cell) { color: #344054; font-size: 13px; }
|
||||
.charge-rule-card :deep(.el-table) { color: #344054; font-size: var(--admin-font-body, 13px); }
|
||||
.charge-rule-card :deep(.el-table th.el-table__cell) { background: var(--admin-label-bg, #f8fafc); color: #475467; font-size: var(--admin-font-body, 13px); font-weight: 650; }
|
||||
.charge-rule-card :deep(.el-table td.el-table__cell) { color: #344054; font-size: var(--admin-font-body, 13px); }
|
||||
.charge-rule-card :deep(.el-table .cell) { line-height: 1.5; text-align: center; }
|
||||
.charge-rule-card :deep(.el-table .charge-remark-column .cell) { text-align: left; }
|
||||
.charge-rule-card :deep(.el-table strong) { color: #1570ef; font-weight: 650; }
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Customer menu workspaces and their explicitly opted-in overlays. */
|
||||
/* Only remove decoration from text actions; retain icon-only paging/settings controls. */
|
||||
.customer-density.admin-density .el-button :is(.lucide-search, .lucide-rotate-ccw, .lucide-copy),
|
||||
.customer-density-dialog.admin-density-dialog .el-button .lucide-copy { display: none; }
|
||||
|
||||
.customer-density.admin-density.customer-detail-page .detail-page-layout { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density.customer-detail-page .detail-header { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); background: var(--admin-surface, #fff); border: 1px solid var(--cc-border); }
|
||||
.customer-density.admin-density .detail-page-toolbar h1 { font-size: var(--admin-font-title, 16px); line-height: 24px; }
|
||||
.customer-density.admin-density .detail-page-toolbar p { margin-top: 2px; font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density.admin-density .tab-panel { padding-top: 0; min-height: 0; background: var(--admin-surface, #fff); border: 1px solid var(--cc-border); border-top: 0; }
|
||||
.customer-density.admin-density .basic-layout { grid-template-columns: 1fr; gap: 0; }
|
||||
.customer-density.admin-density .detail-panel { padding: var(--admin-space-5, 12px); border: 0; border-bottom: 1px solid var(--cc-border); border-radius: 0; background: var(--admin-surface, #fff); box-shadow: none; }
|
||||
.customer-density.admin-density .detail-panel:last-child { border-bottom: 0; }
|
||||
.customer-density.admin-density .module-panel { min-height: 0; }
|
||||
.customer-density.admin-density .panel-title { align-items: center; gap: var(--admin-space-5, 12px); margin-bottom: var(--admin-space-3, 8px); padding-bottom: var(--admin-space-3, 8px); }
|
||||
.customer-density.admin-density .panel-title h2 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .panel-title p { margin-top: 3px; font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density.admin-density .info-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.customer-density.admin-density .info-grid--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.customer-density.admin-density .qualification-list { gap: 0; border: 1px solid var(--cc-border); }
|
||||
.customer-density.admin-density .qualification-list button { grid-template-columns: minmax(0, 1fr) 100px 120px 48px; gap: var(--admin-space-5, 12px); min-height: 40px; padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); border: 0; border-bottom: 1px solid var(--cc-border); border-radius: 0; }
|
||||
.customer-density.admin-density .qualification-list button:last-child { border-bottom: 0; }
|
||||
.customer-density.admin-density .qualification-list strong { font-size: var(--admin-font-body, 13px); }
|
||||
.customer-density.admin-density .qualification-list small { overflow-wrap: anywhere; font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density.admin-density .file-date { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density.admin-density .file-preview-label { color: var(--cc-primary); font-size: var(--admin-font-body, 13px); }
|
||||
.customer-density.admin-density .section-empty,.customer-density.admin-density .module-empty { min-height: var(--admin-empty-height, 64px); padding: var(--admin-space-5, 12px); gap: var(--admin-space-1, 4px); }
|
||||
.customer-density.admin-density .section-empty > svg,.customer-density.admin-density .module-empty > svg { display: none; }
|
||||
.customer-density.admin-density .section-empty span { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density.admin-density .user-table-wrap { border: 1px solid var(--cc-border); border-radius: 0; }
|
||||
.customer-density.admin-density .user-table-wrap table { font-size: var(--admin-font-body, 13px); }
|
||||
.customer-density.admin-density .user-table-wrap th,.customer-density.admin-density .user-table-wrap td { height: var(--admin-row-height, 36px); padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); }
|
||||
.customer-density.admin-density .user-identity > span { display: none; }
|
||||
.customer-density.admin-density .user-identity small,.customer-density.admin-density .member-contact small { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .form-section { padding: var(--admin-space-4, 10px) 0; }
|
||||
.customer-density-dialog.admin-density-dialog .form-section__heading { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .form-grid { gap: 0 var(--admin-space-6, 16px); }
|
||||
.customer-density-dialog.admin-density-dialog .field-hint { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-row > * { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-header > * { min-height: var(--admin-row-height, 36px); }
|
||||
.customer-density-dialog.admin-density-dialog .file-picker { min-height: var(--admin-control-height, 32px); }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-file-name { max-width: none; overflow-wrap: anywhere; white-space: normal; }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-preview-action { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-content { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-header { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-radius: 0; background: var(--admin-surface, #fff); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-title strong { white-space: normal; overflow-wrap: anywhere; }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-icon { display: none; }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-grid { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-section,.customer-density-dialog.admin-density-dialog .review-decision-card { padding: var(--admin-space-4, 10px); border-radius: 0; box-shadow: none; }
|
||||
.customer-density-dialog.admin-density-dialog .review-section-heading { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-section-heading span { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-info-grid { grid-template-columns: 1fr; }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-table { border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-header { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-name { white-space: normal; }
|
||||
.customer-density-dialog.admin-density-dialog .decision-summary { margin-bottom: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .approval-credentials { margin-top: var(--admin-space-5, 12px); border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .approval-credentials > div { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); }
|
||||
|
||||
.customer-density.admin-density .rule-heading,.customer-density.admin-density .rule-card-heading,.customer-density.admin-density .panel-heading { min-height: 0; padding: var(--admin-space-3, 8px) 0; gap: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .rule-heading h2,.customer-density.admin-density .rule-card-heading h2,.customer-density.admin-density .panel-heading h2 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .rule-heading p,.customer-density.admin-density .panel-heading p { margin-top: 3px; font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density.admin-density .formula-band { min-height: var(--admin-row-height, 36px); padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); gap: var(--admin-space-2, 6px) var(--admin-space-7, 24px); }
|
||||
.customer-density.admin-density .rule-state,.customer-density.admin-density .rule-error,.customer-density.admin-density .loading-state,.customer-density.admin-density .fee-loading,.customer-density.admin-density .fee-empty { min-height: var(--admin-empty-height, 64px); padding: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .rule-state > svg { display: none; }
|
||||
.customer-density.admin-density .charge-rule-card { margin-top: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .overseas-fee-panel { margin-top: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .fee-groups { gap: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) 0; }
|
||||
.customer-density.admin-density .fee-groups h3,.customer-density-dialog.admin-density-dialog .edit-groups h3,.customer-density-dialog.admin-density-dialog .groups h3 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .fee-groups th,.customer-density.admin-density .fee-groups td { padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); }
|
||||
.customer-density-dialog.admin-density-dialog .editor-content { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .editor-formula { padding: var(--admin-space-2, 6px) 0; }
|
||||
.customer-density-dialog.admin-density-dialog .editor-table th,.customer-density-dialog.admin-density-dialog .editor-table td,
|
||||
.customer-density-dialog.admin-density-dialog .charge-rule-editor th,.customer-density-dialog.admin-density-dialog .charge-rule-editor td,
|
||||
.customer-density-dialog.admin-density-dialog .edit-groups th,.customer-density-dialog.admin-density-dialog .edit-groups td { padding: var(--admin-space-1, 4px) var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .dialog-actions { justify-content: flex-end; }
|
||||
.customer-density-dialog.admin-density-dialog .dialog-actions .el-button { min-height: var(--admin-control-height, 32px); min-width: 64px; font-size: var(--admin-font-body, 13px); }
|
||||
.customer-density-dialog.admin-density-dialog .edit-groups,.customer-density-dialog.admin-density-dialog .groups { gap: var(--admin-space-5, 12px); padding: 0; }
|
||||
|
||||
@media (max-width: 960px) { .customer-density.admin-density .info-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width: 720px) { .customer-density.admin-density .info-grid { grid-template-columns: 1fr; }.customer-density.admin-density .qualification-list button { grid-template-columns: minmax(0,1fr) auto; }.customer-density.admin-density .qualification-list .file-date { white-space: normal; } }
|
||||
|
||||
.customer-density-dialog .review-info-grid { --admin-field-label-width: 96px; }
|
||||
@@ -26,11 +26,11 @@ describe('customer overseas storage fee api', () => {
|
||||
|
||||
it('keeps overseas fee business actions as semantic buttons at narrow widths', () => {
|
||||
expect(overseasListSource).toContain('border stripe')
|
||||
expect(overseasListSource).toContain('size="small" plain type="primary"')
|
||||
expect(overseasListSource).toContain('class="density-action" size="small" plain')
|
||||
expect(overseasListSource).toContain('size="small" plain type="danger"')
|
||||
expect(overseasListSource).toContain('<el-table :data="group" size="small" border stripe>')
|
||||
expect(overseasListSource).toContain('<Pencil :size="15" />')
|
||||
expect(overseasListSource).toContain('<Trash2 :size="15" />')
|
||||
expect(overseasListSource).not.toContain('<Pencil')
|
||||
expect(overseasListSource).not.toContain('<Trash2')
|
||||
expect(overseasListSource).toContain('@media (max-width:720px)')
|
||||
expect(overseasPanelSource).toContain('size="small" plain type="danger"')
|
||||
expect(overseasPanelSource).toContain('@media (max-width:760px)')
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
listTenantQualifications,
|
||||
type PlatformTenantResource,
|
||||
} from '@/modules/iam'
|
||||
import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import CustomerDetailView from './CustomerDetailView.vue'
|
||||
@@ -39,14 +38,6 @@ vi.mock('@/modules/iam', () => ({
|
||||
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/first-leg-orders', () => ({
|
||||
listPlatformFirstLegOrders: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/finance', () => ({
|
||||
CustomerStatementsPanel: { template: '<div>财务账单</div>' },
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
@@ -54,7 +45,7 @@ class ResizeObserverStub implements ResizeObserver {
|
||||
}
|
||||
|
||||
const tenant: PlatformTenantResource = {
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE', legalName: '柏悦物流有限公司',
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', customerNickname: '柏', status: 'ACTIVE', legalName: '柏悦物流有限公司',
|
||||
legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12', contactName: '张三',
|
||||
contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '基础版', contactWechat: 'baiyue-ops',
|
||||
salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司', establishedDate: '2020-01-01',
|
||||
@@ -90,17 +81,6 @@ describe('CustomerDetailView', () => {
|
||||
membershipId: 'member-1', userId: 'user-1', authUserId: 'auth-1', username: 'merchant_admin', displayName: '张三',
|
||||
phone: '13800138000', email: 'ops@example.com', memberType: 'OWNER', memberNo: null, status: 'ACTIVE', roleIds: [], roleNames: ['企业管理员'],
|
||||
}])
|
||||
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({
|
||||
records: [{
|
||||
id: 'order-1', tenantId: tenant.id, orderNo: 'BY20260807001', customerId: null, customerName: tenant.tenantName,
|
||||
status: 'PROCESSING', transportMode: 'SEA', tradeTerm: null, destinationCountryCode: 'US', destinationWarehouseId: null,
|
||||
destinationWarehouseName: '洛杉矶', expectedShipDate: '2026-08-08', customerRemark: null, pickupMethod: 'WAREHOUSE',
|
||||
receivingWarehouseId: 'warehouse-1', receivingWarehouseName: '深圳头程收货仓', receivingWarehouseAddress: '深圳市', receivingTime: null,
|
||||
channelCode: 'SEA_FCL', channelName: '海运整箱', taxMode: 'DDP', containerMode: 'FULL_CONTAINER', customsInspection: false,
|
||||
destinationType: 'FBA', amazonWarehouseCode: null, shipmentId: null, referenceId: null, expressCompany: null, expressTrackingNo: null,
|
||||
createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:00:00',
|
||||
} satisfies PlatformFirstLegOrderResource], page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
})
|
||||
|
||||
@@ -109,7 +89,7 @@ describe('CustomerDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'基本信息', '运单记录', '财务账单', '计费规则', '操作日志',
|
||||
'基本信息', '计费规则', '操作日志',
|
||||
])
|
||||
expect(document.body.textContent).not.toContain('累计运单')
|
||||
expect(document.body.textContent).not.toContain('累计应收')
|
||||
@@ -120,17 +100,10 @@ describe('CustomerDetailView', () => {
|
||||
expect(document.body.textContent).toContain('公司联系电话')
|
||||
expect(document.body.textContent).toContain('登录手机号')
|
||||
expect(document.body.textContent).toContain('修改会影响登录')
|
||||
expect(document.querySelector('.contact-panel .info-grid--three')).not.toBeNull()
|
||||
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
|
||||
expect(document.body.textContent).toContain('已替换')
|
||||
|
||||
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
|
||||
ordersTab?.click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('BY20260807001')
|
||||
expect(document.body.textContent).toContain('海运整箱 FCL')
|
||||
expect(document.body.textContent).not.toContain('暂无尺寸计算规则查看权限')
|
||||
expect(listPlatformFirstLegOrders).toHaveBeenCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
|
||||
|
||||
const billingTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="计费规则"]')
|
||||
billingTab?.click()
|
||||
await flushPromises()
|
||||
@@ -138,23 +111,6 @@ describe('CustomerDetailView', () => {
|
||||
expect(document.body.textContent).not.toContain('计费规则尚未接入')
|
||||
})
|
||||
|
||||
it('does not expose unknown order status codes', async () => {
|
||||
const result = await listPlatformFirstLegOrders({ tenantId: String(tenant.id), page: 1, size: 10 })
|
||||
vi.mocked(listPlatformFirstLegOrders).mockResolvedValueOnce({
|
||||
...result,
|
||||
records: result.records.map((order) => ({ ...order, status: 'LEGACY_STATUS' as PlatformFirstLegOrderResource['status'] })),
|
||||
})
|
||||
|
||||
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
|
||||
ordersTab?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('未知状态')
|
||||
expect(document.body.textContent).not.toContain('LEGACY_STATUS')
|
||||
})
|
||||
|
||||
it('opens an authenticated qualification preview', async () => {
|
||||
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -167,28 +123,6 @@ describe('CustomerDetailView', () => {
|
||||
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not duplicate the first page request when retrying from a later page', async () => {
|
||||
const wrapper = mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
vi.mocked(listPlatformFirstLegOrders).mockRejectedValueOnce(new Error('暂时不可用'))
|
||||
|
||||
const vm = wrapper.vm as unknown as { orderPage: number }
|
||||
vm.orderPage = 2
|
||||
await flushPromises()
|
||||
expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(2)
|
||||
|
||||
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
|
||||
ordersTab?.click()
|
||||
await flushPromises()
|
||||
const retryButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('重新加载'))
|
||||
retryButton?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(3)
|
||||
expect(listPlatformFirstLegOrders).toHaveBeenLastCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
|
||||
})
|
||||
|
||||
it('reloads a reused detail view and ignores the previous customer response', async () => {
|
||||
const secondTenant = { ...tenant, id: '9', tenantCode: 'TEN9', tenantName: '远航供应链', legalName: '远航供应链有限公司' }
|
||||
let resolveFirstTenant!: (value: PlatformTenantResource) => void
|
||||
@@ -196,8 +130,6 @@ describe('CustomerDetailView', () => {
|
||||
vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant))
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([])
|
||||
vi.mocked(listPlatformTenantMembers).mockResolvedValue([])
|
||||
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
|
||||
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
routeControl.setTenantId('9')
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft, Building2, Eye,
|
||||
FileCheck2, FileText, Pencil, Power,
|
||||
Ship, UserRound, UsersRound,
|
||||
} from '@lucide/vue'
|
||||
import { Building2, FileCheck2, FileText, UserRound, UsersRound } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
changePlatformTenantStatus,
|
||||
@@ -19,15 +17,8 @@ import {
|
||||
type PlatformTenantResource,
|
||||
type PlatformTenantStatus,
|
||||
} from '@/modules/iam'
|
||||
import {
|
||||
listPlatformFirstLegOrders,
|
||||
type PlatformFirstLegOrderResource,
|
||||
type PlatformFirstLegOrderStatus,
|
||||
} from '@/modules/first-leg-orders'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -36,10 +27,9 @@ import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
||||
import { CustomerStatementsPanel } from '@/modules/finance'
|
||||
import { CustomerAuditLogsPanel } from '@/modules/audit'
|
||||
|
||||
type DetailTab = 'basic' | 'orders' | 'statements' | 'billing' | 'logs'
|
||||
type DetailTab = 'basic' | 'billing' | 'logs'
|
||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||
|
||||
const route = useRoute()
|
||||
@@ -53,15 +43,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customer = ref<PlatformTenantResource | null>(null)
|
||||
const qualifications = ref<PlatformTenantQualification[]>([])
|
||||
const members = ref<PlatformTenantMember[]>([])
|
||||
const customerOrders = ref<PlatformFirstLegOrderResource[]>([])
|
||||
const customerOrderTotal = ref(0)
|
||||
const orderPage = ref(1)
|
||||
const orderPageSize = ref(10)
|
||||
const customerOrdersLoading = ref(false)
|
||||
const customerOrdersError = ref<ApiErrorDetails | null>(null)
|
||||
let skipNextOrderPageLoad = false
|
||||
let detailRequestSerial = 0
|
||||
let orderRequestSerial = 0
|
||||
let disposed = false
|
||||
const previews = ref<Record<string, PreviewState>>({})
|
||||
const previewOpen = ref(false)
|
||||
@@ -72,24 +54,11 @@ const tabList = ref<HTMLElement | null>(null)
|
||||
|
||||
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
|
||||
{ key: 'basic', label: '基本信息', icon: '📋' },
|
||||
{ key: 'orders', label: '运单记录', icon: '📦' },
|
||||
{ key: 'statements', label: '财务账单', icon: '🧾' },
|
||||
{ key: 'billing', label: '计费规则', icon: '⚖️' },
|
||||
{ key: 'logs', label: '操作日志', icon: '📝' },
|
||||
]
|
||||
|
||||
const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType)))
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'status', label: '状态', width: 106, slotName: 'status' },
|
||||
{ key: 'orderNo', label: '运单号', minWidth: 180, slotName: 'orderNo' },
|
||||
{ key: 'transport', label: '运输方式', minWidth: 150, slotName: 'transport' },
|
||||
{ key: 'destination', label: '目的地', minWidth: 170, slotName: 'destination' },
|
||||
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
|
||||
{ key: 'expectedShipDate', label: '预计出运', width: 128, slotName: 'expectedShipDate' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 150, slotName: 'createdAt' },
|
||||
]
|
||||
|
||||
function displayText(value?: string | null) { return value?.trim() || '—' }
|
||||
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatCapital(value: number | string | null, currency: string | null) {
|
||||
@@ -120,36 +89,6 @@ function qualificationName(item: PlatformTenantQualification) {
|
||||
try { return qualificationFileName(item.documentUrl) }
|
||||
catch { return `${qualificationLabel(item.qualificationType)}文件` }
|
||||
}
|
||||
function orderStatusLabel(status: PlatformFirstLegOrderStatus) {
|
||||
return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record<PlatformFirstLegOrderStatus, string>)[status] || '未知状态'
|
||||
}
|
||||
function transportLabel(order: PlatformFirstLegOrderResource) {
|
||||
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
|
||||
if (order.transportMode === 'AIR') return '空运'
|
||||
if (order.transportMode === 'RAIL') return '铁路运输'
|
||||
return '陆运'
|
||||
}
|
||||
function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
|
||||
async function loadCustomerOrders(page = orderPage.value, requestedTenantId = tenantId.value) {
|
||||
if (!access.can('platform:order:list')) return
|
||||
const serial = ++orderRequestSerial
|
||||
customerOrdersLoading.value = true
|
||||
customerOrdersError.value = null
|
||||
try {
|
||||
const result = await listPlatformFirstLegOrders({ tenantId: requestedTenantId, page, size: orderPageSize.value })
|
||||
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
|
||||
customerOrders.value = result.records
|
||||
customerOrderTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
|
||||
customerOrders.value = []
|
||||
customerOrdersError.value = getApiErrorDetails(error, '运单记录加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (!disposed && serial === orderRequestSerial) customerOrdersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreviews() {
|
||||
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
|
||||
previews.value = {}
|
||||
@@ -195,9 +134,6 @@ async function loadDetail() {
|
||||
customer.value = null
|
||||
qualifications.value = []
|
||||
members.value = []
|
||||
customerOrders.value = []
|
||||
customerOrderTotal.value = 0
|
||||
customerOrdersError.value = null
|
||||
try {
|
||||
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
|
||||
getPlatformTenant(requestedTenantId),
|
||||
@@ -211,17 +147,6 @@ async function loadDetail() {
|
||||
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
|
||||
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
|
||||
} satisfies PreviewState]))
|
||||
|
||||
if (access.can('platform:order:list')) {
|
||||
if (orderPage.value !== 1) skipNextOrderPageLoad = true
|
||||
orderPage.value = 1
|
||||
void loadCustomerOrders(1, requestedTenantId)
|
||||
} else {
|
||||
customerOrdersLoading.value = false
|
||||
customerOrdersError.value = null
|
||||
customerOrders.value = []
|
||||
customerOrderTotal.value = 0
|
||||
}
|
||||
} catch (error) {
|
||||
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
|
||||
customer.value = null
|
||||
@@ -233,17 +158,13 @@ async function loadDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function openOrder(order: PlatformFirstLegOrderResource) {
|
||||
void router.push({ name: 'unified-order-detail', params: { orderId: String(order.id) }, query: { tenantId: tenantId.value } })
|
||||
}
|
||||
|
||||
async function changeStatus() {
|
||||
if (!customer.value || statusChanging.value) return
|
||||
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
|
||||
type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
|
||||
})
|
||||
statusChanging.value = true
|
||||
customer.value = await changePlatformTenantStatus(tenantId.value, next)
|
||||
@@ -272,39 +193,29 @@ watch(tenantId, (value) => {
|
||||
if (value) void loadDetail()
|
||||
else {
|
||||
detailRequestSerial += 1
|
||||
orderRequestSerial += 1
|
||||
loading.value = false
|
||||
customer.value = null
|
||||
qualifications.value = []
|
||||
members.value = []
|
||||
customerOrders.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
detailRequestSerial += 1
|
||||
orderRequestSerial += 1
|
||||
clearPreviews()
|
||||
})
|
||||
watch(orderPage, (page, previousPage) => {
|
||||
if (skipNextOrderPageLoad) {
|
||||
skipNextOrderPageLoad = false
|
||||
return
|
||||
}
|
||||
if (page !== previousPage) void loadCustomerOrders(page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
|
||||
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
|
||||
STORY: 运营先确认企业状态,再从六个业务入口查看资料、用户和后续记录。
|
||||
FIRST VIEWPORT: 客户身份与动作居首,三项真实概况紧随其后,六页签固定形成内容索引。
|
||||
STORY: 运营先确认企业状态,再查看客户资料、计费规则和操作日志。
|
||||
FIRST VIEWPORT: 客户身份与动作居首,三个页签形成稳定内容索引。
|
||||
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<AdminPage as="div" class="customer-detail-page">
|
||||
<AdminPage as="div" class="customer-detail-page admin-density customer-density">
|
||||
<LoadErrorState v-if="loadError" title="客户详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
@@ -312,14 +223,13 @@ watch(orderPage, (page, previousPage) => {
|
||||
<DetailPageLayout v-else v-loading="loading" aria-label="客户详情">
|
||||
<template #header>
|
||||
<header class="detail-header">
|
||||
<el-button class="back-button" @click="router.push({ name: 'customers' })"><ArrowLeft :size="17" />返回列表</el-button>
|
||||
<div class="company-mark" aria-hidden="true"><Building2 :size="23" /></div>
|
||||
<el-button link class="back-button density-back" @click="router.push({ name: 'customers' })">返回列表</el-button>
|
||||
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || '客户资料'">
|
||||
<template #actions>
|
||||
<div v-if="customer" class="header-actions">
|
||||
<el-tag :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
|
||||
<el-button v-if="access.can('platform:customer:edit') && customer.status !== 'ARCHIVED'" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
|
||||
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit') && customer.status !== 'ARCHIVED'" @click="goEdit">编辑信息</el-button>
|
||||
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus">{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
@@ -334,8 +244,8 @@ watch(orderPage, (page, previousPage) => {
|
||||
</section>
|
||||
</template> -->
|
||||
|
||||
<div ref="tabList" class="detail-tabs" role="tablist" aria-label="客户详情模块">
|
||||
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-label="tab.label" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><span class="detail-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
|
||||
<div ref="tabList" class="detail-tabs density-tabs" role="tablist" aria-label="客户详情模块">
|
||||
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-label="tab.label" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
|
||||
</div>
|
||||
|
||||
<div v-if="customer" :id="`customer-panel-${activeTab}`" class="tab-panel" role="tabpanel" :aria-labelledby="`customer-tab-${activeTab}`" tabindex="0">
|
||||
@@ -343,8 +253,9 @@ watch(orderPage, (page, previousPage) => {
|
||||
<div class="basic-layout">
|
||||
<section class="detail-panel company-panel">
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
|
||||
<dl class="info-grid">
|
||||
<dl class="info-grid density-fields">
|
||||
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
|
||||
<div><dt>客户昵称</dt><dd>{{ displayText(customer.customerNickname) }}</dd></div>
|
||||
<div><dt>统一社会信用代码</dt><dd>{{ displayText(customer.unifiedSocialCreditCode) }}</dd></div>
|
||||
<div><dt>企业类型</dt><dd>{{ displayText(customer.enterpriseType) }}</dd></div>
|
||||
<div><dt>法定代表人</dt><dd>{{ displayText(customer.legalRepresentative) }}</dd></div>
|
||||
@@ -357,14 +268,14 @@ watch(orderPage, (page, previousPage) => {
|
||||
<div class="panel-title"><div><h2>资质文件</h2><p>共 {{ qualifications.length }} 份</p></div><FileText :size="19" /></div>
|
||||
<div v-if="qualifications.length" class="qualification-list">
|
||||
<button v-for="item in qualifications" :key="String(item.id)" type="button" :disabled="previews[String(item.id)]?.loading" @click="openPreview(item)">
|
||||
<span class="file-icon"><FileText :size="18" /></span><span><strong>{{ qualificationLabel(item.qualificationType) }}</strong><small>{{ qualificationName(item) }}</small></span><el-tag :type="qualificationStatus(item.status).type" effect="light" size="small">{{ qualificationStatus(item.status).label }}</el-tag><span class="file-date">{{ item.validTo || '长期有效' }}</span><Eye :size="17" />
|
||||
<span><strong>{{ qualificationLabel(item.qualificationType) }}</strong><small>{{ qualificationName(item) }}</small></span><el-tag :type="qualificationStatus(item.status).type" effect="light" size="small">{{ qualificationStatus(item.status).label }}</el-tag><span class="file-date">{{ item.validTo || '长期有效' }}</span><span class="file-preview-label">预览</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
|
||||
</section>
|
||||
<section class="detail-panel contact-panel">
|
||||
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
|
||||
<dl class="info-grid">
|
||||
<dl class="info-grid info-grid--three density-fields">
|
||||
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
|
||||
<div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div>
|
||||
@@ -391,75 +302,51 @@ watch(orderPage, (page, previousPage) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
|
||||
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单,共 {{ customerOrderTotal }} 票</p></div><Ship :size="19" /></div>
|
||||
<template v-if="access.can('platform:order:list')">
|
||||
<el-alert v-if="customerOrdersError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ customerOrdersError.message }}</span><ApiErrorMeta :details="customerOrdersError" /></div></template><el-button link type="danger" @click="loadDetail">重新加载</el-button></el-alert>
|
||||
<DataTable v-else v-model:page="orderPage" v-model:page-size="orderPageSize" :data="customerOrders" :columns="orderColumns" :loading="customerOrdersLoading" :total="customerOrderTotal" :page-sizes="[10]" empty-text="当前客户暂无运单记录" row-key="id">
|
||||
<template #actions="{ row }"><el-button link type="primary" @click="openOrder(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="orderStatusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
|
||||
<template #orderNo="{ row }"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '—' }}</strong></template>
|
||||
<template #transport="{ row }"><span>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</span></template>
|
||||
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '—' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '—' }}</span></div></template>
|
||||
<template #receivingWarehouse="{ row }"><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '—' }}</span></template>
|
||||
<template #expectedShipDate="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).expectedShipDate).slice(0, 10) }}</span></template>
|
||||
<template #createdAt="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></template>
|
||||
<template #empty><el-empty description="当前客户暂无运单记录" /></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" :dimension-initialization-status="customer.dimensionInitializationStatus" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
|
||||
<AppDialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-tabs { border-bottom: 1px solid var(--cc-border); }
|
||||
|
||||
.customer-detail-page { min-width: 0; }
|
||||
.detail-header { display:flex; min-width:0; align-items:center; gap:12px; }
|
||||
.detail-header { display:flex; min-width:0; align-items:center; gap:var(--admin-space-5, 12px); }
|
||||
.back-button { flex:0 0 auto; }
|
||||
.company-mark { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; background:linear-gradient(135deg,rgba(102,126,234,.18),rgba(240,147,251,.18)); color:#667eea; box-shadow:0 4px 12px rgba(102,126,234,.16); }
|
||||
.detail-page-toolbar { min-width:0; flex:1; }
|
||||
.header-actions { display:flex; flex:0 0 auto; gap:8px; }
|
||||
.header-actions { display:flex; flex:0 0 auto; gap:var(--admin-space-3, 8px); }
|
||||
.header-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.detail-tabs { display:flex; gap:0; overflow-x:auto; overflow-y:hidden; border-bottom:1px solid rgba(228,228,231,.5); border-radius:20px 20px 0 0; background:rgba(255,255,255,.95); box-shadow:0 -4px 16px rgba(0,0,0,.04); scrollbar-width:thin; }
|
||||
.detail-tabs button { display:inline-flex; min-width:max-content; min-height:0; flex:0 0 auto; align-items:center; justify-content:center; gap:8px; padding:16px 28px; border:0; border-bottom:3px solid transparent; background:transparent; color:#71717a; font-size:15px; font-weight:600; white-space:nowrap; cursor:pointer; transition:all .3s cubic-bezier(.4,0,.2,1); }
|
||||
.detail-tab-icon { font-size:18px; line-height:1; }
|
||||
.detail-tabs button:hover { background:rgba(102,126,234,.05); color:#667eea; }
|
||||
.detail-tabs button.active { border-bottom-color:#667eea; background:rgba(102,126,234,.08); color:#667eea; font-weight:800; }
|
||||
.tab-panel { min-height:400px; padding-top:18px; }
|
||||
.basic-layout { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr); gap:18px; align-items:start; }
|
||||
.detail-panel { min-width:0; padding:22px; border:1px solid rgba(255,255,255,.5); border-radius:12px; background:rgba(255,255,255,.95); box-shadow:0 3px 12px rgba(15,23,42,.05); }
|
||||
.contact-panel,.user-panel { grid-column:1 / -1; }
|
||||
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }
|
||||
.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }
|
||||
.info-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:0; }
|
||||
.info-grid > div { min-width:0; padding:12px 14px; border-radius:10px; background:#f8f9fc; }.info-grid > div.wide { grid-column:1 / -1; }
|
||||
.info-grid dt { margin-bottom:5px; color:var(--cc-muted); font-size:11px; }.info-grid dd { margin:0; color:#25324a; font-size:13px; font-weight:650; line-height:1.55; overflow-wrap:anywhere; }
|
||||
.qualification-list { display:flex; flex-direction:column; gap:8px; }
|
||||
.qualification-list button { display:grid; width:100%; grid-template-columns:38px minmax(0,1fr) auto auto 20px; align-items:center; gap:10px; padding:10px; border:1px solid var(--cc-border); border-radius:10px; background:#fff; color:inherit; text-align:left; cursor:pointer; }
|
||||
.basic-layout { display:grid; align-items:start; }
|
||||
.detail-panel { min-width:0; }
|
||||
.contact-panel { grid-column:1 / -1; }
|
||||
.user-panel { grid-column:1 / -1; }
|
||||
.panel-title { display:flex; justify-content:space-between; border-bottom:1px solid var(--cc-border); }
|
||||
.panel-title h2 { margin:0; color:#344054; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); }.panel-title > svg { color:var(--cc-primary); }
|
||||
.info-grid { margin:0; }.info-grid > div.wide { grid-column:1 / -1; }
|
||||
.qualification-list { display:flex; flex-direction:column; }
|
||||
.qualification-list button { display:grid; width:100%; align-items:center; background:var(--admin-surface, #fff); color:inherit; text-align:left; cursor:pointer; }
|
||||
.qualification-list button:hover { border-color:#b9c3f5; background:#fafbff; }.qualification-list button:disabled { cursor:wait; opacity:.65; }
|
||||
.file-icon { display:grid; width:38px; height:38px; place-items:center; border-radius:9px; background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }
|
||||
.qualification-list strong,.qualification-list small { display:block; }.qualification-list strong { font-size:12px; }.qualification-list small,.file-date { margin-top:3px; color:var(--cc-muted); font-size:10px; }.file-date { margin:0; white-space:nowrap; }
|
||||
.section-empty,.module-empty { display:flex; min-height:150px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }
|
||||
.section-empty strong,.module-empty strong { color:#475467; font-size:13px; }.section-empty span,.module-empty p { max-width:600px; margin:0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
||||
.qualification-list strong { display:block; }
|
||||
.qualification-list small { display:block; }.qualification-list small { margin-top:3px; color:var(--cc-muted); }.file-date { margin-top:3px; color:var(--cc-muted); }.file-date { margin:0; white-space:nowrap; }
|
||||
.section-empty { display:flex; align-items:center; justify-content:center; flex-direction:column; color:#98a2b3; text-align:center; }
|
||||
.module-empty { display:flex; align-items:center; justify-content:center; flex-direction:column; color:#98a2b3; text-align:center; }
|
||||
.section-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }
|
||||
.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.section-empty span { max-width:600px; margin:0; color:var(--cc-muted); line-height:1.7; }.module-empty p { max-width:600px; margin:0; color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
|
||||
.section-empty button { border:0; background:transparent; color:var(--cc-primary); cursor:pointer; }.section-empty.is-error svg { color:#d92d20; }
|
||||
.user-table-wrap { overflow-x:auto; }
|
||||
table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }th { padding:10px 12px; background:#f8fafc; color:#667085; text-align:left; font-weight:650; }td { padding:12px; border-top:1px solid #edf0f4; color:#344054; }
|
||||
.user-identity { display:flex; align-items:center; gap:9px; }.user-identity > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:50%; background:#eef1ff; color:var(--cc-primary); font-weight:700; }.user-identity strong,.user-identity small { display:block; }.user-identity small { margin-top:3px; color:var(--cc-muted); font-size:10px; }.role-list { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.member-contact span,.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); font-size:10px; }
|
||||
.module-panel { min-height:360px; }.module-empty { min-height:260px; }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:12px; }
|
||||
.contract-note { padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
||||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
|
||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; } }
|
||||
table { width:100%; min-width:720px; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }th { padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); background:var(--admin-label-bg, #f8fafc); color:#667085; text-align:left; font-weight:650; }td { padding:var(--admin-space-5, 12px); border-top:1px solid #edf0f4; color:#344054; }
|
||||
.user-identity { display:flex; align-items:center; gap:9px; }.user-identity > span { width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:50%; background:#eef1ff; color:var(--cc-primary); font-weight:700; }.user-identity strong { display:block; }.user-identity small { display:block; }.user-identity small { margin-top:3px; color:var(--cc-muted); }.role-list { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.member-contact span { display:block; }
|
||||
.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:var(--admin-font-caption, 12px); }
|
||||
.contract-note { padding:var(--admin-space-6, 16px) 18px; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.contract-note strong { color:#344054; font-size:var(--admin-font-caption, 12px); }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
|
||||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:var(--admin-surface, #fff); }
|
||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; }.info-grid--three { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
|
||||
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
|
||||
@@ -84,7 +85,7 @@ class ResizeObserverStub implements ResizeObserver {
|
||||
}
|
||||
|
||||
const fullTenant: PlatformTenantResource = {
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED',
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', customerNickname: '柏', status: 'PROFILE_REQUIRED',
|
||||
legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12',
|
||||
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com',
|
||||
servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30,
|
||||
@@ -177,6 +178,8 @@ describe('CustomersView', () => {
|
||||
|
||||
it('searches the customer list by customer name only', () => {
|
||||
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
|
||||
expect(customersSource).toContain("label: '客户昵称'")
|
||||
expect(customersSource).toContain('中文拼音大写首字母,如 SZWL')
|
||||
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
|
||||
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
|
||||
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
|
||||
@@ -184,18 +187,23 @@ describe('CustomersView', () => {
|
||||
|
||||
it('matches the frozen prototype containers for customer operations', () => {
|
||||
expect(customersSource).toContain("router.push({ name: 'customer-detail'")
|
||||
expect(customersSource).toContain("'min(640px, calc(100vw - 28px))'")
|
||||
expect(customersSource).toContain("'min(680px, calc(100vw - 28px))'")
|
||||
expect(customersSource).toContain('width="min(520px, calc(100vw - 28px))" class="customer-password-dialog"')
|
||||
expect(customersSource).toContain('width="min(1040px, calc(100vw - 28px))" class="review-decision-dialog"')
|
||||
expect(customersSource).toContain("const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'")
|
||||
expect(customersSource.match(/class="form-grid three-cols"/g)).toHaveLength(2)
|
||||
expect(customersSource).toContain('width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog"')
|
||||
expect(customersSource).toContain('width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog"')
|
||||
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
it('reserves enough space for qualification validity dates without widening the upload column', () => {
|
||||
it('keeps qualification file controls usable when the editor dialog is narrow', () => {
|
||||
expect(customersSource).toContain('class="qualification-validity" role="cell"')
|
||||
expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px')
|
||||
expect(customersSource).toContain('.qualification-table { container-type:inline-size;')
|
||||
expect(customersSource).toContain('@container (max-width:660px)')
|
||||
expect(customersSource).toContain('.qualification-row { grid-template-columns:112px minmax(0,1fr); }')
|
||||
expect(customersSource).toContain('.qualification-label { grid-row:span 2; align-items:center; }')
|
||||
expect(customersSource).toContain('.qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; }')
|
||||
expect(customersSource).toContain('.qualification-file-name { min-width:0; max-width:140px;')
|
||||
expect(customersSource).toContain('.qualification-file-name { min-width:0;')
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/modules/customers/customerDensity.css'), 'utf8')).toContain('max-width: none; overflow-wrap: anywhere; white-space: normal;')
|
||||
})
|
||||
|
||||
it('loads the complete customer detail before editing masked list fields', async () => {
|
||||
@@ -263,7 +271,7 @@ describe('CustomersView', () => {
|
||||
|
||||
await wrapper.get('input[placeholder="请输入工商登记公司名称"]').setValue(fullTenant.tenantName)
|
||||
await wrapper.get('input[placeholder="请输入营业执照上的企业名称"]').setValue(fullTenant.legalName)
|
||||
await wrapper.get('input[placeholder="请输入法定代表人姓名"]').setValue(fullTenant.legalRepresentative)
|
||||
expect(wrapper.get('input[placeholder="请输入法定代表人姓名(选填)"]').attributes('aria-required')).not.toBe('true')
|
||||
await wrapper.get('input[placeholder="请输入主要联系人姓名"]').setValue(fullTenant.contactName)
|
||||
await wrapper.get('input[placeholder="请输入公司联系电话"]').setValue(fullTenant.contactPhone)
|
||||
await wrapper.get('input[placeholder="请输入18位大写字母或数字"]').setValue(fullTenant.unifiedSocialCreditCode)
|
||||
@@ -277,11 +285,13 @@ describe('CustomersView', () => {
|
||||
expect(createPlatformTenant).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tenantName: fullTenant.tenantName,
|
||||
contactPhone: fullTenant.contactPhone,
|
||||
legalRepresentative: '',
|
||||
}))
|
||||
expect(createPlatformTenant).toHaveBeenCalledWith(expect.not.objectContaining({ unifiedSocialCreditCode: expect.anything() }))
|
||||
expect(uploadPlatformTenantRequiredQualificationPackage).toHaveBeenCalledWith(
|
||||
fullTenant.id, licenseFile, null, fullTenant.unifiedSocialCreditCode, false, false,
|
||||
)
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the company contact phone editable without changing a member login phone', async () => {
|
||||
@@ -306,8 +316,42 @@ describe('CustomersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(document.body.textContent).toContain('客户资料已保存')
|
||||
expect(document.body.textContent).not.toContain('尚未提交审核')
|
||||
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
|
||||
expect(customersSource).not.toContain('联系电话(登录用户名)')
|
||||
expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()'))
|
||||
})
|
||||
|
||||
it('allows an active customer to replace qualifications and select a validity date', async () => {
|
||||
const activeTenant = { ...fullTenant, status: 'ACTIVE' as const }
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant)
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([
|
||||
{
|
||||
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null,
|
||||
},
|
||||
{
|
||||
id: 'qualification-freight', tenantId: fullTenant.id, qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE',
|
||||
certificateNo: null, documentUrl: '/files/freight.png', status: 'APPROVED', reviewComment: null, validTo: null,
|
||||
},
|
||||
])
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('编辑')?.click()
|
||||
await flushPromises()
|
||||
|
||||
const fileInputs = [...document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]')]
|
||||
const validityInputs = [...document.querySelectorAll<HTMLInputElement>('.qualification-validity input')]
|
||||
expect(fileInputs).toHaveLength(4)
|
||||
expect(fileInputs.every((input) => !input.disabled)).toBe(true)
|
||||
expect(validityInputs.map((input) => input.disabled)).toEqual([true, false, true, false])
|
||||
|
||||
selectQualificationFile(0, new File(['license-new'], 'license-new.png', { type: 'image/png' }))
|
||||
await flushPromises()
|
||||
expect(validityInputs[0]?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
|
||||
@@ -710,6 +754,9 @@ describe('CustomersView', () => {
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:delete']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'ARCHIVED' })
|
||||
vi.mocked(listPlatformTenants)
|
||||
.mockResolvedValueOnce({ records: [fullTenant], page: 1, size: 10, total: 1, pages: 1 })
|
||||
.mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -730,6 +777,7 @@ describe('CustomersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id)
|
||||
expect(document.body.textContent).not.toContain(fullTenant.tenantName)
|
||||
})
|
||||
|
||||
it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => {
|
||||
@@ -757,24 +805,6 @@ describe('CustomersView', () => {
|
||||
expect(document.body.textContent).toContain('代登录原因')
|
||||
})
|
||||
|
||||
it('treats archived customers as read-only and terminal', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:edit', 'platform:customer:status', 'platform:customer:delete']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [{ ...fullTenant, status: 'ARCHIVED' }], page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('已归档')
|
||||
expect(tableButtonByText('编辑')).toBeUndefined()
|
||||
expect(tableButtonByText('启用')).toBeUndefined()
|
||||
expect(tableButtonByText('停用')).toBeUndefined()
|
||||
expect(tableButtonByText('删除')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders one overflow menu per customer when permitted actions exceed three', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
|
||||
@@ -792,7 +822,7 @@ describe('CustomersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(3)
|
||||
expect(document.querySelectorAll('.el-table__body .table-actions > .el-button').length).toBe(9)
|
||||
expect(document.querySelectorAll('.el-table__body .row-actions > .el-button').length).toBe(9)
|
||||
})
|
||||
|
||||
it('keeps the action row usable when all customer identifiers are missing', async () => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Building2, CheckCircle2, CircleCheck, Download, Eye, FileCheck2, FileText, FileUp, KeyRound, LogIn, MoreHorizontal, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
|
||||
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import {
|
||||
approveEnterpriseReview,
|
||||
@@ -131,13 +134,13 @@ const queryFields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
|
||||
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
|
||||
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已停用', value: 'DISABLED' }, { label: '已归档', value: 'ARCHIVED' },
|
||||
{ label: '已停用', value: 'DISABLED' },
|
||||
] },
|
||||
]
|
||||
|
||||
type CustomerForm = CreatePlatformTenantRequest & { unifiedSocialCreditCode: string }
|
||||
type CustomerForm = CreatePlatformTenantRequest & { customerNickname: string; unifiedSocialCreditCode: string }
|
||||
const emptyForm = (): CustomerForm => ({
|
||||
tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
|
||||
tenantName: '', customerNickname: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
|
||||
contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 30,
|
||||
enterpriseType: '', establishedDate: null, registeredCapital: 100000, registeredCapitalCurrency: 'CNY',
|
||||
registeredAddress: '', internalRemark: '',
|
||||
@@ -145,8 +148,18 @@ const emptyForm = (): CustomerForm => ({
|
||||
const form = ref(emptyForm())
|
||||
const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
customerNickname: [
|
||||
{ max: 32, message: '客户昵称最多 32 个字符', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (!editingId.value || (typeof value === 'string' && value.trim())) callback()
|
||||
else callback(new Error('请输入客户昵称'))
|
||||
},
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
],
|
||||
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
|
||||
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
|
||||
legalRepresentative: [{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' }],
|
||||
unifiedSocialCreditCode: [
|
||||
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
||||
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
|
||||
@@ -199,6 +212,7 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||
const customerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 330, slotName: 'actions' },
|
||||
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', width: 220, className: 'customer-code-column' },
|
||||
{ key: 'customerNickname', prop: 'customerNickname', label: '客户昵称', width: 120, formatter: (row: PlatformTenantResource) => displayText(row.customerNickname) },
|
||||
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' },
|
||||
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
|
||||
@@ -214,10 +228,9 @@ let loadSequence = 0
|
||||
let reviewLoadSequence = 0
|
||||
let editorLoadSequence = 0
|
||||
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
|
||||
const editorDialogWidth = computed(() => editingId.value
|
||||
? 'min(640px, calc(100vw - 28px))'
|
||||
: 'min(680px, calc(100vw - 28px))')
|
||||
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
|
||||
const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'
|
||||
const canUploadQualifications = computed(() => !editorStatus.value
|
||||
|| ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value))
|
||||
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
|
||||
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
|
||||
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
|
||||
@@ -394,7 +407,7 @@ function openCreate() {
|
||||
}
|
||||
function mapTenantToForm(row: PlatformTenantResource): CustomerForm {
|
||||
return {
|
||||
tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
|
||||
tenantName: row.tenantName, customerNickname: row.customerNickname || '', legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
|
||||
contactName: row.contactName || '', contactPhone: row.contactPhone || '', contactEmail: row.contactEmail || '',
|
||||
contactWechat: row.contactWechat || '', servicePackage: row.servicePackage || '基础版', salesOwnerName: row.salesOwnerName || '',
|
||||
paymentTermDays: Number(row.paymentTermDays ?? 0), enterpriseType: row.enterpriseType || '', establishedDate: row.establishedDate || null,
|
||||
@@ -574,7 +587,7 @@ async function saveCustomer() {
|
||||
let saved: PlatformTenantResource
|
||||
if (editingId.value) {
|
||||
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
|
||||
saved = await updatePlatformTenant(editingId.value, payload)
|
||||
saved = await updatePlatformTenant(editingId.value, { ...payload, customerNickname: form.value.customerNickname.trim() })
|
||||
} else {
|
||||
const { unifiedSocialCreditCode: _qualificationCreditCode, ...payload } = form.value
|
||||
const created = await createPlatformTenant(payload)
|
||||
@@ -592,17 +605,21 @@ async function saveCustomer() {
|
||||
editorStatus.value = saved.status
|
||||
savedFormSnapshot.value = JSON.stringify(form.value)
|
||||
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
|
||||
const canSubmitSavedCustomerForReview = ['PROFILE_REQUIRED', 'REJECTED'].includes(saved.status)
|
||||
if (failures.length) {
|
||||
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
|
||||
} else if (hadBusinessLicense && hadLegalPersonId) {
|
||||
} else if (canSubmitSavedCustomerForReview && hadBusinessLicense && hadLegalPersonId) {
|
||||
ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核')
|
||||
} else if (hadBusinessLicense) {
|
||||
} else if (canSubmitSavedCustomerForReview && hadBusinessLicense) {
|
||||
ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核')
|
||||
} else if (hadSelectedQualifications) {
|
||||
} else if (canSubmitSavedCustomerForReview && hadSelectedQualifications) {
|
||||
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
|
||||
} else if (hadSelectedQualifications) {
|
||||
ElMessage.success('客户资料和已选资质已保存')
|
||||
} else {
|
||||
ElMessage.success('客户资料已保存,尚未提交审核')
|
||||
ElMessage.success('客户资料已保存')
|
||||
}
|
||||
if (!failures.length) editorOpen.value = false
|
||||
await loadCustomers()
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
|
||||
finally { saving.value = false }
|
||||
@@ -624,7 +641,7 @@ async function submitReview() {
|
||||
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
|
||||
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
|
||||
if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return }
|
||||
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', '提交企业审核', { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
|
||||
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', '提交企业审核', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
|
||||
const saved = await submitPlatformTenantReview(editingId.value)
|
||||
editorStatus.value = saved.status; ElMessage.success('审核单已提交')
|
||||
editorOpen.value = false; await loadCustomers()
|
||||
@@ -649,12 +666,6 @@ function canArchive(row: PlatformTenantResource) {
|
||||
return access.can('platform:customer:delete') && ['PROFILE_REQUIRED', 'REJECTED'].includes(row.status)
|
||||
}
|
||||
type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete'
|
||||
const customerRowActionIcons: Record<CustomerRowAction, Component> = {
|
||||
'reset-password': KeyRound,
|
||||
impersonate: LogIn,
|
||||
status: Power,
|
||||
delete: Trash2,
|
||||
}
|
||||
function hasReviewAction(row: PlatformTenantResource) {
|
||||
return pendingReviewByTenantId.value.has(String(row.id))
|
||||
&& access.can('platform:customer:review')
|
||||
@@ -668,17 +679,6 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio
|
||||
...(canArchive(row) ? ['delete' as const] : []),
|
||||
]
|
||||
}
|
||||
function customerPrimaryActionCount(row: PlatformTenantResource) {
|
||||
return Number(access.can('platform:customer:list'))
|
||||
+ Number(access.can('platform:customer:edit') && row.status !== 'ARCHIVED')
|
||||
+ Number(hasReviewAction(row))
|
||||
}
|
||||
function customerDirectSecondaryActions(row: PlatformTenantResource) {
|
||||
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
|
||||
}
|
||||
function customerOverflowActions(row: PlatformTenantResource) {
|
||||
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
|
||||
}
|
||||
function customerMoreActionsLabel(row: PlatformTenantResource) {
|
||||
const identity = row.tenantName || row.tenantCode || String(row.id || '当前客户')
|
||||
return `更多客户操作:${identity}`
|
||||
@@ -700,6 +700,27 @@ function customerActionDisabled(action: CustomerRowAction) {
|
||||
if (action === 'delete') return archiveSubmitting.value
|
||||
return false
|
||||
}
|
||||
function customerRowActions(row: PlatformTenantResource): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 500, visible: access.can('platform:customer:list') },
|
||||
{ key: 'edit', label: '编辑', priority: 400, visible: access.can('platform:customer:edit') && row.status !== 'ARCHIVED' },
|
||||
{ key: 'review', label: '审核', priority: 300, tone: 'warning', visible: hasReviewAction(row), disabled: reviewSubmitting.value },
|
||||
...customerSecondaryActions(row).map((action, index): RowActionItem => ({
|
||||
key: action,
|
||||
label: customerActionLabel(row, action),
|
||||
priority: 200 - index,
|
||||
tone: customerActionType(row, action),
|
||||
disabled: customerActionDisabled(action),
|
||||
divided: action === 'delete',
|
||||
})),
|
||||
]
|
||||
}
|
||||
function handleCustomerRowAction(row: PlatformTenantResource, command: string) {
|
||||
if (command === 'detail') showDetail(row)
|
||||
else if (command === 'edit') openEdit(row)
|
||||
else if (command === 'review') openDecision(row, access.can('platform:customer:approve') ? 'approve' : 'reject')
|
||||
else handleMoreAction(row, command)
|
||||
}
|
||||
function handleMoreAction(row: PlatformTenantResource, command: string) {
|
||||
if (command === 'reset-password') openResetPassword(row)
|
||||
if (command === 'impersonate') openImpersonation(row)
|
||||
@@ -709,7 +730,7 @@ function handleMoreAction(row: PlatformTenantResource, command: string) {
|
||||
async function toggleStatus(row: PlatformTenantResource) {
|
||||
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, `${verb}客户`, { type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, `${verb}客户`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
rowActionId.value = String(row.id); await changePlatformTenantStatus(row.id, next)
|
||||
ElMessage.success(`客户已${verb}`); await loadCustomers()
|
||||
} catch (error) {
|
||||
@@ -1036,58 +1057,50 @@ onMounted(() => { void loadCustomers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="customers-page">
|
||||
<AdminPage as="div" class="customers-page admin-density customer-density">
|
||||
<OperationErrorToast :details="passwordError || impersonationError || archiveError" />
|
||||
<ListPageLayout aria-label="客户管理">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
|
||||
<template v-if="loadError" #state>
|
||||
|
||||
|
||||
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" border>
|
||||
<template #toolbar-start><h1>客户列表</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
|
||||
<template #notices><template v-if="loadError">
|
||||
<LoadErrorState title="客户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadCustomers">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
|
||||
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></template>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button v-if="access.can('platform:customer:list')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
|
||||
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="customer-action-button customer-action-review" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="14" />审核</el-button>
|
||||
<el-button v-for="action in customerDirectSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
|
||||
<el-dropdown v-if="customerOverflowActions(row as PlatformTenantResource).length" trigger="click" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, $event as string)">
|
||||
<el-button class="customer-action-button customer-action-more" size="small" plain :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="16" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in customerOverflowActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :class="{ 'customer-action-menu__danger': customerActionType(row as PlatformTenantResource, action) === 'danger' }"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</template></template>
|
||||
<template #before-results><div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div></template>
|
||||
|
||||
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
|
||||
<template #actions="{ row }"><RowActions text-actions class="density-action" :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
|
||||
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
|
||||
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
|
||||
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
|
||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid three-cols">
|
||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
|
||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
|
||||
|
||||
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid two-cols">
|
||||
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid three-cols">
|
||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
|
||||
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
|
||||
<el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item>
|
||||
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
|
||||
|
||||
<FormSection class="qualification-section" title="企业认证资料" description="营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
@@ -1101,27 +1114,27 @@ onMounted(() => { void loadCustomers() })
|
||||
<button v-if="draft.file || draft.uploaded" type="button" class="qualification-preview-trigger" :class="{ 'is-loading': draft.previewLoading }" :disabled="draft.previewLoading" :aria-label="`预览${draft.label}:${draft.file?.name || draft.uploadedName}`" @click="openQualificationPreview(draft)">
|
||||
<img v-if="draft.previewUrl && draft.previewKind === 'image' && !draft.previewFailed" :src="draft.previewUrl" :alt="`${draft.label}预览`" @error="handleQualificationPreviewError(draft)" />
|
||||
<span v-else class="qualification-preview-placeholder"><FileText :size="20" /><small>{{ draft.previewKind === 'pdf' ? 'PDF' : '文件' }}</small></span>
|
||||
<span class="qualification-preview-action"><Eye :size="14" />{{ draft.previewLoading ? '读取中' : (draft.previewFailed ? '重试' : '查看') }}</span>
|
||||
<span class="qualification-preview-action">{{ draft.previewLoading ? '读取中' : (draft.previewFailed ? '重试' : '查看') }}</span>
|
||||
</button>
|
||||
<div class="qualification-file-control"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><FileUp :size="16" /><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name" :title="draft.file?.name || draft.uploadedName">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
|
||||
<div class="qualification-file-control"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name" :title="draft.file?.name || draft.uploadedName">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
|
||||
</div>
|
||||
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
|
||||
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploading || (draft.uploaded && !draft.file)" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
|
||||
</FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer"><Save :size="16" />保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||
<div class="qualification-preview">
|
||||
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
|
||||
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||
<div v-if="approvalResult" class="approval-result-content">
|
||||
<div class="approval-result-warning" role="alert"><ShieldCheck :size="20" /><div><strong>请立即保存以下一次性凭证</strong><span>临时密码只在本次{{ credentialSource === 'creation' ? '创建' : '审核' }}响应中返回,关闭后无法再次查看。</span></div></div>
|
||||
<dl class="approval-credentials">
|
||||
@@ -1132,7 +1145,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #footer><el-button type="primary" @click="closeApprovalResult">我已保存,关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="decisionOpen" title="客户入驻审核" width="min(1040px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false">
|
||||
<el-dialog v-model="decisionOpen" title="客户入驻审核" width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="review-dialog-content">
|
||||
<header class="review-dialog-header">
|
||||
<div class="review-dialog-title"><span class="review-dialog-icon"><Building2 :size="18" /></span><div><strong>{{ selectedReview.tenantName || '未命名客户' }}</strong><span>客户编号:{{ selectedReview.tenantCode || '未生成' }}</span></div></div>
|
||||
@@ -1141,7 +1154,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<div class="review-dialog-grid">
|
||||
<section class="review-dialog-section review-dialog-card">
|
||||
<div class="review-section-heading"><div><h3>企业基本信息</h3><span>核对客户提交的主体资料</span></div><CircleCheck :size="18" /></div>
|
||||
<dl class="review-info-grid">
|
||||
<dl class="review-info-grid density-fields">
|
||||
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '未填写' }}</dd></div>
|
||||
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '未生成' }}</dd></div>
|
||||
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '未填写' }}</dd></div>
|
||||
@@ -1156,7 +1169,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
|
||||
<div v-else class="review-document-table" role="table" aria-label="待审核客户资质">
|
||||
<div class="review-document-header" role="row"><span role="columnheader">资质类型</span><span role="columnheader">已上传文件</span><span role="columnheader">操作</span></div>
|
||||
<div v-for="item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell" class="review-document-type">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell" class="review-document-name" :title="qualificationName(item)"><FileText :size="15" />{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)"><Eye :size="14" />预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)"><Download :size="14" />下载</el-button></span></div>
|
||||
<div v-for="item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell" class="review-document-type">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell" class="review-document-name" :title="qualificationName(item)"><FileText :size="15" />{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)">预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)">下载</el-button></span></div>
|
||||
<div v-if="reviewQualifications.length === 0" class="review-document-empty"><FileText :size="18" /><span>暂无资质文件</span></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1172,11 +1185,11 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">取消</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
|
||||
</el-dialog>
|
||||
|
||||
<AppDialog v-model="reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
|
||||
<div class="qualification-preview"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
|
||||
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
||||
@@ -1187,14 +1200,14 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
|
||||
<el-dialog class="admin-density-dialog customer-density-dialog" v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
|
||||
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
|
||||
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
|
||||
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
|
||||
<el-dialog class="admin-density-dialog customer-density-dialog" v-model="archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
|
||||
<el-alert type="warning" :closable="false" show-icon>删除是逻辑归档:历史业务数据会保留,客户账号会被停用。是否允许归档由服务端最终判断。</el-alert>
|
||||
<div v-if="archiveTenant" class="archive-confirm"><strong>{{ archiveTenant.tenantName }}</strong><span>请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。</span><el-input v-model="archiveConfirmCode" :disabled="archiveSubmitting" :placeholder="archiveTenant.tenantCode" autocomplete="off" /></div>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
|
||||
@@ -1203,100 +1216,80 @@ onMounted(() => { void loadCustomers() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:10px; } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:16px; } .customers-table-heading span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
|
||||
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.table-actions { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; } .table-actions :deep(.el-button) { margin-left:0; }
|
||||
:global(.customer-action-menu .el-dropdown-menu__item) { min-height:40px; gap:8px; }
|
||||
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:var(--admin-space-4, 10px); } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:var(--admin-font-title, 16px); } .customers-table-heading span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); white-space:nowrap; }
|
||||
.customers-table-actions { display:flex; flex-wrap:wrap; gap:var(--admin-space-3, 8px); } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.table-actions { display:flex; flex-wrap:wrap; align-items:center; } .table-actions :deep(.el-button) { margin-left:0; }
|
||||
:global(.customer-action-menu .el-dropdown-menu__item) { min-height:40px; gap:var(--admin-space-3, 8px); }
|
||||
:global(.customer-action-menu .customer-action-menu__danger) { color:var(--el-color-danger); }
|
||||
.review-sync-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; padding:10px 18px; border-bottom:1px solid #fecdca; background:#fffafa; color:#b42318; font-size:12px; } .review-sync-error > div { display:flex; min-width:0; align-items:center; gap:8px; } .review-sync-error span { overflow-wrap:anywhere; }
|
||||
.review-sync-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) 18px; border-bottom:1px solid #fecdca; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); } .review-sync-error > div { display:flex; min-width:0; align-items:center; gap:var(--admin-space-3, 8px); } .review-sync-error span { overflow-wrap:anywhere; }
|
||||
.customer-company-name { display:block; overflow:hidden; color:#334155; font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.customers-table-actions :deep(.el-button--primary) { border:0; background:linear-gradient(135deg,#667eea 0%,#764ba2 50%,#f093fb 100%); box-shadow:0 4px 12px rgba(102,126,234,.3); }
|
||||
.customers-table-actions :deep(.el-button--default) { border-color:rgba(102,126,234,.22); }
|
||||
:deep(.customer-list-table .el-table__header-wrapper th) { background:#f7f9fc; }
|
||||
:deep(.customer-list-table .el-table) { --el-table-border-color:#e5eaf1; --el-table-row-hover-bg-color:rgba(102,126,234,.06); border:1px solid #e5eaf1; border-radius:0; overflow:hidden; }
|
||||
:deep(.customer-list-table .el-table__inner-wrapper::before) { background:#e5eaf1; }
|
||||
:deep(.customer-list-table .el-table__body tr.el-table__row--striped > td) { background:#fbfcff; }
|
||||
:deep(.customer-list-table .el-table__body tr:hover > td) { background:rgba(102,126,234,.06) !important; }
|
||||
:deep(.customer-list-table .el-table__body td), :deep(.customer-list-table .el-table__header th) { border-right:1px solid #edf1f5; }
|
||||
:deep(.customer-list-table .el-table__body td:last-child), :deep(.customer-list-table .el-table__header th:last-child) { border-right:0; }
|
||||
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
|
||||
:deep(.customer-list-table .customer-action-button) { min-height:28px; margin:0; padding:5px 9px; border-radius:8px; font-size:12px; font-weight:650; box-shadow:0 2px 6px rgba(15,23,42,.06); }
|
||||
:deep(.customer-list-table .customer-action-detail) { border-color:#bfdbfe; background:#eff6ff; color:#2563eb; }
|
||||
:deep(.customer-list-table .customer-action-edit) { border-color:#c7d2fe; background:#eef2ff; color:#667eea; }
|
||||
:deep(.customer-list-table .customer-action-review) { border-color:#fed7aa; background:#fff7ed; color:#ea580c; }
|
||||
:deep(.customer-list-table .customer-action-reset-password) { border-color:#ddd6fe; background:#f5f3ff; color:#7c3aed; }
|
||||
:deep(.customer-list-table .customer-action-impersonate) { border-color:#bae6fd; background:#f0f9ff; color:#0369a1; }
|
||||
:deep(.customer-list-table .customer-action-status.is-enable) { border-color:#bbf7d0; background:#f0fdf4; color:#16a34a; }
|
||||
:deep(.customer-list-table .customer-action-status.is-disable), :deep(.customer-list-table .customer-action-delete) { border-color:#fecaca; background:#fef2f2; color:#dc2626; }
|
||||
:deep(.customer-list-table .customer-action-button:hover) { filter:brightness(.97); transform:none; box-shadow:0 2px 6px rgba(15,23,42,.06); }
|
||||
:deep(.customer-list-table .el-tag) { border:0; border-radius:12px; font-weight:650; }
|
||||
:deep(.customer-list-table .el-tag--success) { background:linear-gradient(135deg,rgba(34,197,94,.15),rgba(22,163,74,.1)); color:#16a34a; }
|
||||
:deep(.customer-list-table .el-tag--warning) { background:linear-gradient(135deg,rgba(245,158,11,.15),rgba(234,88,12,.1)); color:#ea580c; }
|
||||
:deep(.customer-list-table .el-tag--danger) { background:linear-gradient(135deg,rgba(239,68,68,.15),rgba(220,38,38,.1)); color:#dc2626; }
|
||||
:deep(.customer-list-table .el-tag--info) { background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:12px; } .qualification-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; }
|
||||
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
|
||||
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||
.qualification-optional { padding:2px 5px; border-radius:4px; background:var(--el-fill-color-light); color:var(--cc-muted); font-size:10px; font-weight:500; line-height:1.4; }
|
||||
.qualification-file { gap:12px; } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:8px; } .file-picker { position:relative; display:inline-flex; min-height:36px; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 12px; border:1px solid var(--cc-border); border-radius:6px; background:#fff; color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; max-width:140px; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
|
||||
.qualification-preview-trigger { position:relative; display:grid; width:68px; height:68px; flex:0 0 68px; overflow:hidden; place-items:center; padding:0; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; color:var(--cc-muted); cursor:pointer; }
|
||||
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
|
||||
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-surface, #fff); color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
|
||||
.qualification-preview-trigger { position:relative; display:grid; width:68px; height:68px; flex:0 0 68px; overflow:hidden; place-items:center; padding:0; border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-label-bg, #f8fafc); color:var(--cc-muted); cursor:pointer; }
|
||||
.qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); }
|
||||
.qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
|
||||
.qualification-preview-trigger:disabled { cursor:wait; opacity:.72; }
|
||||
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:#fff; }
|
||||
@container (max-width:660px) {
|
||||
.qualification-row { grid-template-columns:112px minmax(0,1fr); }
|
||||
.qualification-header { display:none; }
|
||||
.qualification-label { grid-row:span 2; align-items:center; }
|
||||
.qualification-file,.qualification-validity { min-height:44px; }
|
||||
}
|
||||
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:var(--admin-surface, #fff); }
|
||||
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
|
||||
.qualification-preview-placeholder small { font-size:10px; font-weight:700; }
|
||||
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:#fff; font-size:10px; }
|
||||
.qualification-preview { display:grid; max-height:min(70vh,760px); min-height:240px; place-items:center; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; }
|
||||
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
|
||||
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--admin-surface, #fff); }
|
||||
.qualification-preview { display:grid; max-height:min(70vh,760px); min-height:240px; place-items:center; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-label-bg, #f8fafc); }
|
||||
.qualification-preview img { display:block; max-width:100%; height:auto; }
|
||||
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:#fff; }
|
||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:12px; padding:10px 12px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:12px; }
|
||||
:global(.customer-editor-dialog.el-dialog) { display:flex; max-height:min(90vh,900px); flex-direction:column; }
|
||||
:global(.customer-editor-dialog .el-dialog__body) { min-height:0; overflow-y:auto; overscroll-behavior:contain; }
|
||||
:global(.customer-editor-dialog .el-dialog__footer) { flex:0 0 auto; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
|
||||
:global(.review-decision-dialog.el-dialog) { display:flex; max-height:min(90vh,860px); flex-direction:column; overflow:hidden; }
|
||||
:global(.review-decision-dialog .el-dialog__header) { padding:22px 28px 16px; border-bottom:1px solid #eef1f6; }
|
||||
:global(.review-decision-dialog .el-dialog__body) { min-height:0; overflow-y:auto; padding:20px 28px 24px; background:#fbfcfe; }
|
||||
:global(.review-decision-dialog .el-dialog__footer) { flex:0 0 auto; }
|
||||
.review-dialog-content { display:grid; gap:16px; }
|
||||
.review-dialog-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 16px; border:1px solid #dbe4f0; border-radius:10px; background:linear-gradient(90deg,#f4f7ff,#fff); }
|
||||
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:var(--admin-surface, #fff); }
|
||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:var(--admin-space-3, 8px); } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--admin-label-bg, #f8fafc); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); }
|
||||
.review-dialog-content { display:grid; }
|
||||
.review-dialog-header { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); border:1px solid #dbe4f0; }
|
||||
.review-dialog-title { display:flex; min-width:0; align-items:center; gap:11px; }
|
||||
.review-dialog-title > div { display:grid; min-width:0; gap:3px; }
|
||||
.review-dialog-title strong { overflow:hidden; color:#18243a; font-size:16px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.review-dialog-title span:not(.review-dialog-icon) { overflow:hidden; color:#718096; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.review-dialog-icon { display:grid; width:36px; height:36px; flex:0 0 36px; place-items:center; border-radius:9px; background:#e9efff; color:#526fe8; }
|
||||
.review-dialog-grid { display:grid; grid-template-columns:minmax(270px,.85fr) minmax(390px,1.15fr); gap:16px; }
|
||||
.review-dialog-section,.review-decision-card { min-width:0; padding:16px; border:1px solid #e5eaf1; border-radius:10px; background:#fff; box-shadow:0 2px 8px rgba(15,23,42,.03); }
|
||||
.review-section-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:14px; }
|
||||
.review-dialog-title strong { overflow:hidden; color:#18243a; font-size:var(--admin-font-title, 16px); text-overflow:ellipsis; }
|
||||
.review-dialog-title span:not(.review-dialog-icon) { overflow:hidden; color:#718096; font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; white-space:nowrap; }
|
||||
.review-dialog-icon { width:36px; height:var(--admin-row-height, 36px); flex:0 0 36px; place-items:center; border-radius:9px; background:#e9efff; color:#526fe8; }
|
||||
.review-dialog-grid { display:grid; grid-template-columns:minmax(270px,.85fr) minmax(390px,1.15fr); }
|
||||
.review-dialog-section { min-width:0; border:1px solid #e5eaf1; background:var(--admin-surface, #fff); }
|
||||
.review-decision-card { min-width:0; border:1px solid #e5eaf1; background:var(--admin-surface, #fff); }
|
||||
.review-section-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-5, 12px); }
|
||||
.review-section-heading > div { min-width:0; }
|
||||
.review-section-heading h3 { margin:0; color:#1a2438; font-size:14px; font-weight:700; }
|
||||
.review-section-heading span { display:block; margin-top:4px; color:#8a94a6; font-size:11px; line-height:1.4; }
|
||||
.review-section-heading h3 { margin:0; color:#1a2438; font-size:var(--admin-font-section, 14px); font-weight:700; }
|
||||
.review-section-heading span { display:block; margin-top:var(--admin-space-1, 4px); color:#8a94a6; line-height:1.4; }
|
||||
.review-section-heading > svg { flex:0 0 auto; color:#657bea; }
|
||||
.review-info-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:0; }
|
||||
.review-info-grid > div { min-width:0; padding:10px 11px; border-radius:7px; background:#f7f9fc; }
|
||||
.review-info-grid dt { margin-bottom:4px; color:#8792a5; font-size:11px; }
|
||||
.review-info-grid dd { min-width:0; margin:0; overflow:hidden; color:#28364d; font-size:13px; font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.review-document-table { overflow:hidden; border:1px solid #e4e9f1; border-radius:8px; }
|
||||
.review-document-header,.review-document-row { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
|
||||
.review-document-header { background:#f5f7fb; color:#657188; font-size:11px; font-weight:700; }
|
||||
.review-document-header > span,.review-document-row > span { min-width:0; padding:9px 10px; overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
|
||||
.review-document-header > span:last-child,.review-document-row > span:last-child { border-right:0; }
|
||||
.review-document-row { min-height:46px; border-top:1px solid #edf0f4; color:#334155; font-size:12px; }
|
||||
.review-info-grid { margin:0; }
|
||||
.review-document-table { overflow:hidden; border:1px solid #e4e9f1; }
|
||||
.review-document-header { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
|
||||
.review-document-row { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
|
||||
.review-document-header { background:#f5f7fb; color:#657188; font-weight:700; }
|
||||
.review-document-header > span { min-width:0; padding:9px var(--admin-space-4, 10px); overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
|
||||
.review-document-row > span { min-width:0; padding:9px var(--admin-space-4, 10px); overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
|
||||
.review-document-header > span:last-child { border-right:0; }
|
||||
.review-document-row > span:last-child { border-right:0; }
|
||||
.review-document-row { min-height:46px; border-top:1px solid #edf0f4; color:#334155; font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-row > span { display:flex; align-items:center; }
|
||||
.review-document-type { font-weight:650; }
|
||||
.review-document-name { min-width:0; gap:6px; overflow:hidden; color:#4c5b72; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.review-document-name { min-width:0; gap:var(--admin-space-2, 6px); overflow:hidden; color:#4c5b72; text-overflow:ellipsis; }
|
||||
.review-document-name svg { flex:0 0 auto; color:#8798cf; }
|
||||
.review-document-actions { justify-content:flex-end; gap:6px; }
|
||||
.review-document-actions { justify-content:flex-end; gap:var(--admin-space-2, 6px); }
|
||||
.review-document-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.review-document-state,.review-document-error,.review-document-empty { padding:14px; color:var(--cc-muted); font-size:12px; }
|
||||
.review-document-error { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; }
|
||||
.review-document-state { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-error { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-empty { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-3, 8px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; }
|
||||
.review-document-empty { display:flex; align-items:center; justify-content:center; gap:7px; text-align:center; }.review-document-empty svg { color:#a5afc1; }
|
||||
.review-decision-card :deep(.el-form-item) { margin-bottom:0; }.review-decision-card :deep(.el-form-item__label) { color:#4b5870; font-size:12px; font-weight:650; }.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 14px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
||||
.review-decision-card :deep(.el-form-item) { margin-bottom:0; }.review-decision-card :deep(.el-form-item__label) { color:#4b5870; font-size:var(--admin-font-caption, 12px); font-weight:650; }.decision-summary > div > span { display:block; margin-top:var(--admin-space-1, 4px); }.decision-type { margin:0 0 14px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:var(--admin-space-3, 8px); }.dialog-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; }.archive-confirm { display:grid; gap:var(--admin-space-3, 8px); margin:var(--admin-space-6, 16px) 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:var(--admin-space-4, 10px); padding:var(--admin-space-5, 12px) 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong { display:block; } .approval-result-warning span { display:block; } .approval-result-warning strong { font-size:var(--admin-font-section, 14px); } .approval-result-warning span { margin-top:var(--admin-space-1, 4px); font-size:var(--admin-font-caption, 12px); line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:var(--admin-space-5, 12px); } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:var(--admin-space-4, 10px); margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:var(--admin-font-section, 14px); font-weight:700; }
|
||||
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:12px; }.review-info-grid { grid-template-columns:1fr 1fr; }.review-document-header,.review-document-row { grid-template-columns:104px minmax(0,1fr) 124px; }.review-dialog-header { align-items:flex-start; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
@media (max-width:480px) { :global(.review-decision-dialog .el-dialog__header),:global(.review-decision-dialog .el-dialog__body) { padding-left:16px; padding-right:16px; }.review-info-grid { grid-template-columns:1fr; }.review-document-header { display:none; }.review-document-row { grid-template-columns:1fr; gap:6px; padding:10px; }.review-document-row > span { padding:0; border-right:0; }.review-document-actions { justify-content:flex-start; }.review-dialog-title strong { max-width:190px; } }
|
||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -65,21 +67,32 @@ onMounted(() => { void load() })
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="business-page overseas-fees-page">
|
||||
<div class="business-page overseas-fees-page admin-density customer-density">
|
||||
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
|
||||
<template v-if="error" #state><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border stripe empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" size="small" plain type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #storage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||
<template #returnStorage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||
|
||||
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
|
||||
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
|
||||
|
||||
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" class="density-action" size="small" plain @click="openEdit(row as OverseasStorageFeeConfig)">编辑</el-button></div></template>
|
||||
<template #storage="{ row }">
|
||||
<dl class="tier-list" aria-label="仓租阶梯单价,币种见本行币种列">
|
||||
<div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><dt>{{ label(tier) }}:</dt><dd>{{ tier.unitPrice }}</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
<template #returnStorage="{ row }">
|
||||
<dl class="tier-list" aria-label="退货仓储费阶梯单价,币种见本行币种列">
|
||||
<div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><dt>{{ label(tier) }}:</dt><dd>{{ tier.unitPrice }}</dd></div>
|
||||
</dl>
|
||||
</template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)"><Trash2 :size="15" />删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)">删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></template></el-table-column></el-table></div></div></el-form>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</AdminPage>
|
||||
@@ -87,16 +100,17 @@ onMounted(() => { void load() })
|
||||
|
||||
<style scoped>
|
||||
.error-state { display:flex; justify-content:space-between; padding:20px; color:#b42318; }
|
||||
.tier-list { display:grid; grid-template-columns:repeat(2,minmax(130px,1fr)); gap:4px 8px; padding:2px 0; }
|
||||
.tier-item { display:flex; align-items:center; justify-content:space-between; gap:6px; min-height:24px; padding:2px 6px; border-left:2px solid #409eff; background:#f6f9fd; }
|
||||
.tier-item span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
|
||||
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
|
||||
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; }
|
||||
.tier-list { display:flex; flex-wrap:wrap; gap:var(--admin-space-1, 4px) var(--admin-space-6, 16px); margin:0; padding:2px 0; white-space:normal; }
|
||||
.tier-list:empty::after { content:'—'; color:var(--cc-muted); }
|
||||
.tier-item { display:flex; align-items:baseline; min-width:0; max-width:100%; line-height:24px; }
|
||||
.tier-item dt { flex:0 0 auto; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.tier-item dd { min-width:0; margin:0; color:var(--cc-text); font-size:var(--admin-font-body, 13px); font-weight:600; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.groups > div { min-width:0; }
|
||||
.groups :deep(.el-table) { width:100%; }
|
||||
.groups :deep(.el-input-number) { width:100%; min-width:0; }
|
||||
.groups h3 { margin:0 0 10px; font-size:15px; }
|
||||
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.groups h3 { margin:0 0 var(--admin-space-4, 10px); }
|
||||
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||
.group-heading h3 { min-width:0; margin:0; }
|
||||
.group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .groups { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -5,13 +5,13 @@ export const customerRoutes = [
|
||||
path: 'customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/modules/customers/pages/CustomersView.vue'),
|
||||
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
|
||||
meta: { title: '客户管理', showPageHeading: false, description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
|
||||
},
|
||||
{
|
||||
path: 'customers/overseas-storage-fees',
|
||||
name: 'overseas-storage-fees',
|
||||
component: () => import('@/modules/customers/pages/OverseasStorageFeesView.vue'),
|
||||
meta: { title: '海外仓阶梯费用', description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' },
|
||||
meta: { title: '海外仓阶梯费用', showPageHeading: false, description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' },
|
||||
},
|
||||
{
|
||||
path: 'customers/:tenantId',
|
||||
|
||||
@@ -3,6 +3,10 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8')
|
||||
describe('CustomsCasesView', () => {
|
||||
it('uses a wide customer filter and compact status filter', () => {
|
||||
expect(source).toContain("key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220")
|
||||
expect(source).toContain("key: 'status', type: 'select', label: '状态', width: 160")
|
||||
})
|
||||
it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") })
|
||||
it('supports detail, history and agent submission', () => { expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') })
|
||||
it('selects an active customs supplier and submits its stable identity and name snapshot', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const customers = ref<PlatformTenantResource[]>([]); const customersLoading = re
|
||||
const customsAgents = ref<Supplier[]>([]); const customsAgentsLoading = ref(false); const customsAgentsError = ref('')
|
||||
const filters = ref<QueryModel>({ customerId: '', status: '' }); const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 160, options: customsCaseStatusOptions.map((option) => ({ ...option })) },
|
||||
])
|
||||
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { key: 'orderLink', label: '订单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.orderId) }, { key: 'inboundLink', label: '入库单关联', minWidth: 120, formatter: (row: CustomsCaseResource) => associationLabel(row.inboundId) }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
|
||||
function associationLabel(value: string | null | undefined) { return value ? '已关联' : '未关联' }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { listPlatformUnifiedOrders } from '@/modules/unified-orders'
|
||||
import {
|
||||
getActiveCustomerCount,
|
||||
getDashboardOperationalMetrics,
|
||||
getTodayNewOrderCount,
|
||||
} from './dashboard'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/unified-orders', () => ({ listPlatformUnifiedOrders: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/shared/api/http')>(),
|
||||
http: { get: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('dashboard metrics api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('normalizes the operational metrics response', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: {
|
||||
inTransitContainerCount: '3', inTransitTeu: '4.25', unknownContainerTypeCount: '1',
|
||||
pendingCustomsOrderCount: '7', asOf: '2026-09-11T12:00:00',
|
||||
} })
|
||||
|
||||
await expect(getDashboardOperationalMetrics()).resolves.toEqual({
|
||||
inTransitContainerCount: 3, inTransitTeu: 4.25, unknownContainerTypeCount: 1,
|
||||
pendingCustomsOrderCount: 7, asOf: '2026-09-11T12:00:00',
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/dashboard/operational-metrics')
|
||||
})
|
||||
|
||||
it('uses list totals for today orders and active customers', async () => {
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 1, total: 12, pages: 12 })
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 1, total: 8, pages: 8 })
|
||||
|
||||
await expect(getTodayNewOrderCount()).resolves.toBe(12)
|
||||
await expect(getActiveCustomerCount()).resolves.toBe(8)
|
||||
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 1 }))
|
||||
const orderQuery = vi.mocked(listPlatformUnifiedOrders).mock.calls[0]![0]
|
||||
expect(orderQuery.createdFrom).toMatch(/T00:00:00\.000$/)
|
||||
expect(orderQuery.createdTo).toMatch(/T23:59:59\.999$/)
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 1, status: 'ACTIVE' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { listPlatformUnifiedOrders } from '@/modules/unified-orders'
|
||||
import { http, type ApiEnvelope, unwrapApiResponse } from '@/shared/api/http'
|
||||
|
||||
export interface DashboardOperationalMetrics {
|
||||
inTransitContainerCount: number
|
||||
inTransitTeu: number
|
||||
unknownContainerTypeCount: number
|
||||
pendingCustomsOrderCount: number
|
||||
asOf: string | null
|
||||
}
|
||||
|
||||
interface RawDashboardOperationalMetrics {
|
||||
inTransitContainerCount: number | string
|
||||
inTransitTeu: number | string
|
||||
unknownContainerTypeCount: number | string
|
||||
pendingCustomsOrderCount: number | string
|
||||
asOf?: string | null
|
||||
}
|
||||
|
||||
function numberValue(value: number | string | null | undefined) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||
}
|
||||
|
||||
export async function getDashboardOperationalMetrics(): Promise<DashboardOperationalMetrics> {
|
||||
const response = await http.get<ApiEnvelope<RawDashboardOperationalMetrics>, ApiEnvelope<RawDashboardOperationalMetrics>>(
|
||||
'/platform/v1/first-leg/dashboard/operational-metrics',
|
||||
)
|
||||
const data = unwrapApiResponse(response)
|
||||
return {
|
||||
inTransitContainerCount: numberValue(data.inTransitContainerCount),
|
||||
inTransitTeu: numberValue(data.inTransitTeu),
|
||||
unknownContainerTypeCount: numberValue(data.unknownContainerTypeCount),
|
||||
pendingCustomsOrderCount: numberValue(data.pendingCustomsOrderCount),
|
||||
asOf: data.asOf || null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTodayNewOrderCount() {
|
||||
const today = dayjs()
|
||||
const result = await listPlatformUnifiedOrders({
|
||||
createdFrom: today.startOf('day').format('YYYY-MM-DDTHH:mm:ss.SSS'),
|
||||
createdTo: today.endOf('day').format('YYYY-MM-DDTHH:mm:ss.SSS'),
|
||||
page: 1,
|
||||
size: 1,
|
||||
})
|
||||
return result.total
|
||||
}
|
||||
|
||||
export async function getActiveCustomerCount() {
|
||||
const result = await listPlatformTenants({ page: 1, size: 1, status: 'ACTIVE' })
|
||||
return result.total
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user