Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9726e5c3ba | ||
|
|
01ecd63991 | ||
|
|
3598310237 | ||
|
|
6b3286fa5a | ||
|
|
f155976f52 | ||
|
|
db32f31cc2 | ||
|
|
489b1bcd16 | ||
|
|
291535e242 | ||
|
|
1e80470078 | ||
|
|
6f7f8e7632 | ||
|
|
03b1e7163e | ||
|
|
5679b156ca | ||
|
|
3879a47673 | ||
|
|
b34626bd4b | ||
|
|
7005228b52 | ||
|
|
836e210b09 | ||
|
|
41a498657c | ||
|
|
f4224e5d3a | ||
|
|
3267679758 | ||
|
|
7ff7c9914f | ||
|
|
5c28cd7c79 | ||
|
|
d2f2ecf57a | ||
|
|
5a46198bf0 | ||
|
|
ada663128a | ||
|
|
17cb1373e1 | ||
|
|
29f887496a | ||
|
|
b92e5379bc | ||
|
|
767c6b7589 | ||
|
|
f7f6637c45 | ||
|
|
05cf82dbbc | ||
|
|
8ae1e5d0d4 | ||
|
|
4bb21641d9 | ||
|
|
67d917010d | ||
|
|
a5a1976924 | ||
|
|
4806f88d7c | ||
|
|
2705fdbc49 | ||
|
|
ce7f6cee02 | ||
|
|
fbee53c9de | ||
|
|
45f662ecf2 | ||
|
|
148085286d | ||
|
|
f6638ca6c6 | ||
|
|
baee81f459 | ||
|
|
9f51392754 | ||
|
|
8dc0d2d3b8 | ||
|
|
538970797d | ||
|
|
60378fea9b | ||
|
|
f9193a3564 | ||
|
|
db1e9ee27b | ||
|
|
b769d2479c | ||
|
|
af2e1481a6 | ||
|
|
092690791d | ||
|
|
9a10fb6657 | ||
|
|
68284ad2bf | ||
|
|
5e8c17c82f | ||
|
|
50b06dca10 | ||
|
|
11e348a29f | ||
|
|
08fac66fe1 | ||
|
|
8663a829e2 | ||
|
|
2975ba5a7f | ||
|
|
2ade6bfa3c | ||
|
|
46364d3f89 | ||
|
|
2a9c23ebc6 | ||
|
|
adfb1a5cb3 | ||
|
|
ff9ca74ba4 | ||
|
|
cb38114db9 | ||
|
|
e7411eb03f | ||
|
|
6b30e35d07 | ||
|
|
0830d7a3a3 | ||
|
|
6a905419fc | ||
|
|
d8a4160bc7 | ||
|
|
e0f54edcc9 | ||
|
|
3f728cd0b2 | ||
|
|
3291d34fb8 | ||
|
|
02a9d72ef8 | ||
|
|
2cf9129606 | ||
|
|
4c4a3db1eb | ||
|
|
698ca34529 | ||
|
|
133654327d | ||
|
|
260409c071 | ||
|
|
c7d9af0165 | ||
|
|
bda0743520 | ||
|
|
50df363f86 | ||
|
|
c61ae525e1 | ||
|
|
8bf7b20b89 | ||
|
|
1de73b5f1c | ||
|
|
6bdaff3fdf | ||
|
|
9dcd991ddb | ||
|
|
58df0ed9e3 | ||
|
|
b73dc0d410 | ||
|
|
cd3a32f372 | ||
|
|
600fdcb4b4 | ||
|
|
57c50e6876 | ||
|
|
449d0c4965 | ||
|
|
2186f1f3a6 | ||
|
|
533273769d | ||
|
|
cc4856ceac | ||
|
|
3f6d74dee1 | ||
|
|
92bdd32f86 | ||
|
|
20b4d1425b | ||
|
|
43611ecc55 | ||
|
|
f97cdad353 | ||
|
|
315cb8e663 | ||
|
|
ee174f8265 | ||
|
|
240aa5d6d0 | ||
|
|
d77abbef7c | ||
|
|
1edb65df77 | ||
|
|
baa69e66a1 | ||
|
|
d7f2d09b88 | ||
|
|
e124fbcf68 | ||
|
|
ebff6f8167 | ||
|
|
7f39adf9f5 | ||
|
|
67bd3e6e53 | ||
|
|
c0155b8293 | ||
|
|
175d8936b9 | ||
|
|
081f79a9a1 | ||
|
|
e404fe1049 | ||
|
|
c69115f1b7 | ||
|
|
a564850e23 | ||
|
|
453549ece1 | ||
|
|
be2a754342 | ||
|
|
0cb8a5c2b2 | ||
|
|
40cccee266 | ||
|
|
1abc831df1 | ||
|
|
b20c3ac104 | ||
|
|
172cb05c78 | ||
|
|
2bf561bafe | ||
|
|
0ca12de2f4 | ||
|
|
e3038edd38 | ||
|
|
b14d22b55e | ||
|
|
871c8c021b | ||
|
|
33e7e1e41b | ||
|
|
320c2e4471 | ||
|
|
901431f17a | ||
|
|
fb062b5ec4 | ||
|
|
84488adf12 | ||
|
|
b835e52bcb | ||
|
|
214b58b642 | ||
|
|
df4d3dfd66 | ||
|
|
302cc50a4b | ||
|
|
2f23e30e20 | ||
|
|
e1457a8e57 | ||
|
|
f047dce10c | ||
|
|
ebaa265993 | ||
|
|
88dc4852c6 | ||
|
|
3df3f874db | ||
|
|
692beff782 | ||
|
|
7a9e91b7ce | ||
|
|
00a186b37c | ||
|
|
826d444afa | ||
|
|
3a917324f1 | ||
|
|
6475b7b12d | ||
|
|
f405452c37 | ||
|
|
7500f37846 | ||
|
|
73c29f5264 | ||
|
|
8a2f3378f6 | ||
|
|
8bf0e2260e | ||
|
|
9c4512db6a | ||
|
|
9ab8795008 | ||
|
|
9ed5448fe6 | ||
|
|
2caeb12024 | ||
|
|
6e669a1377 | ||
|
|
2998c62099 | ||
|
|
c90a191d83 | ||
|
|
115461683b | ||
|
|
9c70eab606 | ||
|
|
5946bdfcf7 | ||
|
|
7b0df1db37 | ||
|
|
73af3affac | ||
|
|
3c3a7fcd55 | ||
|
|
8fa397bb5e | ||
|
|
cc37a42298 | ||
|
|
6551b703cb | ||
|
|
f24b592705 | ||
|
|
d7911ad09d | ||
|
|
080042d941 | ||
|
|
9ff2720721 | ||
|
|
e4e78796a1 | ||
|
|
ba6d0ff13f | ||
|
|
2bc095ecac | ||
|
|
2477667c27 | ||
|
|
04e1d7dbfa | ||
|
|
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 | ||
|
|
688eddfa77 | ||
|
|
8460cbe123 | ||
|
|
ea035628aa | ||
|
|
7d29f3d7ea | ||
|
|
864a3c3cc7 | ||
|
|
4eda56acf1 | ||
|
|
72520225d9 | ||
|
|
249499cf14 | ||
|
|
093c89c058 | ||
|
|
d5662230d5 | ||
|
|
e390b88b6c | ||
|
|
3cd4f4f7b3 | ||
|
|
7f7c84d404 | ||
|
|
7451a713e5 | ||
|
|
2435aac81d | ||
|
|
2d14568caf | ||
|
|
475b2a08e2 | ||
|
|
38b435a7ac | ||
|
|
de4b719a52 | ||
|
|
1db4f01e95 | ||
|
|
c20ee82def | ||
|
|
bc44cbac46 | ||
|
|
2a185c8c9e | ||
|
|
d5ebe6c7e8 | ||
|
|
a825060a45 | ||
|
|
60fcc58e30 | ||
|
|
d111694185 | ||
|
|
3ac2dc807f | ||
|
|
e94ae9b2b7 | ||
|
|
3fd7768e28 | ||
|
|
d90a5db1c4 | ||
|
|
15beb497c1 | ||
|
|
e2b0102fdb | ||
|
|
8f77ad333b | ||
|
|
4ae5598c2d | ||
|
|
393d32bcd9 | ||
|
|
a9bfa69e67 | ||
|
|
7633700e66 | ||
|
|
749bcce438 | ||
|
|
f81bf8576a | ||
|
|
afb534d4ee | ||
|
|
65a470cdac | ||
|
|
664d4844c3 | ||
|
|
3cde5b5881 | ||
|
|
764b4cc2e6 | ||
|
|
0bf4f5781a | ||
|
|
677a666d9b | ||
|
|
a25b4ed0f7 | ||
|
|
f50ea81cbc | ||
|
|
31dc0ec0ef | ||
|
|
52b1fce8c1 | ||
|
|
7175f04609 | ||
|
|
50ca6a6269 | ||
|
|
ce1075b9b8 | ||
|
|
aeafb9a6f1 | ||
|
|
3f397a87a2 | ||
|
|
d4873aa627 | ||
|
|
d8b59ecca5 | ||
|
|
f50debc5f7 | ||
|
|
a09016835c | ||
|
|
dd146a09b1 | ||
|
|
7ec9add043 | ||
|
|
246cbc115e | ||
|
|
2f7c122c44 | ||
|
|
b7985f4a6d | ||
|
|
4f65948347 | ||
|
|
176f815519 | ||
|
|
9b3664c532 | ||
|
|
bbe8b73cd4 | ||
|
|
3c68ef4ddf | ||
|
|
6270573095 | ||
|
|
9377a4ead3 | ||
|
|
f0801c2774 | ||
|
|
913a8df7f3 | ||
|
|
f281268efe | ||
|
|
490fa4e2e5 | ||
|
|
d4b0462a5a | ||
|
|
e31ba2c5a5 | ||
|
|
602674573c | ||
|
|
b3e6971ea8 | ||
|
|
b5b4b41b99 | ||
|
|
637b202559 | ||
|
|
fa84291551 | ||
|
|
d49bb5a655 | ||
|
|
f75e08c1bb | ||
|
|
0708b4a262 | ||
|
|
776c0e2ebb | ||
|
|
eecf6d8f8a | ||
|
|
28acf1174a | ||
|
|
490b685f3a | ||
|
|
bbb9c40c50 | ||
|
|
132d0c22d7 | ||
|
|
4cdaa1d1ad | ||
|
|
b6b43b6b0a | ||
|
|
19eea79836 | ||
|
|
a14e39893c | ||
|
|
2f26760135 | ||
|
|
73b229bb95 | ||
|
|
a9570a4dae | ||
|
|
29b937989a | ||
|
|
a5d4b7d54a | ||
|
|
440cae9c6d | ||
|
|
4ad8537113 | ||
|
|
5706645032 | ||
|
|
9fcea1ca1c | ||
|
|
adb077a714 | ||
|
|
c59608ff2c | ||
|
|
0b676ddb74 | ||
|
|
519ce1dc96 | ||
|
|
76604da31b | ||
|
|
e42d765f5c | ||
|
|
b4c29e2226 | ||
|
|
041452f2ff | ||
|
|
e3ecba7133 | ||
|
|
4711bcc4e6 | ||
|
|
0cf4a93f9c | ||
|
|
62a016a2f8 | ||
|
|
96467b9468 | ||
|
|
be687f8ded | ||
|
|
8161066657 | ||
|
|
c0d6a4eb47 | ||
|
|
66c6dfdadc | ||
|
|
49babd731a | ||
|
|
623dfce255 | ||
|
|
4d5fd5cfe6 | ||
|
|
09579ea285 | ||
|
|
a759eb1b6b | ||
|
|
0f4f635686 | ||
|
|
c7ba96457a | ||
|
|
3cae7e4eb5 | ||
|
|
11f8b7856e | ||
|
|
83374ba6cc | ||
|
|
3add5dc5b8 | ||
|
|
1a81a5a2b6 | ||
|
|
afbee83b51 | ||
|
|
cd290b8dc6 | ||
|
|
dc1027804e | ||
|
|
4bf937ffd3 | ||
|
|
d0770517dc | ||
|
|
66ea73e766 | ||
|
|
6ec0a0a028 | ||
|
|
f7328e7c60 | ||
|
|
50c3a7e1ba | ||
|
|
0b9d328bdf | ||
|
|
1548d444c6 | ||
|
|
b4d5fb1170 | ||
|
|
0e828cd44a | ||
|
|
462d02efd5 | ||
|
|
abdea48e8d | ||
|
|
8c1dc50cf3 | ||
|
|
2e313172d1 | ||
|
|
06eb745f8b | ||
|
|
0c002c1ca1 | ||
|
|
cee0464e69 | ||
|
|
ae52141952 | ||
|
|
9d3862b215 | ||
|
|
de1b956d43 | ||
|
|
c92f3ce2b9 | ||
|
|
1aa2dfcdff | ||
|
|
aee562d998 | ||
|
|
5e617f0c16 | ||
|
|
de81a16ceb | ||
|
|
813763a02a | ||
|
|
ea1d06448b | ||
|
|
f7b8159d30 | ||
|
|
0044c01ed0 | ||
|
|
d4fc74e601 | ||
|
|
f6cd60ec61 | ||
|
|
94a5dfedf6 | ||
|
|
3525e434a4 | ||
|
|
632fc0bdfa | ||
|
|
ae48f46c9f | ||
|
|
94f003153d | ||
|
|
251645d2d8 | ||
|
|
67a1278554 | ||
|
|
d45e2b4358 | ||
|
|
4687f17d09 | ||
|
|
8f447e58f4 | ||
|
|
b47323edf3 | ||
|
|
44d05e77cb | ||
|
|
929f50dc55 | ||
|
|
0181656b4f | ||
|
|
a90d5fbe39 | ||
|
|
690f386bff | ||
|
|
00045a9764 | ||
|
|
24575223a0 | ||
|
|
550d8add97 | ||
|
|
82f0479928 | ||
|
|
506d310211 | ||
|
|
f7afcb6cdc | ||
|
|
3e3c7e237a | ||
|
|
29bb641524 | ||
|
|
d92af5559e | ||
|
|
38031b0fa0 | ||
|
|
9eec68f7c5 | ||
|
|
7b50e92baf | ||
|
|
64f4e8ee34 | ||
|
|
0ce42a01e2 | ||
|
|
a546ee4c3b | ||
|
|
6d4748c62a | ||
|
|
525c56fdb1 | ||
|
|
fac3153472 | ||
|
|
b907881740 | ||
|
|
a67f6300f8 | ||
|
|
b8948fdff0 | ||
|
|
2563ad7be3 | ||
|
|
627b9602d1 | ||
|
|
5daaf1a5fa | ||
|
|
a3d04eaad8 | ||
|
|
d67544af5e | ||
|
|
9585c21f14 | ||
|
|
4a7a5ae5b6 | ||
|
|
4088e36f74 | ||
|
|
e8c41bca2d | ||
|
|
8307510fc3 | ||
|
|
011195012b | ||
|
|
f6d1febea8 | ||
|
|
c38e538a95 | ||
|
|
d03f7b3f5d | ||
|
|
53c92d4d37 | ||
|
|
57de615717 | ||
|
|
dcbb6d249f | ||
|
|
966fbd1880 | ||
|
|
68b0f8bdf9 | ||
|
|
10ff84497f | ||
|
|
c3ea54dcd4 | ||
|
|
ab168ee4f4 | ||
|
|
f702d1b988 | ||
|
|
3545ffa522 | ||
|
|
d5a23a7c65 | ||
|
|
26fcec530f | ||
|
|
842cb078fc | ||
|
|
11580f9f83 | ||
|
|
f4dea3f705 | ||
|
|
8b3aae399a | ||
|
|
c73cee3962 | ||
|
|
bcdce8ef6c | ||
|
|
787ab1c90c | ||
|
|
5641545fd5 | ||
|
|
e9b6979a82 | ||
|
|
1f3e14a6cd | ||
|
|
10f3e5789a | ||
|
|
37ec787bc1 | ||
|
|
964480a617 | ||
|
|
91db777cb8 | ||
|
|
f6b5ad89a1 | ||
|
|
7033903e67 | ||
|
|
f211d12cda | ||
|
|
806afac6a8 | ||
|
|
866ea58cab | ||
|
|
25eedfe7c7 | ||
|
|
bff9ccbfb6 | ||
|
|
a4f2a9b8a7 | ||
|
|
76c71a3e5e | ||
|
|
0e67cb9d65 |
@@ -3,6 +3,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules.broken-by-path
|
||||
.env.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
@@ -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,15 +7,16 @@ dist/
|
||||
!.env.example
|
||||
|
||||
.playwright-cli/
|
||||
.idea/
|
||||
.impeccable/
|
||||
.codex-temp/
|
||||
.codex-tmp/
|
||||
.logs/
|
||||
outputs/
|
||||
reports/
|
||||
components.d.ts
|
||||
auto-imports.d.ts
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
|
||||
@@ -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,55 +31,70 @@
|
||||
|
||||
- 依赖方向为 `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`,不提交真实地址或凭据。
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
|
||||
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
||||
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
|
||||
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
|
||||
| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 |
|
||||
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `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` |
|
||||
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||
|
||||
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
|
||||
|
||||
## 四期海外模块内部快速索引
|
||||
| 业务 | 首个入口 |
|
||||
|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
|
||||
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
|
||||
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
|
||||
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`modules/overseas-warehouse/` |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
|
||||
| 消息中心 | `modules/notifications/AGENTS.md` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`modules/first-leg-orders/` |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/AGENTS.md`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、装柜照片与证据、真实柜号后补与整柜报关 | `modules/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/AGENTS.md` |
|
||||
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
|
||||
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
|
||||
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
|
||||
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
|
||||
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
|
||||
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` |
|
||||
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `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`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;仅查询、候选和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
||||
| 库存余额、库位与流水 | `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` 代替当前代码与服务端契约。
|
||||
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
|
||||
|
||||
## 业务、安全与共享装配
|
||||
|
||||
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||
- 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。
|
||||
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
||||
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
||||
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
|
||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
|
||||
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||
@@ -85,10 +103,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
|
||||
@@ -99,7 +120,7 @@ pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 构建 Vue 静态文件
|
||||
FROM node:22-alpine AS build
|
||||
# 构建 Vue 静态文件。CI 通过构建参数切换到内网基础镜像,
|
||||
# 本地开发仍可使用 Docker Hub 默认值。
|
||||
ARG NODE_IMAGE=node:22-alpine
|
||||
ARG NGINX_IMAGE=nginx:1.27-alpine
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -24,7 +27,7 @@ ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
|
||||
RUN pnpm build
|
||||
|
||||
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
|
||||
FROM nginx:1.27-alpine
|
||||
FROM ${NGINX_IMAGE}
|
||||
|
||||
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
@@ -1,94 +1,62 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8'
|
||||
DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy'
|
||||
DEVELOPMENT_SSH_KNOWN_HOSTS = '/var/jenkins_home/.ssh/known_hosts'
|
||||
DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/frontend/admin-web'
|
||||
}
|
||||
|
||||
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 {
|
||||
sh '''
|
||||
set -eu
|
||||
sh '''set -eu
|
||||
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 {
|
||||
sh '''
|
||||
set -eu
|
||||
image="admin-web-development:${SOURCE_TAG}"
|
||||
ssh_args="-i ${DEVELOPMENT_SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEVELOPMENT_SSH_KNOWN_HOSTS} -o ConnectTimeout=10"
|
||||
|
||||
docker build \
|
||||
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
|
||||
-t "$image" .
|
||||
docker save "$image" | gzip -1 | ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" 'gzip -dc | docker load'
|
||||
|
||||
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" "install -d -m 750 '$DEVELOPMENT_DEPLOY_DIR'"
|
||||
scp $ssh_args docker-compose.development.yml \
|
||||
"$DEVELOPMENT_SSH_TARGET:$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp"
|
||||
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" \
|
||||
"mv -f '$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp' '$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml'"
|
||||
|
||||
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" sh -s -- "$DEVELOPMENT_DEPLOY_DIR" "$SOURCE_TAG" <<'REMOTE'
|
||||
set -eu
|
||||
cd "$1"
|
||||
export IMAGE_TAG="$2"
|
||||
docker compose -f docker-compose.yml up -d --remove-orphans --pull never
|
||||
docker compose -f docker-compose.yml ps
|
||||
docker compose -f docker-compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
REMOTE
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署检查') {
|
||||
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
docker compose -f docker-compose.test.yml ps
|
||||
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
|
||||
ssh -i "$DEVELOPMENT_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$DEVELOPMENT_SSH_KNOWN_HOSTS" \
|
||||
"$DEVELOPMENT_SSH_TARGET" 'docker logs --tail=100 admin-web-development || true' || 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 部署时设置容器环境变量 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。
|
||||
唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。
|
||||
|
||||
认证请求使用 `/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,13 +0,0 @@
|
||||
services:
|
||||
admin-web:
|
||||
image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: admin-web-development
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9001:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "http://8.163.49.32:9002"
|
||||
API_UPSTREAM: "http://host.docker.internal:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
|
||||
@@ -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
|
||||
@@ -1,19 +1,29 @@
|
||||
name: firstleg-test-admin-web
|
||||
services:
|
||||
admin-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: admin-web-test:local
|
||||
args:
|
||||
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: admin-web-test
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
ports:
|
||||
- "${HOST_PORT:-9001}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
|
||||
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
|
||||
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
|
||||
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
|
||||
API_UPSTREAM: "http://host.docker.internal:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
|
||||
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}"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Admin业务详细索引
|
||||
|
||||
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
|
||||
|
||||
正文代码路径以工作区 `apps/admin-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
|
||||
|
||||
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
|
||||
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||
|
||||
全量验收按业务从 `../../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 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
|
||||
## 货柜工作台规则
|
||||
|
||||
- 国内“送柜报关”只消费 `/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/`
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||
|---|---|---|
|
||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
||||
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
||||
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
|
||||
@@ -13,3 +13,5 @@
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,21 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Resolve independent PDA containers at request time; their restart must not block Admin startup.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
location = /pda-domestic { return 308 /pda-domestic/; }
|
||||
location = /pda-overseas { return 308 /pda-overseas/; }
|
||||
location ^~ /pda-domestic/ {
|
||||
set $pda_domestic_origin http://pda-domestic-h5-test:80;
|
||||
proxy_pass $pda_domestic_origin$request_uri;
|
||||
proxy_set_header Host $proxy_host;
|
||||
}
|
||||
location ^~ /pda-overseas/ {
|
||||
set $pda_overseas_origin http://pda-overseas-h5-test:80;
|
||||
proxy_pass $pda_overseas_origin$request_uri;
|
||||
proxy_set_header Host $proxy_host;
|
||||
}
|
||||
|
||||
# Docker 容器运行正常的检查地址,供 Jenkins 使用。
|
||||
location = /healthz {
|
||||
default_type text/plain;
|
||||
@@ -21,27 +36,39 @@ server {
|
||||
|
||||
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
|
||||
location /api/ {
|
||||
client_max_body_size 12m;
|
||||
client_max_body_size 55m;
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
||||
location /overseas-api/ {
|
||||
client_max_body_size 12m;
|
||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
||||
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。
|
||||
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
|
||||
location /openapi/ {
|
||||
client_max_body_size 55m;
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 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 {
|
||||
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"testOnly": true,
|
||||
"deployment": {
|
||||
"admin": {
|
||||
"build": 536,
|
||||
"result": "SUCCESS",
|
||||
"commit": "73c29f5"
|
||||
},
|
||||
"firstleg": {
|
||||
"build": 488,
|
||||
"result": "SUCCESS",
|
||||
"commit": "e94271706ce7"
|
||||
},
|
||||
"lastleg": {
|
||||
"build": 234,
|
||||
"result": "SUCCESS",
|
||||
"commit": "7fd7699f6e83"
|
||||
}
|
||||
},
|
||||
"databaseReset": {
|
||||
"tables": [
|
||||
"ll_inbound_fba_appointment",
|
||||
"ll_inbound_fba_appointment_item"
|
||||
],
|
||||
"beforeCounts": [
|
||||
49,
|
||||
59
|
||||
],
|
||||
"afterResetCounts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"httpPassed": [
|
||||
"five_customer_one_appointment",
|
||||
"same_command_replay",
|
||||
"duplicate_cargo_conflict",
|
||||
"update_version",
|
||||
"stale_update_conflict",
|
||||
"stale_delete_conflict",
|
||||
"delete_and_calendar_absence",
|
||||
"rebook_after_delete",
|
||||
"two_new_customer_accounts_10_negative_isolation_checks",
|
||||
"nonempty_customer_75_rows_all_owned",
|
||||
"nonempty_customer_forged_filter_stays_scoped",
|
||||
"own_plan_detail_visible_other_plan_hidden",
|
||||
"existing_customer_platform_appointment_forbidden",
|
||||
"browser_deleted_appointment_absence_http_confirmed"
|
||||
],
|
||||
"uiPassed": [
|
||||
"1920x1080_month",
|
||||
"five_customer_detail",
|
||||
"edit_saved",
|
||||
"week_view",
|
||||
"candidate_existing_appointment_disabled",
|
||||
"two_customer_selection",
|
||||
"cross_page_selection_retained",
|
||||
"create_saved",
|
||||
"final_month_top_alignment",
|
||||
"final_week_top_alignment",
|
||||
"delete_confirmation_and_active_dispatch_rejection",
|
||||
"month_week_three_previews_233_total_title",
|
||||
"browser_successful_delete_two_to_one"
|
||||
],
|
||||
"initialCalendarRequests": [
|
||||
{
|
||||
"type": "appointments",
|
||||
"count": 1,
|
||||
"durationMs": 59
|
||||
},
|
||||
{
|
||||
"type": "arrivals",
|
||||
"count": 1,
|
||||
"durationMs": 106
|
||||
}
|
||||
],
|
||||
"pending": [],
|
||||
"screenshots": [
|
||||
"month-five-customers.png",
|
||||
"week-five-customers.png",
|
||||
"final-month.png",
|
||||
"compact-month.png",
|
||||
"compact-week.png",
|
||||
"delete-success.png"
|
||||
],
|
||||
"scope": {
|
||||
"admin": "appointment calendar and inbound appointment consumer",
|
||||
"client": "no page or code change; merchant inbound API positive and negative isolation verified",
|
||||
"remainingSample": "QA-UI-20261009-2CUSTOMERS"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1 @@
|
||||
{"repo": "html/admin-web", "number": 18, "url": "http://8.163.30.128:10082/html/admin-web/pulls/18", "head": "73c29f5264e46f648713c58b469a8c7bd335cd9c", "branch": "release/appointment-gray-20261009"}
|
||||
@@ -0,0 +1 @@
|
||||
f9dd30b8b9c6b756c4502e4efc33df4ddb59e566
|
||||
@@ -0,0 +1 @@
|
||||
{"repo": "java-project/baiyue-firstleg", "number": 25, "url": "http://8.163.30.128:10082/java-project/baiyue-firstleg/pulls/25", "head": "7fd7699f6e83e77e487aceff914d160cf42376a0", "branch": "release/appointment-gray-20261009"}
|
||||
@@ -0,0 +1 @@
|
||||
f60f44793f289f7612b561f1c470e093f67f4e32
|
||||
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"name": "permissions",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 358,
|
||||
"elapsedMs": 115
|
||||
},
|
||||
{
|
||||
"name": "arrivals",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 10,
|
||||
"elapsedMs": 297
|
||||
},
|
||||
{
|
||||
"name": "appointments",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 0,
|
||||
"elapsedMs": 287
|
||||
},
|
||||
{
|
||||
"name": "inboundCandidates",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 10,
|
||||
"elapsedMs": 221
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[{"job": "baiyue-firstleg-prod-lastleg-service", "queue": "https://jenkins.buggoglobal.com/queue/item/7294/", "number": 13, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "baiyue-firstleg-prod-firstleg", "queue": "https://jenkins.buggoglobal.com/queue/item/7295/", "number": 14, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "admin-web-prod", "queue": "https://jenkins.buggoglobal.com/queue/item/7300/", "number": 26, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}]
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"scope": [
|
||||
"admin-web",
|
||||
"firstleg",
|
||||
"lastleg-service"
|
||||
],
|
||||
"databaseAuthorized": true,
|
||||
"schemaPreflight": {
|
||||
"appointmentRows": 0,
|
||||
"itemRows": 0,
|
||||
"permissionConflicts": 0,
|
||||
"adminRoleCount": 1,
|
||||
"affectedAdminSubjects": 1
|
||||
},
|
||||
"targets": {
|
||||
"backend": "7fd7699f6",
|
||||
"admin": "73c29f5"
|
||||
},
|
||||
"sourceEquivalence": {
|
||||
"backendMergeTreeMatchesTestedSource": true,
|
||||
"adminMergeTreeMatchesTestedSource": true
|
||||
},
|
||||
"sqlExecuted": true,
|
||||
"deployed": true,
|
||||
"pending": null,
|
||||
"releaseBranchesDeleted": [
|
||||
"backend",
|
||||
"admin"
|
||||
],
|
||||
"browserVerified": true,
|
||||
"grayWriteCrudRetested": false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
-- REVIEW ONLY: gray appointment schema and platform administrator permissions.
|
||||
-- Execute only with explicit gray SQL authorization, after verifying both appointment tables remain empty.
|
||||
-- No DROP TABLE, DELETE, TRUNCATE, or business-data backfill.
|
||||
-- DDL auto-commits; stop immediately on any error. Do not use mysql --force.
|
||||
USE baiyue_lastleg;
|
||||
ALTER TABLE ll_inbound_fba_appointment
|
||||
DROP INDEX uk_ll_fba_appointment_no,
|
||||
DROP INDEX idx_ll_fba_appointment_list,
|
||||
DROP COLUMN customer_id,
|
||||
ADD COLUMN appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空' AFTER appointment_at,
|
||||
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '并发版本' AFTER appointment_end_at,
|
||||
ADD UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||
ADD KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code);
|
||||
ALTER TABLE ll_inbound_fba_appointment_item
|
||||
ADD COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID' AFTER appointment_id,
|
||||
ADD KEY idx_ll_fba_appointment_item_cargo (line_id, appointment_id);
|
||||
USE baiyue_iam;
|
||||
START TRANSACTION;
|
||||
INSERT INTO iam_permission
|
||||
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||
VALUES
|
||||
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
|
||||
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0);
|
||||
INSERT INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r CROSS JOIN iam_permission p
|
||||
WHERE r.id=4000001 AND r.tenant_id=0 AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
|
||||
AND p.id IN (5107280,5107281);
|
||||
INSERT INTO iam_security_subject_version (auth_user_id,permission_version,updated_at)
|
||||
SELECT DISTINCT u.auth_user_id,1,NOW(3)
|
||||
FROM iam_membership_role mr
|
||||
JOIN iam_membership m ON m.id=mr.membership_id AND m.tenant_id=mr.tenant_id
|
||||
JOIN iam_user u ON u.id=m.user_id
|
||||
WHERE mr.tenant_id=0 AND mr.role_id=4000001 AND m.deleted=0 AND u.deleted=0
|
||||
ON DUPLICATE KEY UPDATE permission_version=permission_version+1,updated_at=NOW(3);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
ll_inbound_fba_appointment 13
|
||||
ll_inbound_fba_appointment_item 9
|
||||
platform:appointment:list
|
||||
platform:appointment:delete
|
||||
2
|
||||
@@ -5,19 +5,24 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chongchong/shared": "link:./shared",
|
||||
"@lucide/vue": "^1.27.0",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.40",
|
||||
"vue-echarts": "^8.3.2",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -17,6 +17,12 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.18.1
|
||||
version: 1.18.1(debug@4.4.3)
|
||||
dayjs:
|
||||
specifier: ^1.11.21
|
||||
version: 1.11.21
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
element-plus:
|
||||
specifier: ^2.14.3
|
||||
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -29,6 +35,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.40
|
||||
version: 3.5.40(typescript@5.9.3)
|
||||
vue-echarts:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3))
|
||||
vue-router:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -638,6 +647,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
editorconfig@1.0.7:
|
||||
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1147,6 +1159,9 @@ packages:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -1322,6 +1337,12 @@ packages:
|
||||
vue-component-type-helpers@3.3.8:
|
||||
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
|
||||
|
||||
vue-echarts@8.3.2:
|
||||
resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==}
|
||||
peerDependencies:
|
||||
echarts: ^6.0.0
|
||||
vue: ^3.3.0
|
||||
|
||||
vue-router@5.2.0:
|
||||
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
|
||||
peerDependencies:
|
||||
@@ -1403,6 +1424,9 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/css-color@5.1.11':
|
||||
@@ -1957,6 +1981,11 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
editorconfig@1.0.7:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
@@ -2433,6 +2462,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
@@ -2560,6 +2591,11 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.8: {}
|
||||
|
||||
vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)):
|
||||
dependencies:
|
||||
echarts: 6.1.0
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0
|
||||
@@ -2655,3 +2691,7 @@ snapshots:
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
set +x
|
||||
: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
|
||||
: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}"
|
||||
: "${TARGET_SSH_KEY:?}"
|
||||
: "${TARGET_SSH_USER:?}"
|
||||
: "${TARGET_SSH_HOST:?}"
|
||||
: "${TARGET_SSH_KNOWN_HOSTS:?}"
|
||||
: "${BUILD_REGISTRY_USER:?}"
|
||||
: "${BUILD_REGISTRY_PASSWORD:?}"
|
||||
: "${REGISTRY_USER:?}"
|
||||
: "${REGISTRY_PASSWORD:?}"
|
||||
case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac
|
||||
# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters.
|
||||
printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$'
|
||||
printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$'
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
|
||||
IMAGE_TAG=$(git rev-parse --short=12 HEAD)
|
||||
registry=${PRODUCTION_IMAGE_REPOSITORY%%/*}
|
||||
image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG"
|
||||
TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
||||
remote_dir=/data/firstleg/deploy/gray-admin-web
|
||||
run_ssh() {
|
||||
ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@"
|
||||
}
|
||||
run_scp() {
|
||||
scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@"
|
||||
}
|
||||
export DOCKER_CONFIG
|
||||
DOCKER_CONFIG=$(mktemp -d)
|
||||
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
|
||||
docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
|
||||
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
|
||||
--build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" .
|
||||
docker push "$image"
|
||||
run_ssh install -d -m 750 "$remote_dir"
|
||||
run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml"
|
||||
# The remote env file contains only image coordinates and public web origin, never credentials.
|
||||
printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \
|
||||
"$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'"
|
||||
# Registry credentials exist only in an isolated temporary Docker config, removed after pull.
|
||||
printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$'
|
||||
run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh"
|
||||
printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image"
|
||||
run_ssh sh -s -- "$remote_dir" <<'REMOTE'
|
||||
set -eu
|
||||
cd "$1"
|
||||
docker network inspect firstleg-production-network >/dev/null
|
||||
docker compose --env-file release.env -f compose.yml config --quiet
|
||||
docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120
|
||||
docker compose --env-file release.env -f compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
REMOTE
|
||||
@@ -0,0 +1,37 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
return index >= 0 ? process.argv[index + 1] : ''
|
||||
}
|
||||
|
||||
function normalizeOrigin(value, name) {
|
||||
if (!value) throw new Error(`缺少 ${name}`)
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/') {
|
||||
throw new Error(`${name} 必须是无账号、无路径的 HTTP(S) origin`)
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
const environment = option('--environment')
|
||||
if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test')
|
||||
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway')
|
||||
const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
|
||||
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD')
|
||||
|
||||
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
ADMIN_PHASE4_LIVE: '1',
|
||||
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
|
||||
ADMIN_ACCEPTANCE_PASSWORD: password,
|
||||
ADMIN_TEST_GATEWAY: gateway,
|
||||
VITE_API_BASE_URL: `${gateway}/api`,
|
||||
},
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
process.exit(result.status ?? 1)
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('test runner requires an explicit test environment and unified gateway', () => {
|
||||
assert.match(runner, /--environment/)
|
||||
assert.match(runner, /environment !== 'test'/)
|
||||
assert.match(runner, /--gateway/)
|
||||
assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/)
|
||||
assert.match(runner, /shell: process\.platform === 'win32'/)
|
||||
assert.match(runner, /if \(result\.error\) throw result\.error/)
|
||||
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
|
||||
})
|
||||
|
||||
test('live suite uses production auth clients and domain adapters', () => {
|
||||
assert.match(suite, /useAuthStore/)
|
||||
assert.match(suite, /refreshAuthenticatedSession/)
|
||||
assert.match(suite, /createQuote/)
|
||||
assert.match(suite, /createUsTruckOrder/)
|
||||
assert.match(suite, /createLocalLooseboardOrder/)
|
||||
assert.match(suite, /listLastMileOrders/)
|
||||
assert.match(suite, /listDispatchCandidates/)
|
||||
assert.match(suite, /listCarrierScheduleQueries/)
|
||||
})
|
||||
|
||||
test('live suite owns marker resources and redacts credentials from trace reports', () => {
|
||||
assert.match(suite, /ADMIN-FRONT-P4-/)
|
||||
assert.match(suite, /password\|token\|authorization/i)
|
||||
assert.match(suite, /createdResources/)
|
||||
assert.match(suite, /coverageGaps/)
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { refreshAuthenticatedSession } from '@/shared/api/http'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
|
||||
import {
|
||||
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
||||
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getQuote, getVehicle, listExchangeRates,
|
||||
listQuoteWarehouseOptions, listQuotes, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
||||
} from '@/modules/overseas-master-data/api/overseasMasterData'
|
||||
import {
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder,
|
||||
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
|
||||
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateUsTruckOrder,
|
||||
} from '@/modules/overseas-business/api/platformOverseasBusiness'
|
||||
import { listLastMileOrders, getLastMileOrder } from '@/modules/overseas-warehouse/api/lastMileOrders'
|
||||
import { listInboundOrders } from '@/modules/overseas-warehouse/api/inboundOrders'
|
||||
import { getInboundPlan } from '@/modules/overseas-warehouse/api/inboundPlans'
|
||||
import { listDispatchCandidates, listDispatchDrafts, listDispatches } from '@/modules/overseas-warehouse/api/dispatches'
|
||||
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '@/modules/overseas-warehouse/api/inventory'
|
||||
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '@/modules/overseas-warehouse/api/pallets'
|
||||
import { listRelabelOrders } from '@/modules/overseas-warehouse/api/relabelOrders'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '@/modules/port-schedules/api/platformCarrierScheduleQueries'
|
||||
import OverseasQuotesView from '@/modules/overseas-master-data/pages/OverseasQuotesView.vue'
|
||||
import PurchaseOrdersView from '@/modules/overseas-business/pages/PurchaseOrdersView.vue'
|
||||
import TransportOrdersView from '@/modules/overseas-business/pages/TransportOrdersView.vue'
|
||||
import LastMileOrdersView from '@/modules/overseas-warehouse/pages/LastMileOrdersView.vue'
|
||||
import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersView.vue'
|
||||
import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue'
|
||||
import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue'
|
||||
import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue'
|
||||
import OutboundsView from '@/modules/overseas-warehouse/pages/OutboundsView.vue'
|
||||
import DeliveryOrdersView from '@/modules/overseas-warehouse/pages/DeliveryOrdersView.vue'
|
||||
import ExceptionOrdersView from '@/modules/overseas-warehouse/pages/ExceptionOrdersView.vue'
|
||||
import CustomerVisitsView from '@/modules/overseas-warehouse/pages/CustomerVisitsView.vue'
|
||||
import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/InventoryTransactionsView.vue'
|
||||
import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
|
||||
type Region = 'unified'
|
||||
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
|
||||
interface CoverageGap { area: string; reason: string }
|
||||
|
||||
const enabled = process.env.ADMIN_PHASE4_LIVE === '1'
|
||||
const marker = `ADMIN-FRONT-P4-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`
|
||||
const traces: TraceStep[] = []
|
||||
const coverageGaps: CoverageGap[] = []
|
||||
const createdResources: Record<string, unknown> = {}
|
||||
const wrappers: VueWrapper[] = []
|
||||
|
||||
function redact(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redact)
|
||||
if (!value || typeof value !== 'object') return value
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, /password|token|authorization/i.test(key) ? '[REDACTED]' : redact(item)]))
|
||||
}
|
||||
function parsed(value: unknown) {
|
||||
if (typeof value !== 'string') return value
|
||||
try { return JSON.parse(value) } catch { return value }
|
||||
}
|
||||
function envelope(value: unknown) {
|
||||
const body = parsed(value) as { code?: unknown; requestId?: unknown; traceId?: unknown } | null
|
||||
return body && typeof body === 'object' ? body : null
|
||||
}
|
||||
function installTracingAdapter(client: AxiosInstance, region: Region) {
|
||||
const nodeAdapter = axios.getAdapter('http')
|
||||
client.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
|
||||
try {
|
||||
const response = await nodeAdapter(config)
|
||||
const body = envelope(response.data)
|
||||
traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: response.status, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) })
|
||||
return response
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const body = envelope(error.response?.data)
|
||||
traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: error.response?.status || 0, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
function gap(area: string, reason: string) { coverageGaps.push({ area, reason }) }
|
||||
function numericId(value: unknown) { return /^[1-9]\d*$/.test(String(value ?? '')) }
|
||||
function expectPage(value: { records: unknown[]; page: number; size: number; total: number; pages: number }) {
|
||||
expect(Array.isArray(value.records)).toBe(true); expect(value.page).toBeGreaterThanOrEqual(1); expect(value.size).toBeGreaterThan(0); expect(value.total).toBeGreaterThanOrEqual(value.records.length); expect(value.pages).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
async function expectConflict(action: () => Promise<unknown>) {
|
||||
await expect(action()).rejects.toSatisfy((error: unknown) => {
|
||||
if (error instanceof ApiResponseError) return error.code.endsWith('409')
|
||||
if (!axios.isAxiosError(error)) return false
|
||||
const response = parsed(error.response?.data) as { code?: unknown } | undefined
|
||||
return error.response?.status === 409 || String(response?.code || '').endsWith('409')
|
||||
})
|
||||
}
|
||||
function futureDate(days: number) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) }
|
||||
|
||||
async function masterDataLifecycle() {
|
||||
const options = await listQuoteWarehouseOptions()
|
||||
const quotePage = await listQuotes({ page: 1, size: 20 })
|
||||
expectPage(quotePage)
|
||||
if (options.length) {
|
||||
const quote = await createQuote({ name: `${marker} 报价`, warehouseId: options[0].warehouseId, providerName: `${marker} Provider`, currency: 'USD', effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', versionCode: marker.slice(-32), warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: marker }], transitPrices: [], remark: marker })
|
||||
const id = quote.summary.id
|
||||
createdResources.quote = id
|
||||
let detail = await getQuote(id)
|
||||
const stale = detail.summary.recordVersion
|
||||
detail = await updateQuote(id, { recordVersion: stale, name: `${marker} 报价-编辑`, providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: `${marker} edited` })
|
||||
await expectConflict(() => updateQuote(id, { recordVersion: stale, name: 'STALE', providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: marker }))
|
||||
detail = await changeQuoteStatus(id, 'ACTIVE', detail.summary.recordVersion)
|
||||
expect(detail.summary.status).toBe('ACTIVE')
|
||||
expect((await exportQuotes({ keyword: marker })).size).toBeGreaterThan(0)
|
||||
detail = await changeQuoteStatus(id, 'INACTIVE', detail.summary.recordVersion)
|
||||
expect(detail.summary.status).toBe('INACTIVE')
|
||||
} else gap('海外报价', 'warehouse-options 为空,未猜测仓库 ID,跳过报价写生命周期')
|
||||
|
||||
let exchangeMonth = ''
|
||||
for (let index = 0; index < 24 && !exchangeMonth; index += 1) {
|
||||
const month = `${2097 + Math.floor(index / 12)}-${String(index % 12 + 1).padStart(2, '0')}`
|
||||
const page = await listExchangeRates({ page: 1, size: 20, monthFrom: month, monthTo: month })
|
||||
if (!page.total) exchangeMonth = month
|
||||
}
|
||||
if (!exchangeMonth) throw new Error('安全汇率月份池已占满')
|
||||
let rate = await createExchangeRate({ month: exchangeMonth, usdToCnyAverage: 7.123456, remark: marker })
|
||||
createdResources.exchangeRate = exchangeMonth
|
||||
const staleRate = rate.recordVersion
|
||||
rate = await updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.234567, remark: `${marker} edited` })
|
||||
await expectConflict(() => updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.9, remark: 'STALE' }))
|
||||
rate = await getExchangeRate(exchangeMonth)
|
||||
rate = await changeExchangeRateStatus(exchangeMonth, 'INACTIVE', rate.recordVersion, `${marker} cleanup`)
|
||||
expect(rate.status).toBe('INACTIVE')
|
||||
expect((await exportExchangeRates({ monthFrom: exchangeMonth, monthTo: exchangeMonth })).size).toBeGreaterThan(0)
|
||||
|
||||
let vehicle = await createVehicle({ plateNumber: `QA-${marker.slice(-18)}`, model: 'Phase4 Front QA', fuelConsumptionPer100Km: 18.5, mileageKm: 100, driverOrDispatchCompany: marker, phone: '+1-213-555-0188', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: marker })
|
||||
createdResources.vehicle = vehicle.id
|
||||
const staleVehicle = vehicle.recordVersion
|
||||
vehicle = await updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'Phase4 Front QA Edited', fuelConsumptionPer100Km: 18.75, mileageKm: 101, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: `${marker} edited` })
|
||||
await expectConflict(() => updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'STALE', fuelConsumptionPer100Km: 19, mileageKm: 102, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: 'STALE' }))
|
||||
vehicle = await getVehicle(vehicle.id)
|
||||
vehicle = await changeVehicleStatus(vehicle.id, 'INACTIVE', vehicle.recordVersion)
|
||||
expect(vehicle.status).toBe('INACTIVE')
|
||||
expect((await exportVehicles({ keyword: vehicle.plateNumber })).size).toBeGreaterThan(0)
|
||||
expectPage(await listVehicles({ page: 1, size: 20, keyword: marker }))
|
||||
}
|
||||
|
||||
async function cleanupTransport(path: string, customerId: string, version: number) {
|
||||
await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
|
||||
}
|
||||
async function overseasBusinessLifecycle() {
|
||||
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
|
||||
const active = customers.find(item => numericId(item.id))
|
||||
if (!active) { gap('卡车/散板', 'IAM 无 ACTIVE 客户'); return }
|
||||
const customerId = String(active.id)
|
||||
expect(Array.isArray(await listOverseasPurchaseCustomerOptions())).toBe(true)
|
||||
expect(Array.isArray(await listOverseasPurchaseWarehouseOptions())).toBe(true)
|
||||
expectPage(await listOverseasPurchases({ page: 1, size: 20 }))
|
||||
const common = { origin: `${marker}-ORIGIN`, originAddress: 'Long Beach', destination: `${marker}-DEST`, destinationAddress: 'Ontario', containerType: '40HQ', packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, currency: 'USD', contactName: '四期前端验收', contactInfo: '13800000000', requestedDate: futureDate(7), remark: marker }
|
||||
let truck = await createUsTruckOrder(customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, idempotencyKey: `${marker}-truck` })
|
||||
createdResources.truck = truck.orderNo
|
||||
try {
|
||||
truck = await getUsTruckOrder(truck.orderNo, customerId)
|
||||
const stale = truck.version
|
||||
truck = await updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: `${marker}-DEST-EDIT`, version: stale })
|
||||
await expectConflict(() => updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: 'STALE', version: stale }))
|
||||
} finally {
|
||||
truck = await getUsTruckOrder(truck.orderNo, customerId)
|
||||
if (truck.status === 'PENDING_REVIEW') await cleanupTransport(`/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(truck.orderNo)}`, customerId, truck.version)
|
||||
}
|
||||
expectPage(await listUsTruckOrders({ page: 1, size: 20, customerId }))
|
||||
|
||||
let loose = await createLocalLooseboardOrder(customerId, { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', ...common, idempotencyKey: `${marker}-looseboard` })
|
||||
createdResources.looseboard = loose.orderNo
|
||||
try {
|
||||
loose = await getLocalLooseboardOrder(loose.orderNo, customerId)
|
||||
const stale = loose.version
|
||||
loose = await updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: `${marker}-DEST-EDIT`, version: stale })
|
||||
await expectConflict(() => updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: 'STALE', version: stale }))
|
||||
} finally {
|
||||
loose = await getLocalLooseboardOrder(loose.orderNo, customerId)
|
||||
if (loose.status === 'PENDING_ACCEPTANCE') await cleanupTransport(`/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(loose.orderNo)}`, customerId, loose.version)
|
||||
}
|
||||
expectPage(await listLocalLooseboardOrders({ page: 1, size: 20, customerId }))
|
||||
}
|
||||
|
||||
async function overseasWarehouseCoverage() {
|
||||
const lastMile = await listLastMileOrders({ page: 1, size: 20 }); expectPage(lastMile)
|
||||
if (lastMile.records[0]) await getLastMileOrder(String(lastMile.records[0].id))
|
||||
const inbound = await listInboundOrders({ page: 1, size: 20 }); expectPage(inbound)
|
||||
if (inbound.records[0]) await getInboundPlan(inbound.records[0].planNo, inbound.records[0].customerId)
|
||||
expectPage(await listDispatches({ page: 1, size: 20 }))
|
||||
expect(Array.isArray(await listDispatchCandidates({}))).toBe(true)
|
||||
expect(Array.isArray(await listDispatchDrafts())).toBe(true)
|
||||
const inventoryQuery = { page: 1, size: 20 }
|
||||
const balances = await listInventoryBalances(inventoryQuery); expectPage(balances)
|
||||
await getInventorySummary(inventoryQuery)
|
||||
await listInventoryLedger({ page: 1, size: 20 })
|
||||
if (balances.records[0]) await listInventoryPositions(String(balances.records[0].id), 1, 20)
|
||||
expectPage(await listRelabelOrders({ page: 1, size: 20 }))
|
||||
const warehouses = (await listCustomerWarehouses()).records.filter(item => item.status === 'ACTIVE')
|
||||
if (!warehouses.length) { gap('公共托盘', '实际海外仓为空,跳过前端托盘写生命周期'); return }
|
||||
const warehouse = warehouses[0]
|
||||
const payload = { palletCode: `QA-${marker.slice(-20)}`, palletType: 'STANDARD', fbaWarehouseCode: 'ONT8', capacityCbm: 2.5, unitPrice: 10, status: 'IDLE', remark: marker }
|
||||
const palletId = await createPallet(warehouse.id, payload)
|
||||
createdResources.pallet = palletId
|
||||
try {
|
||||
const pallet = await getPallet(warehouse.id, palletId)
|
||||
await updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 2.75, remark: `${marker} edited`, version: pallet.version })
|
||||
await expectConflict(() => updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 3, remark: 'STALE', version: pallet.version }))
|
||||
expectPage(await listPallets(warehouse.id, { page: 1, size: 20, keyword: payload.palletCode }))
|
||||
}
|
||||
finally {
|
||||
const current = await getPallet(warehouse.id, palletId)
|
||||
if (current.status !== 'SCRAPPED') await updatePallet(warehouse.id, palletId, { ...payload, status: 'SCRAPPED', remark: `${marker} cleanup`, version: current.version })
|
||||
}
|
||||
gap('FBA预约/智能预排/换标新增', '没有本轮 marker 入仓计划和托盘候选;为避免操作历史数据,本轮只自动覆盖列表、详情、候选和草稿查询')
|
||||
}
|
||||
|
||||
async function carrierCoverage() {
|
||||
const page = await listCarrierScheduleQueries({ page: 1, size: 20 })
|
||||
expectPage(page)
|
||||
if (page.records[0]) await getCarrierScheduleQuery(page.records[0].query.id)
|
||||
else gap('船节点人工补录', '环境无真实查询任务;未创建或修改历史任务')
|
||||
}
|
||||
|
||||
async function waitForTrace(method: string, path: string, start: number) {
|
||||
const deadline = Date.now() + 20_000
|
||||
while (Date.now() < deadline) {
|
||||
if (traces.slice(start).some(item => item.method === method && item.path.startsWith(path))) return
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
throw new Error(`页面未发出 ${method} ${path}`)
|
||||
}
|
||||
async function mountPage(component: Parameters<typeof mount>[0], method: string, path: string, props?: Record<string, unknown>) {
|
||||
const start = traces.length
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '<div />' }) }] })
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [ElementPlus, router, setActivePinia(createPinia())] } })
|
||||
wrappers.push(wrapper)
|
||||
await waitForTrace(method, path, start)
|
||||
await nextTick()
|
||||
expect(wrapper.text()).not.toContain('服务暂时不可用')
|
||||
return wrapper
|
||||
}
|
||||
async function exerciseListSearch(
|
||||
wrapper: VueWrapper,
|
||||
path: string,
|
||||
model: Record<string, unknown>,
|
||||
expectedParams: Record<string, unknown>,
|
||||
) {
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
expect(toolbar.exists()).toBe(true)
|
||||
toolbar.vm.$emit('update:modelValue', { ...model })
|
||||
await nextTick()
|
||||
let start = traces.length
|
||||
toolbar.vm.$emit('search', { ...model })
|
||||
await waitForTrace('GET', path, start)
|
||||
const searchRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
|
||||
expect(searchRequests).toHaveLength(1)
|
||||
expect(searchRequests[0]?.httpStatus).toBe(200)
|
||||
expect(searchRequests[0]?.params).toMatchObject(expectedParams)
|
||||
expect(wrapper.text()).not.toContain('加载失败')
|
||||
|
||||
start = traces.length
|
||||
;(toolbar.vm as unknown as { reset(): void }).reset()
|
||||
await waitForTrace('GET', path, start)
|
||||
const resetRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
|
||||
expect(resetRequests).toHaveLength(1)
|
||||
expect(resetRequests[0]?.httpStatus).toBe(200)
|
||||
const resetParams = resetRequests[0]?.params as Record<string, unknown> | undefined
|
||||
for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true)
|
||||
}
|
||||
async function pageRequestCoverage() {
|
||||
const quotes = await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
|
||||
await exerciseListSearch(quotes, '/platform/v1/last-leg/master-data/overseas-quotes',
|
||||
{ keyword: `${marker}-NO-MATCH`, warehouseId: '', providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' },
|
||||
{ keyword: `${marker}-NO-MATCH`, providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' })
|
||||
|
||||
const purchases = await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
|
||||
await exerciseListSearch(purchases, '/platform/v1/last-leg/overseas-procurements',
|
||||
{ orderNo: `${marker}-NO-MATCH`, customerId: '', purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` },
|
||||
{ orderNo: `${marker}-NO-MATCH`, purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` })
|
||||
|
||||
const trucks = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
|
||||
await exerciseListSearch(trucks, '/platform/v1/last-leg/us-truck-orders',
|
||||
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
|
||||
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
|
||||
|
||||
const looseboards = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
|
||||
await exerciseListSearch(looseboards, '/platform/v1/last-leg/local-looseboard-orders',
|
||||
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
|
||||
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
|
||||
|
||||
const lastMile = await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
|
||||
await exerciseListSearch(lastMile, '/platform/v1/last-leg/orders',
|
||||
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2098-01-01', '2098-01-02'] },
|
||||
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, currentStatus: 'DELIVERED', abnormal: true, detained: false, createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
|
||||
let start = traces.length
|
||||
await lastMile.findAll('.el-radio-button').find(item => item.text() === '待分配')!.trigger('click')
|
||||
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
|
||||
start = traces.length
|
||||
await lastMile.findAll('.el-radio-button').find(item => item.text().startsWith('柜子'))!.trigger('click')
|
||||
await waitForTrace('GET', '/platform/v1/last-leg/orders/container-summaries', start)
|
||||
start = traces.length
|
||||
await lastMile.findAll('.el-radio-button').find(item => item.text() === '全部')!.trigger('click')
|
||||
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
|
||||
|
||||
const inbounds = await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
|
||||
await exerciseListSearch(inbounds, '/platform/v1/last-leg/inbound-plans',
|
||||
{ orderNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouse: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', inboundDate: ['2098-01-01', '2098-01-02'] },
|
||||
{ planNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouseCode: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
|
||||
|
||||
const inventory = await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
|
||||
await exerciseListSearch(inventory, '/platform/v1/last-leg/inventory/balances',
|
||||
{ warehouseId: '', owner: `${marker}-OWNER`, sku: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, location: `${marker}-LOCATION`, status: 'AVAILABLE' },
|
||||
{ customerName: `${marker}-OWNER`, skuCode: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, warehouseLocationCode: `${marker}-LOCATION`, inventoryStatus: 'AVAILABLE' })
|
||||
|
||||
const dispatches = await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
|
||||
await exerciseListSearch(dispatches, '/platform/v1/last-leg/dispatches',
|
||||
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` },
|
||||
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` })
|
||||
|
||||
const relabels = await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
|
||||
await exerciseListSearch(relabels, '/platform/v1/last-leg/relabel-applications',
|
||||
{ relabelNo: `${marker}-RELABEL`, sku: `${marker}-SKU`, status: 'CANCELLED' },
|
||||
{ relabelNo: `${marker}-RELABEL`, skuCode: `${marker}-SKU`, status: 'CANCELLED' })
|
||||
|
||||
const outbounds = await mountPage(OutboundsView, 'GET', '/platform/v1/last-leg/outbound-orders')
|
||||
await exerciseListSearch(outbounds, '/platform/v1/last-leg/outbound-orders',
|
||||
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' },
|
||||
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' })
|
||||
|
||||
const deliveries = await mountPage(DeliveryOrdersView, 'GET', '/platform/v1/last-leg/delivery-orders')
|
||||
await exerciseListSearch(deliveries, '/platform/v1/last-leg/delivery-orders',
|
||||
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, warehouseId: '', status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedAt: ['2098-01-01', '2098-01-02'] },
|
||||
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedFrom: '2098-01-01T00:00:00', plannedTo: '2098-01-02T23:59:59' })
|
||||
|
||||
const exceptions = await mountPage(ExceptionOrdersView, 'GET', '/platform/v1/last-leg/exception-orders')
|
||||
await exerciseListSearch(exceptions, '/platform/v1/last-leg/exception-orders',
|
||||
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' },
|
||||
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' })
|
||||
|
||||
const visits = await mountPage(CustomerVisitsView, 'GET', '/platform/v1/last-leg/customer-visits')
|
||||
await exerciseListSearch(visits, '/platform/v1/last-leg/customer-visits',
|
||||
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedAt: ['2098-01-01', '2098-01-02'] },
|
||||
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedFrom: '2098-01-01T00:00:00', completedTo: '2098-01-02T23:59:59' })
|
||||
|
||||
const ledger = await mountPage(InventoryTransactionsView, 'GET', '/platform/v1/last-leg/inventory/ledger')
|
||||
await exerciseListSearch(ledger, '/platform/v1/last-leg/inventory/ledger',
|
||||
{ occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` },
|
||||
{ customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' })
|
||||
|
||||
const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets')
|
||||
await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets',
|
||||
{ warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' },
|
||||
{ keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' })
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => {
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
|
||||
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
|
||||
installTracingAdapter(http, 'unified')
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(pinia)
|
||||
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
|
||||
await refreshAuthenticatedSession()
|
||||
await listVehicles({ page: 1, size: 1 })
|
||||
expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true)
|
||||
expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true)
|
||||
})
|
||||
it('runs marker-owned adapter lifecycles and real page requests', async () => {
|
||||
await masterDataLifecycle()
|
||||
await overseasBusinessLifecycle()
|
||||
await overseasWarehouseCoverage()
|
||||
await carrierCoverage()
|
||||
await pageRequestCoverage()
|
||||
expect(traces.length).toBeGreaterThan(20)
|
||||
})
|
||||
afterAll(() => {
|
||||
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
|
||||
const reportDir = resolve(process.cwd(), 'reports')
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
|
||||
const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
|
||||
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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+/)
|
||||
})
|
||||
@@ -42,7 +42,8 @@ describe('domestic outbound list responsive contracts', () => {
|
||||
const listLayout = source('src/shared/components/ListPageLayout.vue')
|
||||
|
||||
expect(queryToolbar).toContain('@media (max-width: 720px)')
|
||||
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; }')
|
||||
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
|
||||
expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
|
||||
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
|
||||
expect(dataTable).toContain('@media (max-width: 720px)')
|
||||
expect(dataTable).toContain('flex-direction: column')
|
||||
|
||||
@@ -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) {
|
||||
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
|
||||
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
|
||||
|
||||
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
|
||||
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
|
||||
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
|
||||
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
|
||||
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||
@@ -19,10 +21,12 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
|
||||
]))
|
||||
|
||||
const stubs = {
|
||||
'el-menu': {
|
||||
'el-menu': defineComponent({
|
||||
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
|
||||
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)"><slot /></nav>',
|
||||
},
|
||||
emits: ['select'],
|
||||
methods: { updateActiveIndex() {} },
|
||||
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)" @click="$emit(\'select\', \'/warehouses\')"><slot /></nav>',
|
||||
}),
|
||||
'el-sub-menu': {
|
||||
props: ['index'],
|
||||
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
||||
@@ -34,10 +38,20 @@ const stubs = {
|
||||
}
|
||||
|
||||
describe('AdminSidebarMenu', () => {
|
||||
function routerPlugin() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<p />' } },
|
||||
{ path: '/warehouses', component: { template: '<p />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the visible phase-one modules and forwards the active route', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
|
||||
global: { stubs },
|
||||
global: { stubs, plugins: [routerPlugin()] },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
||||
@@ -46,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')
|
||||
@@ -62,7 +79,7 @@ describe('AdminSidebarMenu', () => {
|
||||
it('forwards desktop collapse state to Element Plus', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: visibleNavigation, activePath: '/', collapsed: true },
|
||||
global: { stubs },
|
||||
global: { stubs, plugins: [routerPlugin()] },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||
@@ -72,7 +89,7 @@ describe('AdminSidebarMenu', () => {
|
||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/', collapsed: false },
|
||||
global: { stubs },
|
||||
global: { stubs, plugins: [routerPlugin()] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('菜单权限')
|
||||
@@ -100,7 +117,7 @@ describe('AdminSidebarMenu', () => {
|
||||
}]
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/tracking', collapsed: false },
|
||||
global: { stubs },
|
||||
global: { stubs, plugins: [routerPlugin()] },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
||||
@@ -110,7 +127,7 @@ describe('AdminSidebarMenu', () => {
|
||||
it('recreates the menu when asynchronously loaded groups change', async () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
|
||||
global: { stubs },
|
||||
global: { stubs, plugins: [routerPlugin()] },
|
||||
})
|
||||
const initialMenu = wrapper.get('nav').element
|
||||
|
||||
@@ -119,4 +136,17 @@ describe('AdminSidebarMenu', () => {
|
||||
expect(wrapper.get('nav').element).not.toBe(initialMenu)
|
||||
expect(wrapper.text()).toContain('系统管理')
|
||||
})
|
||||
|
||||
it('navigates explicitly when a leaf menu item is selected', async () => {
|
||||
const router = routerPlugin()
|
||||
await router.push('/')
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: visibleNavigation, activePath: '/', collapsed: false },
|
||||
global: { stubs, plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.get('nav').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter, type NavigationFailure } from 'vue-router'
|
||||
import type { MenuInstance } from 'element-plus'
|
||||
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
||||
|
||||
@@ -8,6 +10,8 @@ const props = defineProps<{
|
||||
activePath: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
const router = useRouter()
|
||||
const menuRef = ref<MenuInstance>()
|
||||
|
||||
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||
return nodes.flatMap((node) => node.children?.length
|
||||
@@ -18,16 +22,36 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||
const groupKeys = computed(() => getGroupKeys(props.items))
|
||||
const defaultOpeneds: string[] = []
|
||||
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
|
||||
|
||||
async function restoreActivePath() {
|
||||
await nextTick()
|
||||
menuRef.value?.updateActiveIndex(props.activePath)
|
||||
}
|
||||
|
||||
async function navigate(path: string) {
|
||||
if (!path.startsWith('/') || path === props.activePath) return
|
||||
let failure: NavigationFailure | void
|
||||
try {
|
||||
failure = await router.push(path)
|
||||
} catch {
|
||||
await restoreActivePath()
|
||||
return
|
||||
}
|
||||
if (failure) await restoreActivePath()
|
||||
}
|
||||
|
||||
watch(() => props.activePath, restoreActivePath)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
:key="menuInstanceKey"
|
||||
class="side-nav-menu"
|
||||
:default-active="activePath"
|
||||
:collapse="collapsed"
|
||||
:default-openeds="defaultOpeneds"
|
||||
router
|
||||
@select="navigate"
|
||||
>
|
||||
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
|
||||
</el-menu>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('AppLayout', () => {
|
||||
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('消息管理')
|
||||
expect(wrapper.text()).toContain('模板管理')
|
||||
expect(wrapper.text()).not.toContain('我的消息')
|
||||
expect(wrapper.text()).toContain('我的消息')
|
||||
const notificationTrigger = wrapper.get('.notification-trigger')
|
||||
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -37,7 +38,8 @@ const menuExpanded = computed(() => (
|
||||
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
||||
))
|
||||
const canOpenNotificationManagement = computed(() => (
|
||||
access.can('platform:notification:task:list')
|
||||
access.can('platform:notification:view')
|
||||
|| access.can('platform:notification:task:list')
|
||||
|| access.can('platform:notification:template:list')
|
||||
))
|
||||
|
||||
@@ -56,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
|
||||
@@ -75,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 {
|
||||
@@ -177,6 +180,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
<Bell :size="18" /><span>消息中心</span>
|
||||
</button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
@@ -193,7 +197,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="renderedRoute.meta.keepAlive !== false"
|
||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
|
||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
|
||||
|
||||
it('registers message-center routes with the frozen IAM permissions', () => {
|
||||
const expected = [
|
||||
['notification-inbox', '/notifications', 'platform:notification:view'],
|
||||
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
||||
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
||||
]
|
||||
|
||||
@@ -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,10 @@ 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'
|
||||
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
|
||||
import { selfOperatedRoutes } from '@/modules/self-operated/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -74,6 +79,10 @@ const router = createRouter({
|
||||
...masterDataRoutes,
|
||||
...downloadRoutes,
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
...appointmentCalendarRoutes,
|
||||
...selfOperatedRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
@@ -99,7 +108,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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
@@ -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',
|
||||
@@ -89,13 +87,19 @@ describe('admin auth session', () => {
|
||||
mocks.refreshHandler = null
|
||||
})
|
||||
|
||||
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
||||
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('accepts the unified access and refresh token response', async () => {
|
||||
mocks.post.mockResolvedValue(unifiedLoginResponse())
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const auth = useAuthStore()
|
||||
|
||||
await auth.login('platform_admin', 'password')
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
|
||||
loginId: 'platform_admin',
|
||||
password: 'password',
|
||||
})
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
expect(auth.refreshToken).toBe('refresh-domestic')
|
||||
})
|
||||
@@ -110,8 +114,6 @@ describe('admin auth session', () => {
|
||||
expiresIn: '7200',
|
||||
refreshToken: 'refresh-serialized',
|
||||
refreshExpiresIn: '604800',
|
||||
overseasAccessToken: null,
|
||||
overseasExpiresIn: '0',
|
||||
},
|
||||
})
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
@@ -178,29 +180,43 @@ describe('admin auth session', () => {
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
|
||||
it('reuses a valid persisted session without blocking reload on refresh', async () => {
|
||||
localStorage.setItem('cc-admin-token', token())
|
||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||
const refreshedToken = token({
|
||||
token_version: 2,
|
||||
})
|
||||
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
|
||||
'platform:warehouse:list',
|
||||
'platform:firstleg:inbound:list',
|
||||
])
|
||||
const auth = useAuthStore()
|
||||
|
||||
await expect(auth.restoreSession()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
|
||||
expect([...auth.permissionCodes]).toEqual([
|
||||
'platform:warehouse:list',
|
||||
'platform:firstleg:inbound:list',
|
||||
])
|
||||
expect(localStorage.getItem('cc-admin-permission-codes'))
|
||||
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
||||
expect(mocks.post).not.toHaveBeenCalled()
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
|
||||
expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]')
|
||||
})
|
||||
|
||||
it('reuses a session rotated by another tab without refreshing it again', async () => {
|
||||
const previousToken = token({ token_version: 1 })
|
||||
const rotatedToken = token({ token_version: 2 })
|
||||
localStorage.setItem('cc-admin-token', previousToken)
|
||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
|
||||
localStorage.setItem('cc-admin-token', rotatedToken)
|
||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
|
||||
return callback()
|
||||
}),
|
||||
},
|
||||
})
|
||||
const auth = useAuthStore()
|
||||
|
||||
await expect(auth.refreshSession()).resolves.toBeUndefined()
|
||||
|
||||
expect(mocks.post).not.toHaveBeenCalled()
|
||||
expect(auth.token).toBe(rotatedToken)
|
||||
expect(auth.refreshToken).toBe('refresh-rotated')
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
|
||||
})
|
||||
|
||||
it('does not let an in-flight refresh restore a session after logout', async () => {
|
||||
@@ -225,4 +241,157 @@ describe('admin auth session', () => {
|
||||
expect(auth.refreshToken).toBe('')
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an old refresh after another tab has switched to a different account', async () => {
|
||||
localStorage.setItem('cc-admin-token', token())
|
||||
localStorage.setItem('cc-admin-refresh-token', 'admin-refresh')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||
let finishRefresh!: (value: ReturnType<typeof loginResponse>) => void
|
||||
mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve }))
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const listeners = vi.spyOn(window, 'addEventListener')
|
||||
const auth = useAuthStore()
|
||||
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
|
||||
listeners.mockRestore()
|
||||
const refreshing = auth.refreshSession()
|
||||
await Promise.resolve()
|
||||
const salesToken = token({ user_id: '2000002', username: 'sales' })
|
||||
localStorage.setItem('cc-admin-token', salesToken)
|
||||
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
|
||||
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
|
||||
expect(auth.user?.account).toBe('sales')
|
||||
finishRefresh(loginResponse(token(), 'admin-rotated'))
|
||||
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
|
||||
expect(auth.user?.account).toBe('sales')
|
||||
expect(auth.token).toBe(salesToken)
|
||||
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
|
||||
})
|
||||
|
||||
it('discards late permission results after another tab switches accounts without clearing the new session', async () => {
|
||||
mocks.post.mockResolvedValue(loginResponse())
|
||||
let finishPermissions!: (value: string[]) => void
|
||||
let permissionsStarted!: () => void
|
||||
const started = new Promise<void>(resolve => { permissionsStarted = resolve })
|
||||
mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => {
|
||||
permissionsStarted()
|
||||
return new Promise(resolve => { finishPermissions = resolve })
|
||||
})
|
||||
const listeners = vi.spyOn(window, 'addEventListener')
|
||||
const auth = useAuthStore()
|
||||
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
|
||||
listeners.mockRestore()
|
||||
const login = auth.login('platform_admin', 'password')
|
||||
await started
|
||||
const salesToken = token({ user_id: '2000002', username: 'sales' })
|
||||
localStorage.setItem('cc-admin-token', salesToken)
|
||||
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
|
||||
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
|
||||
finishPermissions(['platform:warehouse:list'])
|
||||
await expect(login).rejects.toThrow('登录会话已被后续操作替代')
|
||||
expect(auth.user?.account).toBe('sales')
|
||||
expect(auth.token).toBe(salesToken)
|
||||
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
|
||||
expect(mocks.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,10 @@ 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()
|
||||
// A different account in another tab supersedes this tab's pending login/refresh responses.
|
||||
if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) {
|
||||
sessionGeneration += 1
|
||||
}
|
||||
token.value = storedToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
user.value = session.user
|
||||
@@ -166,9 +162,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) {
|
||||
@@ -176,7 +192,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
try {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/login',
|
||||
{ username: account, password },
|
||||
{ loginId: account, password },
|
||||
)
|
||||
const result = unwrapApiResponse<LoginResponse>(response)
|
||||
await applySession(result, expectedGeneration)
|
||||
@@ -203,14 +219,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
observedRefreshToken,
|
||||
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
||||
reuseCurrentSession: async () => {
|
||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: storedRefreshToken },
|
||||
)
|
||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
||||
applyStoredSession()
|
||||
ensureCurrentGeneration(expectedGeneration)
|
||||
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
@@ -229,15 +240,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()
|
||||
@@ -263,7 +270,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
|
||||
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
|
||||
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
|
||||
currentRefreshToken = 'refresh-new'
|
||||
return callback()
|
||||
}),
|
||||
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
|
||||
it('performs the refresh when the token has not changed', async () => {
|
||||
const refresh = vi.fn(async () => 'access-new')
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
|
||||
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
|
||||
})
|
||||
|
||||
await expect(coordinateCrossTabRefresh({
|
||||
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
|
||||
|
||||
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
||||
})
|
||||
|
||||
it('aborts a refresh lock that cannot be acquired', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(coordinateCrossTabRefresh({
|
||||
lockName: 'admin-refresh',
|
||||
lockTimeoutMs: 5,
|
||||
observedRefreshToken: 'refresh-old',
|
||||
currentRefreshToken: () => 'refresh-old',
|
||||
reuseCurrentSession: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
lockTimeoutMs?: number
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => Promise<T>
|
||||
refresh: (refreshToken: string) => Promise<T>
|
||||
}
|
||||
|
||||
export class CrossTabRefreshTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('登录状态刷新等待超时,请重新登录')
|
||||
this.name = 'CrossTabRefreshTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||
const run = async () => {
|
||||
const currentRefreshToken = options.currentRefreshToken()
|
||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return navigator.locks.request(options.lockName, run)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
||||
try {
|
||||
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
|
||||
function token(payload: string) {
|
||||
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||
}
|
||||
|
||||
describe('JWT claim decoding', () => {
|
||||
it.each(['2110664514823360515', '"2110664514823360515"'])('preserves the exact account ID encoded as %s', userId => {
|
||||
const claims = decodeJwtClaims(token(`{"user_id":${userId},"app_code":"PLATFORM","exp":1792060000,"iat":1792058200}`))
|
||||
expect(claims.user_id).toBe('2110664514823360515')
|
||||
expect(claims.exp).toBe(1792060000)
|
||||
expect(claims).toHaveProperty('iat', 1792058200)
|
||||
})
|
||||
|
||||
it('retains safe numeric IDs and ordinary claims', () => {
|
||||
expect(decodeJwtClaims(token('{"user_id":123,"username":"operator","roles":["ADMIN"],"exp":1}')))
|
||||
.toEqual({ user_id: 123, username: 'operator', roles: ['ADMIN'], exp: 1 })
|
||||
})
|
||||
|
||||
it.each(['', 'header..signature'])('rejects a missing payload: %s', value => {
|
||||
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 格式无效')
|
||||
})
|
||||
|
||||
it.each(['header.%.signature', token('{"user_id":2110664514823360515,')])('rejects malformed encoding or JSON: %s', value => {
|
||||
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 无法解析')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseJsonWithSafeIntegers } from '@chongchong/shared'
|
||||
|
||||
export interface PlatformJwtClaims {
|
||||
user_id?: string | number
|
||||
username?: string
|
||||
@@ -19,7 +21,7 @@ export function decodeJwtClaims(token: string): PlatformJwtClaims {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
return parseJsonWithSafeIntegers(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
} catch {
|
||||
throw new Error('登录响应中的 accessToken 无法解析')
|
||||
}
|
||||
|
||||
@@ -8,71 +8,49 @@ 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([
|
||||
'首页数据看板',
|
||||
'客户管理',
|
||||
'国内仓',
|
||||
'海外派送',
|
||||
'供应商中心',
|
||||
'货柜管理',
|
||||
'海外业务',
|
||||
'海外仓',
|
||||
'财务管理',
|
||||
'销售管理',
|
||||
'通用',
|
||||
'系统管理',
|
||||
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
|
||||
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
|
||||
]))
|
||||
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
|
||||
})
|
||||
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
|
||||
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
|
||||
'/dashboard', '/self-operated/overview', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||
])
|
||||
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 === 'service-center')).toBeUndefined()
|
||||
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([
|
||||
'产品信息',
|
||||
'费用项目',
|
||||
'汇率管理',
|
||||
'仓库管理',
|
||||
'海外仓报价',
|
||||
'船期价格表',
|
||||
'车辆列表',
|
||||
'下载中心',
|
||||
const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync']))
|
||||
expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard'])
|
||||
})
|
||||
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: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
|
||||
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', () => {
|
||||
@@ -88,7 +66,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')
|
||||
@@ -112,7 +91,7 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/system/permissions')
|
||||
expect(new Set(paths).size).toBe(paths.length)
|
||||
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set())
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list']))
|
||||
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -141,6 +120,10 @@ describe('adminNavigation', () => {
|
||||
'platform:overseas-warehouse:last-mile-order:list',
|
||||
'platform:overseas-warehouse:inbound-order:list',
|
||||
'platform:overseas-warehouse:dispatch:list',
|
||||
'platform:overseas-warehouse:outbound:list',
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:relabel-order:list',
|
||||
'platform:overseas-warehouse:pallet:list',
|
||||
'platform:overseas-warehouse:inventory:list',
|
||||
@@ -157,6 +140,9 @@ describe('adminNavigation', () => {
|
||||
'/overseas-warehouse/last-mile-orders',
|
||||
'/overseas-warehouse/inbound-orders',
|
||||
'/overseas-warehouse/dispatches',
|
||||
'/overseas-warehouse/outbounds',
|
||||
'/overseas-warehouse/delivery-orders',
|
||||
'/overseas-warehouse/exception-orders',
|
||||
'/overseas-warehouse/relabel-orders',
|
||||
'/overseas-warehouse/pallets',
|
||||
'/overseas-warehouse/inventory',
|
||||
@@ -169,7 +155,7 @@ describe('adminNavigation', () => {
|
||||
]))
|
||||
expect(paths).not.toContain('/overseas-warehouse/cartons')
|
||||
expect(paths).not.toContain('/overseas-warehouse/products')
|
||||
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
|
||||
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
|
||||
})
|
||||
|
||||
it('filters protected pages without mutating the source tree', () => {
|
||||
@@ -196,12 +182,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')
|
||||
@@ -221,26 +205,30 @@ 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('shows the restored supplier entry under general only with its list permission', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
||||
const missing = filterAdminNavigation(adminNavigation, new Set())
|
||||
|
||||
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
|
||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
|
||||
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
||||
expect(getNavigationLeaves(filterAdminNavigation(adminNavigation, new Set(['platform:supplier:create', 'platform:supplier:edit']))).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',
|
||||
'/container-loading',
|
||||
'/loading-plans',
|
||||
'/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,14 +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: '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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -83,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: '货柜管理',
|
||||
@@ -100,9 +94,49 @@ 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: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
||||
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
|
||||
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
|
||||
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
||||
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseas-inventory',
|
||||
title: '海外尾程',
|
||||
icon: PrototypeOverseasWarehouseIcon,
|
||||
children: [
|
||||
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
||||
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
|
||||
{ key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
|
||||
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
||||
{ key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
|
||||
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
|
||||
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
|
||||
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
||||
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
|
||||
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'appointment-management',
|
||||
title: '预约管理',
|
||||
icon: CalendarDays,
|
||||
children: [
|
||||
{ key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment: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-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -113,19 +147,16 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||
{ key: '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: 'overseas-inventory',
|
||||
title: '海外仓',
|
||||
icon: PrototypeOverseasWarehouseIcon,
|
||||
key: 'customer-service',
|
||||
title: '客户服务',
|
||||
icon: PrototypeCustomerServiceIcon,
|
||||
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-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: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -151,19 +182,33 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'self-operated',
|
||||
title: '海外仓',
|
||||
icon: PrototypeSalesIcon,
|
||||
children: [
|
||||
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
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' },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -176,6 +221,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' }),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createNavigationRecoveryHandler,
|
||||
installAssetRecoveryListeners,
|
||||
isStaleRouteAssetError,
|
||||
recoverNavigationError,
|
||||
reloadNavigationTarget,
|
||||
type NavigationRecoveryDependencies,
|
||||
} from './navigationRecovery'
|
||||
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
||||
return {
|
||||
storage: memoryStorage(),
|
||||
now: () => 1_000,
|
||||
hasUnsavedWork: () => false,
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
discardUnsavedWork: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
...overrides,
|
||||
} satisfies NavigationRecoveryDependencies
|
||||
}
|
||||
|
||||
describe('navigationRecovery', () => {
|
||||
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
|
||||
'forces a full reload for an async component failure on the current document: %s', (target) => {
|
||||
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
|
||||
const history = { state: { position: 2 }, replaceState: vi.fn() }
|
||||
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
|
||||
expect(location.reload).toHaveBeenCalledOnce()
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('loads the intended new route through a document navigation', () => {
|
||||
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
|
||||
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
|
||||
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
|
||||
expect(location.reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Failed to fetch dynamically imported module: /assets/page.js',
|
||||
'Importing a module script failed',
|
||||
'ChunkLoadError: Loading chunk 42 failed',
|
||||
'Loading CSS chunk page failed',
|
||||
'Unable to preload CSS for /assets/page-123.css',
|
||||
])('recognizes stale deployed route assets: %s', (message) => {
|
||||
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
||||
})
|
||||
|
||||
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
|
||||
const deps = dependencies()
|
||||
await expect(recoverNavigationError(
|
||||
new TypeError('Failed to fetch dynamically imported module'),
|
||||
'/overseas-warehouse/inventory',
|
||||
deps,
|
||||
)).resolves.toBe(true)
|
||||
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
|
||||
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
|
||||
expect(deps.showError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks before discarding dirty work and stays put when the user cancels', async () => {
|
||||
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
|
||||
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
|
||||
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
|
||||
const storage = memoryStorage()
|
||||
const deps = dependencies({ storage })
|
||||
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
||||
const reloaded = dependencies({ storage })
|
||||
await recoverNavigationError(error, '/system/redirect', reloaded)
|
||||
expect(reloaded.navigate).not.toHaveBeenCalled()
|
||||
reloaded.now = () => 31_000
|
||||
await recoverNavigationError(error, '/system/users', reloaded)
|
||||
expect(reloaded.navigate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports ordinary router failures without reloading the application', async () => {
|
||||
const deps = dependencies()
|
||||
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
||||
})
|
||||
|
||||
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
|
||||
const storage = memoryStorage()
|
||||
storage[method] = () => { throw new Error('Storage unavailable') }
|
||||
const deps = dependencies({ storage })
|
||||
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
|
||||
})
|
||||
|
||||
it('preserves the target query and hash after the user confirms discarding work', async () => {
|
||||
const deps = dependencies({ hasUnsavedWork: () => true })
|
||||
const target = '/orders?status=NEW&page=3#details'
|
||||
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
|
||||
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||
expect(deps.navigate).toHaveBeenCalledWith(target)
|
||||
})
|
||||
|
||||
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
|
||||
let finishConfirmation!: (value: boolean) => void
|
||||
const deps = dependencies({
|
||||
hasUnsavedWork: () => true,
|
||||
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
|
||||
})
|
||||
const recover = createNavigationRecoveryHandler(deps)
|
||||
const cachedError = new Error('Importing a module script failed')
|
||||
const first = recover(cachedError, '/orders')
|
||||
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
|
||||
await Promise.resolve()
|
||||
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||
finishConfirmation(false)
|
||||
await Promise.all([first, duplicate])
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
deps.confirmDiscard = vi.fn(async () => true)
|
||||
await recover(cachedError, '/orders')
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deduplicates the same error reported by Vite and the router', async () => {
|
||||
const deps = dependencies()
|
||||
const recover = createNavigationRecoveryHandler(deps)
|
||||
const error = new Error('Importing a module script failed')
|
||||
await recover(error, '/orders')
|
||||
await recover(error, '/orders')
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
expect(deps.showError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
|
||||
const deps = dependencies()
|
||||
const recover = createNavigationRecoveryHandler(deps)
|
||||
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
|
||||
try {
|
||||
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
|
||||
payload: new Error('Cannot read properties of undefined'),
|
||||
})
|
||||
window.dispatchEvent(ordinary)
|
||||
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
|
||||
reason: new Error('Request failed with status code 500'),
|
||||
})
|
||||
window.dispatchEvent(apiError)
|
||||
await Promise.resolve()
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(apiError.defaultPrevented).toBe(false)
|
||||
|
||||
const error = new Error('Unable to preload CSS for /assets/page.css')
|
||||
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
|
||||
window.dispatchEvent(preload)
|
||||
expect(preload.defaultPrevented).toBe(false)
|
||||
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
|
||||
window.dispatchEvent(rejection)
|
||||
expect(rejection.defaultPrevented).toBe(true)
|
||||
await recover(error, '/orders?page=2')
|
||||
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
|
||||
} finally {
|
||||
removeListeners()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
|
||||
const RECOVERY_WINDOW_MS = 30_000
|
||||
|
||||
const staleAssetErrorPatterns = [
|
||||
/failed to fetch dynamically imported module/i,
|
||||
/error loading dynamically imported module/i,
|
||||
/importing a module script failed/i,
|
||||
/chunkloaderror/i,
|
||||
/loading (css )?chunk [\w-]+ failed/i,
|
||||
/unable to preload css for/i,
|
||||
]
|
||||
|
||||
export interface NavigationRecoveryDependencies {
|
||||
storage: Pick<Storage, 'getItem' | 'setItem'>
|
||||
now: () => number
|
||||
hasUnsavedWork: () => boolean
|
||||
confirmDiscard: () => Promise<boolean>
|
||||
discardUnsavedWork: () => void
|
||||
navigate: (target: string) => void
|
||||
showError: (message: string) => void
|
||||
}
|
||||
|
||||
interface RecoveryAttempt {
|
||||
target: string
|
||||
attemptedAt: number
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return typeof error === 'string' ? error : ''
|
||||
}
|
||||
|
||||
export function isStaleRouteAssetError(error: unknown) {
|
||||
const message = errorMessage(error)
|
||||
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
||||
export async function recoverNavigationError(
|
||||
error: unknown,
|
||||
target: string,
|
||||
dependencies: NavigationRecoveryDependencies,
|
||||
) {
|
||||
if (!isStaleRouteAssetError(error)) {
|
||||
dependencies.showError('页面切换失败,请重试')
|
||||
return false
|
||||
}
|
||||
// Keep this marker across successful routing and full reloads. A redirect to
|
||||
// another route must not bypass the cooldown either.
|
||||
try {
|
||||
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
|
||||
let previous: RecoveryAttempt | null = null
|
||||
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
|
||||
if (previous && Number.isFinite(previous.attemptedAt)
|
||||
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
|
||||
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||
return false
|
||||
}
|
||||
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
|
||||
|
||||
try {
|
||||
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||
target,
|
||||
attemptedAt: dependencies.now(),
|
||||
} satisfies RecoveryAttempt))
|
||||
} catch {
|
||||
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||
return false
|
||||
}
|
||||
dependencies.discardUnsavedWork()
|
||||
dependencies.navigate(target)
|
||||
return true
|
||||
}
|
||||
|
||||
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
|
||||
let handledErrors = new WeakSet<object>()
|
||||
let pending: Promise<boolean> | undefined
|
||||
const guardedDependencies = {
|
||||
...dependencies,
|
||||
confirmDiscard: async () => {
|
||||
const confirmed = await dependencies.confirmDiscard()
|
||||
// Some importers cache the rejected promise/error. Cancelling must not
|
||||
// prevent a later explicit retry from asking again.
|
||||
if (!confirmed) handledErrors = new WeakSet<object>()
|
||||
return confirmed
|
||||
},
|
||||
}
|
||||
return (error: unknown, target: string): Promise<boolean> => {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
|
||||
handledErrors.add(error)
|
||||
}
|
||||
if (pending) return pending
|
||||
// Defer execution so simultaneous router, Vite and Vue errors share one
|
||||
// confirmation/reload, even when there is no unsaved form.
|
||||
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
|
||||
.finally(() => { pending = undefined })
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
|
||||
const current = new URL(browser.location.href)
|
||||
const destination = new URL(target, current)
|
||||
if (destination.pathname === current.pathname && destination.search === current.search) {
|
||||
// Assigning a URL that only changes the fragment does not reload assets.
|
||||
browser.history.replaceState(browser.history.state, '', destination.href)
|
||||
browser.location.reload()
|
||||
} else {
|
||||
browser.location.assign(destination.href)
|
||||
}
|
||||
}
|
||||
|
||||
export function installAssetRecoveryListeners(
|
||||
browser: Window,
|
||||
target: () => string,
|
||||
recover: (error: unknown, target: string) => Promise<boolean>,
|
||||
) {
|
||||
const onPreloadError = (event: Event) => {
|
||||
const error = (event as Event & { payload?: unknown }).payload
|
||||
if (isStaleRouteAssetError(error)) void recover(error, target())
|
||||
// Let Vite reject the import: swallowing it would make Vue Router treat
|
||||
// an undefined module as a successful navigation, including on cancel.
|
||||
}
|
||||
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
if (!isStaleRouteAssetError(event.reason)) return
|
||||
event.preventDefault()
|
||||
void recover(event.reason, target())
|
||||
}
|
||||
browser.addEventListener('vite:preloadError', onPreloadError)
|
||||
browser.addEventListener('unhandledrejection', onUnhandledRejection)
|
||||
return () => {
|
||||
browser.removeEventListener('vite:preloadError', onPreloadError)
|
||||
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +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 root = process.cwd()
|
||||
const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8')
|
||||
const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8')
|
||||
const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8')
|
||||
const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8')
|
||||
|
||||
describe('admin nginx deployment config', () => {
|
||||
it('allows qualification uploads with multipart overhead through the API proxy', () => {
|
||||
expect(config).toContain('client_max_body_size 12m;')
|
||||
})
|
||||
test('all business traffic uses one Gateway without rewriting API paths', () => {
|
||||
assert.match(config, /location \/api\//)
|
||||
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
|
||||
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
|
||||
assert.match(config, /client_max_body_size 55m;/)
|
||||
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
||||
assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
|
||||
})
|
||||
|
||||
it('prevents the application shell from retaining stale chunk references', () => {
|
||||
expect(config).toContain('location = /index.html')
|
||||
expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"')
|
||||
})
|
||||
test('test deployment requires an explicit shared network, Gateway and version', () => {
|
||||
for (const key of ['IMAGE_TAG']) {
|
||||
assert.ok(compose.includes('${' + key + ':?'))
|
||||
}
|
||||
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
|
||||
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
|
||||
assert.match(compose, /external: true/)
|
||||
assert.match(compose, /127\.0\.0\.1:/)
|
||||
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
|
||||
assert.match(jenkins, /'java-builder' : 'java-test'/)
|
||||
assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
|
||||
assert.match(jenkins, /docker network inspect/)
|
||||
assert.match(jenkins, /scripts\/deploy-production\.sh/)
|
||||
assert.match(jenkins, /firstleg-production-ssh/)
|
||||
|
||||
it('serves hashed assets immutably and never falls back missing chunks to HTML', () => {
|
||||
expect(config).toContain('location /assets/')
|
||||
expect(config).toContain('try_files $uri =404;')
|
||||
expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Vue history routes on the application shell without caching them', () => {
|
||||
expect(config).toContain('try_files $uri $uri/ /index.html;')
|
||||
expect(config).toContain('Cache-Control "no-cache"')
|
||||
})
|
||||
test('application shell is revalidated and missing chunks remain 404', () => {
|
||||
assert.match(config, /location = \/index\.html/)
|
||||
assert.match(config, /no-store/)
|
||||
assert.match(config, /max-age=31536000, immutable/)
|
||||
assert.match(config, /try_files \$uri =404;/)
|
||||
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
|
||||
})
|
||||
|
||||
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('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
||||
})
|
||||
|
||||
test('container build excludes local environment values and waits for Nginx health', () => {
|
||||
const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8')
|
||||
assert.match(ignore, /^\.env$/m)
|
||||
assert.match(ignore, /^\.env\.\*$/m)
|
||||
assert.match(compose, /healthcheck:/)
|
||||
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
|
||||
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
|
||||
})
|
||||
|
||||
test('gray production is isolated from TEST and routes to the single Gateway', () => {
|
||||
const production = readFileSync(resolve(root, 'docker-compose.production.yml'), 'utf8')
|
||||
assert.match(production, /name: firstleg-production-network/)
|
||||
assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/)
|
||||
assert.match(production, /127\.0\.0\.1:/)
|
||||
assert.match(production, /healthcheck:/)
|
||||
assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/)
|
||||
assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, createApp } from 'vue'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import { createPinia } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
import App from './App.vue'
|
||||
import router from '@/app/router'
|
||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||
import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
@@ -19,8 +22,6 @@ const auth = useAuthStore(pinia)
|
||||
const unsaved = useUnsavedWorkStore(pinia)
|
||||
const workspace = useWorkspaceTabsStore(pinia)
|
||||
|
||||
await auth.restoreSession()
|
||||
|
||||
registerAuthSessionExpiredHandler(async () => {
|
||||
const currentRoute = router.currentRoute.value
|
||||
if (currentRoute.name === 'login') return
|
||||
@@ -36,6 +37,42 @@ registerAuthSessionExpiredHandler(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
const recoverAssetError = createNavigationRecoveryHandler({
|
||||
storage: {
|
||||
getItem: (key) => window.sessionStorage.getItem(key),
|
||||
setItem: (key, value) => window.sessionStorage.setItem(key, value),
|
||||
},
|
||||
now: Date.now,
|
||||
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||
confirmDiscard: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||
'重新加载页面?',
|
||||
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
discardUnsavedWork: () => unsaved.clear(),
|
||||
navigate: (target) => reloadNavigationTarget(window, target),
|
||||
showError: (message) => ElMessage.error(message),
|
||||
})
|
||||
let pendingRoute: string | undefined
|
||||
const recoveryTarget = () => pendingRoute
|
||||
?? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
router.beforeEach((to) => { pendingRoute = to.fullPath })
|
||||
router.onError((error, to) => {
|
||||
void recoverAssetError(error, to.fullPath)
|
||||
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||
})
|
||||
router.afterEach((to) => {
|
||||
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||
})
|
||||
installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError)
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
||||
event.preventDefault()
|
||||
@@ -43,5 +80,10 @@ window.addEventListener('beforeunload', (event) => {
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.config.errorHandler = (error) => {
|
||||
if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget())
|
||||
else console.error(error)
|
||||
}
|
||||
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
|
||||
await auth.restoreSession()
|
||||
app.use(pinia).use(router).mount('#app')
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Admin 预约日历模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
|
||||
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
|
||||
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
|
||||
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
|
||||
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
|
||||
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
|
||||
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
|
||||
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
|
||||
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
|
||||
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
|
||||
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
|
||||
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
|
||||
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
|
||||
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,183 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
import { getAppointmentBatch, repartitionAppointments } from './batchApi'
|
||||
import { getWorkItems } from './workApi'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import { workItem } from './work.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
|
||||
let guard: LeaveGuard
|
||||
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
|
||||
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
|
||||
} } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
|
||||
vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] })
|
||||
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 })
|
||||
})
|
||||
it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
|
||||
await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||
expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }])
|
||||
expect(request.batches.map(row => row.plannedDate)).toEqual([null, null])
|
||||
expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30])
|
||||
expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20])
|
||||
expect(request).not.toHaveProperty('appointmentAt')
|
||||
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => {
|
||||
const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1')
|
||||
context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }]
|
||||
context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
|
||||
await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||
expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }])
|
||||
expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]])
|
||||
expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
|
||||
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount()
|
||||
})
|
||||
it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => {
|
||||
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1)
|
||||
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20')
|
||||
expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount()
|
||||
})
|
||||
it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => {
|
||||
const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS'
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A')
|
||||
expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格')
|
||||
await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('loads a candidate context before merging and preserves both original versions', async () => {
|
||||
const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6
|
||||
vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext())
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => {
|
||||
const context = batchContext()
|
||||
const source = context.appointment.items[0]!
|
||||
context.appointment.items = [source]
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3']
|
||||
context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20']
|
||||
context.sources = []; context.allowedActions = []
|
||||
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment
|
||||
forty.appointmentStatus = 'SUCCESS'
|
||||
forty.items = [
|
||||
{ ...source, packageCount: 40 },
|
||||
{ ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 },
|
||||
{ ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 },
|
||||
{ ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 },
|
||||
]
|
||||
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment
|
||||
twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }]
|
||||
context.currentBatches = [forty, twenty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.findAll('article')).toHaveLength(2)
|
||||
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
|
||||
expect(destinations.text()).toContain('已取消,分配保留')
|
||||
expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE')
|
||||
expect(destinations.text()).not.toContain('FBA_APPOINTMENT:')
|
||||
await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click')
|
||||
expect(wrapper.emitted('appointment')).toEqual([['AP-40']])
|
||||
await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
|
||||
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4')
|
||||
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => {
|
||||
const context = batchContext()
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
|
||||
context.sources = []; context.allowedActions = []
|
||||
const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment
|
||||
unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }]
|
||||
const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment
|
||||
empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }]
|
||||
context.currentBatches = [unknown, empty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.findAll('article')).toHaveLength(1)
|
||||
expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱')
|
||||
expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount()
|
||||
})
|
||||
it('does not fall back to historical replacement links when current destinations are empty', async () => {
|
||||
const context = batchContext()
|
||||
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
|
||||
context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT']
|
||||
context.sources = []; context.allowedActions = []; context.currentBatches = []
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.findAll('article')).toHaveLength(0)
|
||||
expect(destinations.text()).toContain('原快照不代表当前待办')
|
||||
expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount()
|
||||
})
|
||||
it('keeps every 40/20 destination after the entire customer has left the original current items', async () => {
|
||||
const context = batchContext(), originalItem = context.appointment.items[0]!
|
||||
context.appointment.items = [context.appointment.items[1]!]
|
||||
context.sources = [context.sources[1]!]
|
||||
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment
|
||||
forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }]
|
||||
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||
twenty.items = [{ ...originalItem, packageCount: 20 }]
|
||||
context.currentBatches = [context.appointment, forty, twenty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
|
||||
expect(destinations.text()).not.toContain('IN-UNRELATED')
|
||||
expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1)
|
||||
expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => {
|
||||
const original = batchContext()
|
||||
original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = []
|
||||
const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2')
|
||||
current.appointment.plannedDate = '2026-10-22'
|
||||
original.currentBatches = [current.appointment]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current)
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('AP-CURRENT')
|
||||
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
|
||||
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
|
||||
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
|
||||
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getAppointmentBatch, repartitionAppointments, allocation, allocationKey, mergeBatchSources, splitBatch, type FbaAppointmentBatchContext, type RepartitionFbaAppointmentsRequest, type RepartitionFbaAppointmentsResponse } from './batchApi'
|
||||
import { getWorkItems, type WorkPage } from './workApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import type { CargoVerificationSource } from './cargoVerificationApi'
|
||||
const props = defineProps<{ batchKey: string }>()
|
||||
const emit = defineEmits<{ updated: []; appointment: [appointmentNo: string] }>()
|
||||
const access = useAccessPreviewStore(), activeKey = ref(props.batchKey), context = ref<FbaAppointmentBatchContext | null>(null), other = ref<FbaAppointmentBatchContext | null>(null)
|
||||
const mode = ref<'split' | 'merge'>('split'), loading = ref(false), saving = ref(false), ready = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<RepartitionFbaAppointmentsRequest | null>(null), result = ref<RepartitionFbaAppointmentsResponse | null>(null)
|
||||
const form = reactive({ firstDate: '', nextDate: '', reason: '', remark: '', moved: {} as Record<string, number | string> })
|
||||
const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerificationSource | null>(null)
|
||||
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
|
||||
let candidateRequest = 0
|
||||
let initializedKey: string | null = null
|
||||
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length)
|
||||
const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey))
|
||||
const currentDestinations = computed(() => {
|
||||
if (!context.value) return []
|
||||
const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey])
|
||||
const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey))
|
||||
return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
|
||||
})
|
||||
function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record<string, string>)[status] || '预约状态待核实' }
|
||||
async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) }
|
||||
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; ready.value = false; error.value = null
|
||||
try {
|
||||
context.value = await getAppointmentBatch(activeKey.value)
|
||||
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
|
||||
if (!preserve || initializedKey !== activeKey.value) {
|
||||
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
|
||||
baseline.value = JSON.stringify(form)
|
||||
initializedKey = activeKey.value
|
||||
}
|
||||
ready.value = true
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function searchCandidates(page = 1) {
|
||||
const request = ++candidateRequest; candidatePage.value = page; candidates.value = null; candidateError.value = null; candidateLoading.value = true
|
||||
try { const rows = await getWorkItems({ view: 'ALL', types: ['DELIVERY'], keyword: keyword.value.trim() || undefined, page, size: 8 }); if (request === candidateRequest) candidates.value = rows }
|
||||
catch (cause) { if (request === candidateRequest) candidateError.value = getApiErrorDetails(cause, '可合并工作事项读取失败') }
|
||||
finally { if (request === candidateRequest) candidateLoading.value = false }
|
||||
}
|
||||
async function chooseOther(key: string) {
|
||||
if (!context.value || pending.value || saving.value) return
|
||||
candidateLoading.value = true; candidateError.value = null
|
||||
try {
|
||||
const selected = await getAppointmentBatch(key)
|
||||
if (!selected.allowedActions.includes('MERGE')) throw new Error(selected.blockReasons.join(';') || '该批次当前不允许合并')
|
||||
mergeBatchSources([context.value, selected]); other.value = selected
|
||||
} catch (cause) { candidateError.value = getApiErrorDetails(cause, '批次不能合并') }
|
||||
finally { candidateLoading.value = false }
|
||||
}
|
||||
async function changeMode(next: 'split' | 'merge') {
|
||||
if (!await canLeave()) return
|
||||
mode.value = next; other.value = null; result.value = null; await load()
|
||||
if (next === 'merge') await searchCandidates()
|
||||
}
|
||||
function sourceLabel(source: CargoVerificationSource) {
|
||||
const item = context.value?.appointment.items.find(item => item.customerId === source.customerId && item.sourceKey === source.sourceKey)
|
||||
return item?.inboundCode || '原票业务号未提供'
|
||||
}
|
||||
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
|
||||
async function verified() { verification.value = null; await load(); emit('updated') }
|
||||
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() }
|
||||
async function save() {
|
||||
if (!context.value || !canSubmit.value || saving.value) return
|
||||
if (!pending.value) {
|
||||
try {
|
||||
if (!form.reason.trim()) { ElMessage.warning('请填写分批或合并原因'); return }
|
||||
const contexts = mode.value === 'merge' && other.value ? [context.value, other.value] : [context.value]
|
||||
const allocations = mode.value === 'split' ? splitBatch(context.value, form.moved) : [mergeBatchSources(contexts).map(source => allocation(source, Number(source.packageCount)))]
|
||||
pending.value = { originals: contexts.map(row => ({ appointmentNo: row.appointment.appointmentNo, version: row.appointment.version })), batches: allocations.map((rows, index) => ({ plannedDate: (index === 0 ? form.firstDate : form.nextDate) || null, remark: form.remark.trim() || null, allocations: rows })), reason: form.reason.trim(), idempotencyKey: crypto.randomUUID() }
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '请核对逐票分配数量'); return }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
result.value = await repartitionAppointments(pending.value); pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success(mode.value === 'split' ? '已分为两个待安排批次' : '已合并为一个待安排批次'); emit('updated')
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '调整结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="预约分批合并">
|
||||
<CargoVerificationForm v-if="verification" :key="`${verification.customerId}:${verification.sourceKey}`" :source="verification" @saved="verified" />
|
||||
<template v-else>
|
||||
<h3>{{ context?.appointment.appointmentNo || '预约批次' }} · 分批与合并</h3>
|
||||
<LoadErrorState v-if="error" title="批次资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、来源和核实版本…</p>
|
||||
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
|
||||
<template v-else-if="context">
|
||||
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
|
||||
<template v-if="hasCurrentDestinations">
|
||||
<p v-if="replaced">该批次已被替代,原货物快照仅供追溯。{{ context.appointment.repartitionReason }}</p>
|
||||
<p v-else>本批仍有保留货物,以下同时列出服务端返回的当前后送去向。</p>
|
||||
<section aria-label="当前货物去向">
|
||||
<h4>当前货物去向</h4><p>以下仅展示原批次客户、来源及目的仓对应的当前分配;合并批次可能还含其他货物,进入完整批次后可查看。</p>
|
||||
<article v-for="destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row">
|
||||
<strong>{{ destination.appointment.appointmentNo }}</strong><p>{{ bookingStatus(destination.appointment.appointmentStatus) }} · {{ destination.appointment.plannedDate || '日期未定' }}</p>
|
||||
<div v-for="item in destination.items" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · {{ item.fbaWarehouseCode }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div>
|
||||
<el-button :disabled="loading || !!error || saving || !!pending" @click="openAppointment(destination.appointment.appointmentNo)">查看本批预约</el-button>
|
||||
<el-button v-if="destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)">查看完整批次</el-button>
|
||||
</article>
|
||||
<p v-if="!currentDestinations.length && !loading && !error">当前没有与原货物范围匹配的分配;原快照不代表当前待办。</p>
|
||||
</section>
|
||||
</template>
|
||||
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
|
||||
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
|
||||
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
||||
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
|
||||
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
|
||||
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
|
||||
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
import { getWorkDetail, type WorkAction } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
|
||||
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
|
||||
})
|
||||
async function open() {
|
||||
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
|
||||
} } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
|
||||
const content = creationBody()
|
||||
await content.get('input[type="checkbox"]').setValue(true)
|
||||
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
|
||||
return dialog
|
||||
}
|
||||
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
|
||||
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
|
||||
const wrapper = await open()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
expect(dialog.props('width')).toBe('520px')
|
||||
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
|
||||
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
|
||||
await dialog.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect((dialog.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
|
||||
})
|
||||
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
await dialog.get('input').setValue('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(dialog.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('上次提交结果尚未确认')
|
||||
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
})
|
||||
it('keeps complete long cargo names accessible in the selectable table', async () => {
|
||||
const wrapper = await open()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
|
||||
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
|
||||
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
|
||||
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
|
||||
const wrapper = await open(); const dialog = await create(wrapper)
|
||||
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
|
||||
expect(fields.props('continuous')).toBe(true)
|
||||
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
|
||||
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
|
||||
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(date.props('modelValue')).toBe('2026-10-25')
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
|
||||
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(creationBody().text()).toContain('AP-001')
|
||||
})
|
||||
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
|
||||
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
|
||||
const wrapper = await open(); await create(wrapper)
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
const receipt = creationBody().get('[aria-label="预约保存回执"]')
|
||||
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
|
||||
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
|
||||
})
|
||||
it('does not open or request creation data without its original write permission', async () => {
|
||||
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
|
||||
})
|
||||
it('uses the same real continuous form through the original work detail deep link', async () => {
|
||||
routing.query.workItem = 'work-delivery-1'
|
||||
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
|
||||
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
|
||||
await content.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
|
||||
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
|
||||
expect(content.text()).toContain('本次保存已完成')
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||
|
||||
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
|
||||
const render = (events: ScheduleEvent[] = []) => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-10-19', events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
describe('four-type appointment calendar', () => {
|
||||
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
|
||||
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
|
||||
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
|
||||
})
|
||||
it('filters locally without requesting another range', async () => {
|
||||
const wrapper = render([event('arrival'), event('dispatch')])
|
||||
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
|
||||
expect(wrapper.text()).toContain('当天暂无安排')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
it('offers creation in the header only when authorized', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
|
||||
await wrapper.setProps({ canCreate: true })
|
||||
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([[]])
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('changes the selected date locally and requests one range per mode or period change', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
|
||||
})
|
||||
it('limits previews to three while retaining every event in the daily details', () => {
|
||||
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
|
||||
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
|
||||
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
|
||||
})
|
||||
it('keeps undated records outside date cells and exposes them explicitly', async () => {
|
||||
const wrapper = render([{ ...event('warehouse'), date: null }])
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||
await wrapper.get('.undated-button').trigger('click')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
|
||||
})
|
||||
it('retains business links and exposes editing only with authoritative eligibility', async () => {
|
||||
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
|
||||
const wrapper = render([row])
|
||||
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
|
||||
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('open')).toEqual([[row]])
|
||||
expect(wrapper.emitted('edit')).toEqual([[row]])
|
||||
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
|
||||
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
|
||||
})
|
||||
it('distinguishes loading and partial failure from a complete empty day', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ loading: true })
|
||||
expect(wrapper.text()).toContain('正在读取安排')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.setProps({ loading: false, incomplete: true })
|
||||
expect(wrapper.text()).toContain('部分数据不可用')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||||
|
||||
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
|
||||
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
|
||||
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
|
||||
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
|
||||
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
|
||||
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, ScheduleEvent[]>()
|
||||
for (const event of visibleEvents.value) {
|
||||
const date = event.date || 'undated'
|
||||
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
|
||||
}
|
||||
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
return result
|
||||
})
|
||||
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
|
||||
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
|
||||
function select(date: string) {
|
||||
const before = calendarRange(selectedDate.value, mode.value)
|
||||
selectedDate.value = date; undated.value = false
|
||||
const after = calendarRange(date, mode.value)
|
||||
emit('select', date, mode.value)
|
||||
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||
}
|
||||
function changeMode(next: CalendarMode) {
|
||||
if (mode.value === next) return
|
||||
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||||
<div class="calendar-main">
|
||||
<header class="calendar-toolbar">
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
|
||||
<div class="view-toggle" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||
<strong class="calendar-period">{{ heading }}</strong>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-options">
|
||||
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
|
||||
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input v-model="keyword" type="search" maxlength="128" placeholder="柜号、车次号、仓库" aria-label="搜索当前日历"></label>
|
||||
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
|
||||
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
|
||||
<span class="day-number">{{ Number(date.slice(8)) }}</span>
|
||||
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
|
||||
.calendar-main, .calendar-detail { background: var(--admin-surface); border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
|
||||
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.calendar-label > svg { color: #617ce9; }.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
|
||||
.calendar-search { margin-left: auto; }.calendar-search input { box-sizing: border-box; width: 190px; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: 0 var(--admin-space-3); font: inherit; }
|
||||
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
|
||||
.view-toggle button, .nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
|
||||
.view-toggle button[aria-pressed='true'], .undated-button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 600; }.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
|
||||
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; }.calendar-day:hover { border-color: #bac8ed; }.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
|
||||
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
|
||||
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
|
||||
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: #617ce9; }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
|
||||
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid #edf0f6; padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
|
||||
ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
|
||||
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
|
||||
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
})
|
||||
describe('simplified appointment entry', () => {
|
||||
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(true)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
|
||||
expect(panel.props('canWrite')).toBe(true)
|
||||
vi.mocked(getCalendarSources).mockClear()
|
||||
panel.vm.$emit('created'); await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
|
||||
routing.denied.add(permission)
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('canCreate')).toBe(false)
|
||||
calendar.vm.$emit('create'); await flushPromises()
|
||||
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps unsaved input when the operator declines to leave', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
|
||||
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
|
||||
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
|
||||
expect(wrapper.text()).toContain('计划到仓读取失败')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not request unreadable domains and describes the incomplete scope', async () => {
|
||||
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少排车读取权限')
|
||||
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores stale calendar responses when the date range changes', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
|
||||
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
|
||||
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
|
||||
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('提交结果尚未确认')
|
||||
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
|
||||
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
|
||||
expect(await guard()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
|
||||
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires an explicit reread after a conflict and retains the entered date', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the selected date and view in the source return link', async () => {
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list')
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||
expect(listTripCalendar).toHaveBeenCalledOnce()
|
||||
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', event); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { isAxiosError } from 'axios'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
import { appointmentExternalTarget } from './workNavigation'
|
||||
|
||||
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||||
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||||
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||||
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const creating = ref(false)
|
||||
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||
function initialDate() {
|
||||
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||||
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||||
}
|
||||
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
|
||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||
let generation = 0, controller: AbortController | null = null
|
||||
async function load(next = range.value) {
|
||||
range.value = next
|
||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
let result: CalendarSources | null = null
|
||||
try {
|
||||
result = await getCalendarSources(next, signal)
|
||||
if (request !== generation) return
|
||||
sources.value = result
|
||||
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
|
||||
} catch (cause) { fail('到港与还柜读取失败', cause) }
|
||||
if (request !== generation) return
|
||||
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
|
||||
}
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
|
||||
if (request === generation) warehouses.value = rows
|
||||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||
})(),
|
||||
(async () => {
|
||||
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
|
||||
try {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
if (request === generation) loading.value = false
|
||||
}
|
||||
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
|
||||
async function openSource(event: ScheduleEvent) {
|
||||
if (!event.sourcePath || !await canLeave()) return
|
||||
const context = { date: selectedDate.value, mode: mode.value }
|
||||
await router.replace({ query: context })
|
||||
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||
}
|
||||
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
|
||||
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
|
||||
function edit(event: ScheduleEvent) {
|
||||
if (!event.canEdit || !event.warehouse) return
|
||||
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
|
||||
}
|
||||
async function save() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row || saving.value || conflict.value || !plannedAt.value) return
|
||||
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveWarehouseArrival(pending.id, pending.body, pending.key)
|
||||
pending = null; unknownResult.value = false; editor.value = null
|
||||
ElMessage.success('计划到仓已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||
unknownResult.value = !rejectedCommand(cause)
|
||||
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||
if (!unknownResult.value) pending = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function rereadEditor() {
|
||||
const row = editor.value?.warehouse
|
||||
if (!row) return
|
||||
saving.value = true
|
||||
try {
|
||||
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
|
||||
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
|
||||
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
|
||||
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() {
|
||||
if (saving.value || unknownResult.value) return false
|
||||
for (const guard of guards) if (!await guard()) return false
|
||||
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm' }) }
|
||||
catch { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||||
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
|
||||
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
|
||||
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
|
||||
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
|
||||
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
|
||||
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
|
||||
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
|
||||
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
|
||||
origins.value = []
|
||||
initialAction.value = null
|
||||
}, { immediate: true })
|
||||
async function openWork(key: string, action?: WorkAction) {
|
||||
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
|
||||
}
|
||||
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
|
||||
async function closeBusiness(done: () => void) {
|
||||
if (!await canLeave()) return
|
||||
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
|
||||
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
|
||||
}
|
||||
async function external(path: string) {
|
||||
if (!await canLeave()) return
|
||||
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
|
||||
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
|
||||
onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density appointment-schedule" spacing="compact">
|
||||
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||||
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<div v-if="editor" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label><span>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></span></template><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||||
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||||
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
|
||||
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
|
||||
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
|
||||
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
|
||||
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
|
||||
const access = vi.hoisted(() => ({ canCancel: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({useAccessPreviewStore: () => ({can: () => access.canCancel})}))
|
||||
vi.mock('vue-router', () => ({onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn()}))
|
||||
vi.mock('./api', () => ({deleteAppointment: vi.fn(),listAppointmentCancellations: vi.fn(),listAppointmentCancellationRecords: vi.fn()}))
|
||||
const record = {appointmentNo:'AP-CANCEL',version:1,cancelReason:null,cancelledAt:'2026-10-11T01:00:00',snapshot:{fbaWarehouseCode:'ABE3',appointmentStatus:'CANCELLED',appointmentRemark:'原备注保留',plannedDate:null,plannedAt:null,plannedEndAt:null,confirmationNo:'EXT-1',appointmentAt:'2026-10-15T09:00:00',appointmentEndAt:null,timezone:'America/New_York',selectedCount:1}}
|
||||
function dialog() {
|
||||
return mount(AppointmentCancellationDialog,{props:{appointmentNo:'AP-CANCEL',version:0,selectedCount:1},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose','showConfirm'],template:'<section><slot/><button v-if="showConfirm" data-save @click="$emit(\'confirm\')">取消</button></section>'}}}})
|
||||
}
|
||||
describe('appointment cancellation',()=>{
|
||||
beforeEach(()=>{vi.resetAllMocks();access.canCancel=true})
|
||||
it('requires a reason and retries the same reason and version after an unknown result',async()=>{
|
||||
vi.mocked(deleteAppointment).mockRejectedValueOnce(new Error('lost')).mockResolvedValueOnce(undefined)
|
||||
const wrapper=dialog();await flushPromises()
|
||||
await wrapper.get('[data-save]').trigger('click');expect(deleteAppointment).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea').setValue(' 仓库取消时段 ')
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(deleteAppointment).toHaveBeenCalledWith('AP-CANCEL',0,'仓库取消时段')
|
||||
const done=vi.fn();wrapper.getComponent({name:'AppDialog'}).props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(deleteAppointment).toHaveBeenNthCalledWith(2,'AP-CANCEL',0,'仓库取消时段')
|
||||
expect(wrapper.emitted('cancelled')).toHaveLength(1);wrapper.unmount()
|
||||
})
|
||||
it('does not expose or invoke cancellation without its independent permission',async()=>{
|
||||
access.canCancel=false;const wrapper=dialog();await flushPromises()
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
wrapper.getComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
|
||||
expect(deleteAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('reads history once per page and keeps missing historical reason explicit',async()=>{
|
||||
vi.mocked(listAppointmentCancellations).mockResolvedValue({records:[record],total:11})
|
||||
const wrapper=mount(AppointmentCancellationHistory,{props:{appointmentNo:'AP-CANCEL'},global:{plugins:[ElementPlus]}});await flushPromises()
|
||||
expect(listAppointmentCancellations).toHaveBeenCalledExactlyOnceWith('AP-CANCEL',1,10)
|
||||
expect(wrapper.text()).toContain('历史原因未记录');expect(wrapper.text()).toContain('原备注保留')
|
||||
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2);await flushPromises()
|
||||
expect(listAppointmentCancellations).toHaveBeenLastCalledWith('AP-CANCEL',2,10);wrapper.unmount()
|
||||
})
|
||||
it('discovers persisted cancellations with one server page and opens by business number',async()=>{
|
||||
vi.mocked(listAppointmentCancellationRecords).mockResolvedValue({records:[{appointmentNo:'AP-CANCEL',version:1,cancelReason:'实际原因',cancelledAt:record.cancelledAt,fbaWarehouseCode:'ABE3',selectedCount:1}],total:1})
|
||||
const wrapper=mount(AppointmentCancellationRecords,{global:{plugins:[ElementPlus]}});await flushPromises()
|
||||
expect(listAppointmentCancellationRecords).toHaveBeenCalledExactlyOnceWith(1,10)
|
||||
expect(listAppointmentCancellations).not.toHaveBeenCalled()
|
||||
await wrapper.get('.el-table__body-wrapper .el-table__row').findAll('button').find(b=>b.text()==='查看')!.trigger('click')
|
||||
expect(wrapper.emitted('view')).toEqual([['AP-CANCEL']]);wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
|
||||
vi.mock('@/shared/api/http',()=>({http:{get:vi.fn(),delete:vi.fn()},unwrapApiResponse:(result:{data:unknown})=>result.data}))
|
||||
beforeEach(()=>vi.resetAllMocks())
|
||||
it('sends the required cancellation body without the old reasonless query shape',async()=>{
|
||||
vi.mocked(http.delete).mockResolvedValue({data:undefined})
|
||||
await deleteAppointment('AP-TEST',3,'仓库取消时段')
|
||||
expect(http.delete).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP-TEST',{data:{version:3,cancelReason:'仓库取消时段'}})
|
||||
})
|
||||
it('uses paged cancellation reads and the original business number for detail history',async()=>{
|
||||
vi.mocked(http.get).mockResolvedValue({data:{records:[],total:'12'}})
|
||||
expect((await listAppointmentCancellationRecords(2,10)).total).toBe(12)
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations',{params:{page:2,size:10}})
|
||||
await listAppointmentCancellations('AP/TEST',1,10)
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP%2FTEST/cancellations',{params:{page:1,size:10}})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { deleteAppointment } from './api'
|
||||
const props = defineProps<{ appointmentNo: string; version: number; selectedCount: number }>()
|
||||
const emit = defineEmits<{ close: []; cancelled: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const reason = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{number: string; version: number; reason: string} | null>(null)
|
||||
function canLeave() {
|
||||
if (saving.value || pending.value) { ElMessage.warning('请先核实本次取消结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregister = register?.(canLeave)
|
||||
onBeforeUnmount(() => unregister?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
async function save() {
|
||||
if (!access.can('platform:appointment:delete') || saving.value) return
|
||||
if (!pending.value && !reason.value.trim()) { ElMessage.warning('请填写实际取消原因'); return }
|
||||
pending.value ||= {number: props.appointmentNo, version: props.version, reason: reason.value.trim()}
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
await deleteAppointment(pending.value.number, pending.value.version, pending.value.reason)
|
||||
pending.value = null; emit('cancelled')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '取消结果待核实,请重试本次取消')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog :model-value="true" title="取消预约" width="min(600px, calc(100vw - 48px))" class="admin-density-dialog" confirm-type="danger" :confirm-text="pending ? '重试本次取消' : '确认取消预约'" :show-confirm="access.can('platform:appointment:delete')" :confirm-loading="saving" :before-close="beforeClose" @update:model-value="close" @confirm="save">
|
||||
<p>{{ appointmentNo }} · {{ selectedCount }} 票货物</p>
|
||||
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
|
||||
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
|
||||
<el-form label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, onBeforeUnmount } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listAppointmentCancellations, type AppointmentCancellation } from './api'
|
||||
const props = defineProps<{ appointmentNo: string }>()
|
||||
const page = ref(1), size = ref(10), total = ref(0), rows = ref<AppointmentCancellation[]>([])
|
||||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:170,singleLine:false},
|
||||
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:220,singleLine:false},
|
||||
{key:'snapshot',label:'取消时记录',slotName:'snapshot',minWidth:260,singleLine:false},
|
||||
]
|
||||
async function load() {
|
||||
const current = ++request; loading.value = true; error.value = null
|
||||
try { const result = await listAppointmentCancellations(props.appointmentNo,page.value,size.value); if(current===request) {rows.value=result.records;total.value=result.total} }
|
||||
catch(cause) { if(current===request) error.value=getApiErrorDetails(cause,'取消历史加载失败') }
|
||||
finally { if(current===request) loading.value=false }
|
||||
}
|
||||
onMounted(load)
|
||||
onBeforeUnmount(()=>++request)
|
||||
</script>
|
||||
<template>
|
||||
<LoadErrorState v-if="error" title="取消历史加载失败" :message="error.message" @retry="load" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" row-key="version" @update:page="load" @update:page-size="page=1;load()">
|
||||
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
|
||||
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
|
||||
<template #snapshot="{row}"><details><summary>{{ row.snapshot.fbaWarehouseCode }} · {{ row.snapshot.selectedCount }} 票 · 查看取消时记录</summary><p>原业务备注:{{ row.snapshot.appointmentRemark || '—' }}</p><p>原计划:{{ row.snapshot.plannedAt || row.snapshot.plannedDate || '待定' }}<span v-if="row.snapshot.plannedEndAt"> — {{ row.snapshot.plannedEndAt }}</span></p><p>原确认:{{ row.snapshot.appointmentAt || '未确认' }}<span v-if="row.snapshot.appointmentEndAt"> — {{ row.snapshot.appointmentEndAt }}</span> · {{ row.snapshot.timezone || '时区待核实' }}</p><p>外部确认号:{{ row.snapshot.confirmationNo || '—' }}</p></details></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, onBeforeUnmount } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listAppointmentCancellationRecords, type AppointmentCancellationSummary } from './api'
|
||||
const emit = defineEmits<{ view: [appointmentNo: string] }>()
|
||||
const page=ref(1),size=ref(10),total=ref(0),rows=ref<AppointmentCancellationSummary[]>([])
|
||||
const loading=ref(false),error=ref<ApiErrorDetails|null>(null)
|
||||
let request=0
|
||||
const columns: DataTableColumn[]=[
|
||||
{key:'actions',label:'操作',slotName:'actions',width:90,fixed:'left'},
|
||||
{prop:'appointmentNo',label:'预约单号',minWidth:220,singleLine:false},
|
||||
{prop:'fbaWarehouseCode',label:'目的仓',width:100},
|
||||
{prop:'selectedCount',label:'票数',width:80},
|
||||
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:180,singleLine:false},
|
||||
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:240,singleLine:false},
|
||||
]
|
||||
async function load() {
|
||||
const current=++request;loading.value=true;error.value=null
|
||||
try {const result=await listAppointmentCancellationRecords(page.value,size.value);if(current===request){rows.value=result.records;total.value=result.total}}
|
||||
catch(cause){if(current===request)error.value=getApiErrorDetails(cause,'取消记录加载失败')}
|
||||
finally{if(current===request)loading.value=false}
|
||||
}
|
||||
onMounted(load)
|
||||
onBeforeUnmount(()=>++request)
|
||||
</script>
|
||||
<template>
|
||||
<LoadErrorState v-if="error" title="取消记录加载失败" :message="error.message" @retry="load" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="(row: object) => `${(row as AppointmentCancellationSummary).appointmentNo}:${(row as AppointmentCancellationSummary).version}`" @update:page="load" @update:page-size="page=1;load()">
|
||||
<template #actions="{row}"><el-button @click="emit('view',row.appointmentNo)">查看</el-button></template>
|
||||
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
|
||||
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import { listPlanningCargo } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
function render(attach = false) {
|
||||
return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '<section class="integrated-query-group"><slot name="query-tabs"/></section>' },
|
||||
} } })
|
||||
}
|
||||
describe('appointment planning cargo query', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
|
||||
it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => {
|
||||
const wrapper = render(true); await flushPromises()
|
||||
const tabs = () => wrapper.findAll('[role="tab"]')
|
||||
expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物'])
|
||||
expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true)
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
await tabs()[1]!.trigger('click')
|
||||
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true)
|
||||
await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(tabs()[0]!.element)
|
||||
expect(tabs()[1]!.attributes('tabindex')).toBe('-1')
|
||||
await tabs()[0]!.trigger('keydown', { key: 'End' })
|
||||
await flushPromises()
|
||||
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(tabs()[1]!.element)
|
||||
await tabs()[1]!.trigger('keydown', { key: 'Home' })
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses one server-page request per entry, page change and reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
await wrapper.get('[data-search]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(3)
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
})
|
||||
it('shows a source failure instead of an authoritative empty list', async () => {
|
||||
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||
})
|
||||
it('discards a late previous page and uses the new server total', async () => {
|
||||
let resolveOld!: (value: typeof empty) => void
|
||||
vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render()
|
||||
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
|
||||
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
|
||||
resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
|
||||
expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
|
||||
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const props = defineProps<{ externalSource?: PlanningSource | null }>()
|
||||
const emit = defineEmits<{ created: []; sourceAccepted: [] }>()
|
||||
const arrangement = reactive(createAppointmentForm())
|
||||
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
|
||||
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
|
||||
const following = ref<ResolvedPlanningSource | null>(null)
|
||||
const candidateMode = ref<'firstleg' | 'external'>('firstleg')
|
||||
const externalRevision = ref(0)
|
||||
const followupError = ref<ApiErrorDetails | null>(null)
|
||||
type Selection = Pick<PlanningSource, 'sourceKey' | 'customerId' | 'originWarehouseId' | 'destinationWarehouseCode' | 'inboundCode'> & { bookingNo?: string | null }
|
||||
const selected = ref<Record<string, Selection>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const externalRows = computed(() => selectedRows.value.filter(row => row.sourceKey.startsWith('BOOKING:')))
|
||||
const batchPreview = ref<PlanningBatchPreview | null>(null)
|
||||
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
|
||||
let batchRequest = 0
|
||||
function addSource(source: PlanningSource) {
|
||||
if (!access.can(overseasWarehousePermissions.inboundOrderAppointment) || batchLoading.value || creating.value) return false
|
||||
if (!source.canFollowup) { ElMessage.warning('当前来源尚不能安排预约'); return false }
|
||||
const anchor = selectedRows.value[0]
|
||||
if (anchor && (anchor.originWarehouseId !== source.originWarehouseId || anchor.destinationWarehouseCode !== source.destinationWarehouseCode)) {
|
||||
ElMessage.warning('请先完成或清空当前合批选择;不同起运仓或目的仓不能混入'); return false
|
||||
}
|
||||
if (!selected.value[source.sourceKey] && selectedRows.value.length >= 100) { ElMessage.warning('每批最多选择100票'); return false }
|
||||
selected.value[source.sourceKey] = source; batchError.value = null
|
||||
return true
|
||||
}
|
||||
function acceptExternalSource() {
|
||||
if (props.externalSource && addSource(props.externalSource)) emit('sourceAccepted')
|
||||
}
|
||||
function arrangeFollowing() {
|
||||
if (following.value && !following.value.coverage.appointments.length && addSource(following.value.source)) following.value = null
|
||||
}
|
||||
function selectable(row: PlanningCargo) {
|
||||
const anchor = selectedRows.value[0]
|
||||
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: unknown) {
|
||||
batchError.value = null
|
||||
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
|
||||
else delete selected.value[row.sourceKey]
|
||||
}
|
||||
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
|
||||
async function previewBatch() {
|
||||
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
|
||||
const current = ++batchRequest
|
||||
batchLoading.value = true; batchError.value = null
|
||||
try {
|
||||
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
|
||||
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
|
||||
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
|
||||
finally { if (current === batchRequest) batchLoading.value = false }
|
||||
}
|
||||
async function saveArrangement() {
|
||||
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
|
||||
const error = appointmentFormError(arrangement)
|
||||
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
|
||||
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
|
||||
creating.value = true; createError.value = null
|
||||
try {
|
||||
const result = await createSourceAppointment(pendingCreate.value)
|
||||
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
|
||||
} catch (cause) {
|
||||
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
let followupRequest = 0
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
|
||||
const page = ref(1), size = ref(20), total = ref(0)
|
||||
const rows = ref<PlanningCargo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
let controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
|
||||
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
|
||||
{ prop: 'originWarehouseName', label: '接仓点', minWidth: 140 },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '预报箱数', width: 100 },
|
||||
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
|
||||
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
|
||||
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
|
||||
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
|
||||
]
|
||||
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningCargo({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||
if (request !== current) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (cause) {
|
||||
if (request === current) { error.value = getApiErrorDetails(cause, '已开船货物加载失败'); rows.value = []; total.value = 0 }
|
||||
} finally { if (request === current) loading.value = false }
|
||||
}
|
||||
function source(row: PlanningCargo) {
|
||||
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
|
||||
}
|
||||
async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId' | 'destinationWarehouseCode'>) {
|
||||
const request = ++followupRequest
|
||||
followupError.value = null
|
||||
try {
|
||||
const result = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })
|
||||
if (request === followupRequest) following.value = result
|
||||
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
|
||||
}
|
||||
function canLeave() {
|
||||
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function closeBatch() { if (canLeave()) batchPreview.value = null }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
|
||||
<template #query-tabs><AppointmentCargoTabs v-model="candidateMode" /></template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #notices>
|
||||
<details v-if="selectedRows.length" class="external-selection"><summary>已选 {{ selectedRows.length }} 票(切换货源保留选择)</summary>
|
||||
<div class="external-selection-list"><div v-for="row in selectedRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
|
||||
</details>
|
||||
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false" />
|
||||
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||
</template>
|
||||
</AppointmentExternalCandidates>
|
||||
<DataTable v-show="candidateMode === 'firstleg'" v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<section class="integrated-query-group" aria-label="头程货物类型与筛选">
|
||||
<AppointmentCargoTabs v-if="candidateMode === 'firstleg'" v-model="candidateMode" />
|
||||
<QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||
</section>
|
||||
</template>
|
||||
<template #notices>
|
||||
<div v-if="externalSource" class="cargo-note">
|
||||
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
|
||||
<el-button v-if="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :disabled="batchLoading || creating" @click="acceptExternalSource">加入本次选择</el-button>
|
||||
</div>
|
||||
<details v-if="externalRows.length" class="external-selection">
|
||||
<summary>已选外接货 {{ externalRows.length }} 票(可与同仓头程货合批)</summary>
|
||||
<div class="external-selection-list"><div v-for="row in externalRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
|
||||
</details>
|
||||
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="(value: boolean | string | number) => toggle(row as PlanningCargo, value)" /></template>
|
||||
<template #warehouseEta="{ row }"><span>{{ row.estimatedWarehouseAt?.replace('T', ' ') || '—' }}</span><div v-if="row.estimatedWarehouseAt" class="cargo-note">来源时区未明确</div></template>
|
||||
<template #progress="{ row }">
|
||||
<span v-if="row.detained">暂扣,待处理</span>
|
||||
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
|
||||
<span v-else-if="row.hasActiveAppointment">已有预约</span>
|
||||
<span v-else>{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
|
||||
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cargo-note { margin: var(--admin-space-3) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.external-selection { margin: var(--admin-space-3) 0; }
|
||||
.external-selection-list { max-height: 216px; overflow-y: auto; }
|
||||
.external-selection-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mode = defineModel<'firstleg' | 'external'>({ required: true })
|
||||
const tabs = [{ key: 'firstleg', label: '头程货物' }, { key: 'external', label: '外接货物' }] as const
|
||||
async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
let next: number
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
|
||||
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = tabs.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
const key = tabs[next]!.key
|
||||
mode.value = key
|
||||
await nextTick()
|
||||
document.getElementById(`appointment-cargo-tab-${key}`)?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="预约货源类型">
|
||||
<button v-for="(tab, index) in tabs" :id="`appointment-cargo-tab-${tab.key}`" :key="tab.key" type="button" role="tab"
|
||||
:aria-selected="mode === tab.key" :tabindex="mode === tab.key ? 0 : -1"
|
||||
:class="{ 'is-active': mode === tab.key }"
|
||||
@click="mode = tab.key" @keydown="handleKeydown($event, index)">{{ tab.label }}</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||
import { listImpacts, refreshImpacts, impactStatus } from './impactApi'
|
||||
|
||||
const impactReady = ref(false), impactError = ref<ApiErrorDetails | null>(null)
|
||||
const impactId = ref<string | null>(null), refreshing = ref(false), onlyChanges = ref(false)
|
||||
const access = useAccessPreviewStore()
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
|
||||
const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total = ref(0)
|
||||
const loading = ref(false), opening = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
|
||||
const context = ref<ContainerFollowupContext | null>(null)
|
||||
const changedCount = computed(() => rows.value.filter(row => row.impact?.status === 'OPEN').length)
|
||||
const displayedRows = computed(() => onlyChanges.value ? rows.value.filter(row => row.impact?.status === 'OPEN') : rows.value)
|
||||
async function refreshForecasts() {
|
||||
refreshing.value = true; contextError.value = null
|
||||
try { await refreshImpacts({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }); await load() }
|
||||
catch (cause) { contextError.value = getApiErrorDetails(cause, '船期核对未完成,原记录保留') }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
let request = 0, contextRequest = 0, controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
|
||||
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
|
||||
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
|
||||
{ prop: 'customerCount', label: '客户数', width: 90 },
|
||||
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
|
||||
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
|
||||
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
|
||||
{ key: 'impact', label: '船期跟进', slotName: 'impact', minWidth: 160 },
|
||||
{ key: 'owner', label: '负责人', slotName: 'owner', minWidth: 110 },
|
||||
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
|
||||
]
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
|
||||
try {
|
||||
const params = { page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }
|
||||
const [cargo, impacts] = await Promise.allSettled([listPlanningContainers(params, controller.signal), listImpacts(params, controller.signal)])
|
||||
if (current !== request) return
|
||||
if (cargo.status === 'rejected') throw cargo.reason
|
||||
rows.value = cargo.value.records; total.value = cargo.value.total
|
||||
if (impacts.status === 'fulfilled') {
|
||||
const states = new Map(impacts.value.records.map(row => [row.container.containerBookingId, row.impact]))
|
||||
rows.value = rows.value.map(row => ({ ...row, impact: states.get(row.containerBookingId) || null }))
|
||||
impactReady.value = true
|
||||
} else { onlyChanges.value = false; impactError.value = getApiErrorDetails(impacts.reason, '船期跟进暂不可用') }
|
||||
|
||||
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
async function follow(row: PlanningContainer, warehouseId?: string) {
|
||||
const current = ++contextRequest
|
||||
contextError.value = null; opening.value = true
|
||||
try {
|
||||
const result = await resolvePlanningContainer(row.containerBookingId, warehouseId)
|
||||
if (current === contextRequest) context.value = result
|
||||
} catch (cause) { if (current === contextRequest) contextError.value = getApiErrorDetails(cause, '整柜范围核对失败') }
|
||||
finally { if (current === contextRequest) opening.value = false }
|
||||
}
|
||||
const displayTime = (value: string | null) => value?.replace('T', ' ') || '—'
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="displayedRows" :total="total" :loading="loading || refreshing" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
|
||||
<template #actions>
|
||||
<el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
|
||||
<el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
|
||||
</template>
|
||||
</QueryToolbar></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
||||
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
|
||||
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
||||
<template #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
|
||||
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
|
||||
<template #impact="{ row }">{{ row.impact ? impactStatus[row.impact.status] : (impactReady ? '未核对' : '暂不可用') }}</template>
|
||||
<template #owner="{ row }">{{ row.impact?.ownerName || '未认领' }}</template>
|
||||
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
|
||||
</DataTable>
|
||||
<AppointmentImpactDialog v-if="impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
|
||||
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-help { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.container-help summary { cursor: pointer; }
|
||||
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppointmentCreatePanel from './AppointmentCreatePanel.vue'
|
||||
import { createSourceAppointment, listPlanningCargo, listPlanningDestinations, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import type { QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), listPlanningDestinations: vi.fn(), resolvePlanningSource: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740999', orderId: '9007199254740999', customerId: '9007199254740998', customerName: '测试客户', inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
const guards = new Set<LeaveGuard>()
|
||||
function render(canWrite = true) {
|
||||
return mount(AppointmentCreatePanel, { props: { canWrite, canLeave: async () => { for (const guard of guards) if (!await guard()) return false; return true } }, global: {
|
||||
provide: { 'appointment-work-leave-guard': (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) } },
|
||||
stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters" /><slot name="toolbar-actions" /><slot name="notices" /><div v-for="row in data" :key="row.sourceKey"><span>{{ row.inboundCode }}</span><slot name="select" :row="row" /><slot name="eligibility" :row="row" /></div><button data-page @click="$emit(\'update:page\', 2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], template: '<button data-query @click="$emit(\'search\')">查询</button>' },
|
||||
ElCheckbox: { props: ['modelValue', 'disabled'], emits: ['change'], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'change\', $event.target.checked)">' },
|
||||
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot /></button>' },
|
||||
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<input data-date type="date" v-model="model.plannedDate" :disabled="disabled">' },
|
||||
LoadErrorState: { props: ['message'], emits: ['retry'], template: '<p role="alert">{{ message }}<button data-retry @click="$emit(\'retry\')">重试</button><slot name="details" /></p>' }, ApiErrorMeta: true,
|
||||
},
|
||||
} })
|
||||
}
|
||||
async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); guards.clear()
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 21, page: 1, size: 20, pages: 2 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(listPlanningDestinations).mockResolvedValue(['ABE4','ONT8'])
|
||||
})
|
||||
it('loads searchable destination options lazily and keeps the submitted filter across pages until reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const toolbar = wrapper.getComponent({name:'QueryToolbar'})
|
||||
const destination = () => (toolbar.props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
expect(destination().props?.filterable).toBe(true)
|
||||
expect(listPlanningDestinations).not.toHaveBeenCalled()
|
||||
const open = destination().props!.onVisibleChange as (visible:boolean)=>void
|
||||
open(true); await flushPromises()
|
||||
expect(destination().options).toEqual([{label:'ABE4',value:'ABE4'},{label:'ONT8',value:'ONT8'}])
|
||||
open(true); await flushPromises(); expect(listPlanningDestinations).toHaveBeenCalledTimes(1)
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'IN-TEST',destination:'ABE4'})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:2,size:20,keyword:'IN-TEST',destination:'ABE4'},expect.any(AbortSignal))
|
||||
toolbar.vm.$emit('update:modelValue',{keyword:'',destination:undefined})
|
||||
await wrapper.get('[data-query]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({page:1,size:20,keyword:undefined},expect.any(AbortSignal))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports a destination directory failure and allows retry without hiding loaded cargo', async () => {
|
||||
vi.mocked(listPlanningDestinations).mockRejectedValueOnce(new Error('offline'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
const field = (wrapper.getComponent({name:'QueryToolbar'}).props('fields') as QueryField[]).find(field=>field.key==='destination')!
|
||||
;(field.props!.onVisibleChange as (visible:boolean)=>void)(true); await flushPromises()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
|
||||
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningDestinations).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not read or create per-row details until the operator selects cargo and proceeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled(); expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
await selectAndPrepare(wrapper)
|
||||
expect(resolvePlanningSource).toHaveBeenCalledWith({ sourceKey: cargo.sourceKey, customerId: cargo.customerId })
|
||||
expect(previewPlanningBatch).toHaveBeenCalledWith([{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }])
|
||||
expect(wrapper.text()).not.toContain(cargo.orderId)
|
||||
wrapper.unmount(); expect(guards.size).toBe(0)
|
||||
})
|
||||
it('submits the real form, shows the saved appointment, and prevents another submit of the same completed form', async () => {
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.get('[data-date]').setValue('2026-10-25')
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null }), idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
|
||||
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
|
||||
expect(wrapper.find('[data-date]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="预约保存回执"]').text()).toContain('等待对方确认')
|
||||
expect(wrapper.get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '返回选择货物')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).exists()).toBe(false)
|
||||
expect((await Promise.all([...guards].map(guard => guard()))).includes(false)).toBe(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试本次创建')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createSourceAppointment).mock.calls[1]).toEqual(vi.mocked(createSourceAppointment).mock.calls[0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('disables blocked cargo and keeps selected cargo across pages', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ records: [cargo, { ...cargo, sourceKey: 'ORDER:blocked', detained: true }], total: 21, page: 1, size: 20, pages: 2 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findAll('input[type="checkbox"]')[1]!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('input[type="checkbox"]')[0]!.setValue(true)
|
||||
await wrapper.get('[data-page]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '下一步:填写预约(1)')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reports source failures and does not offer a write path without permission', async () => {
|
||||
const readonly = render(false); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount()
|
||||
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores a source resolution that arrives after the dialog is closed', async () => {
|
||||
let complete!: (value: Awaited<ReturnType<typeof resolvePlanningSource>>) => void
|
||||
vi.mocked(resolvePlanningSource).mockImplementationOnce(() => new Promise(resolve => { complete = resolve }))
|
||||
const wrapper = render(); await flushPromises(); await selectAndPrepare(wrapper)
|
||||
wrapper.unmount(); complete({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } }); await flushPromises()
|
||||
expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
|
||||
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
|
||||
import type { SourceReference } from './workApi'
|
||||
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
|
||||
|
||||
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
|
||||
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
|
||||
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
|
||||
let destinationsRequest = 0, destinationsController: AbortController | null = null
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
|
||||
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
|
||||
options: destinations.value.map(code => ({ label: code, value: code })),
|
||||
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
|
||||
if (visible && !destinationsLoaded.value) void loadDestinations()
|
||||
} } },
|
||||
])
|
||||
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
|
||||
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
|
||||
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
|
||||
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
|
||||
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '箱数', width: 80 },
|
||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
||||
]
|
||||
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
|
||||
function reason(row: PlanningCargo) {
|
||||
if (row.detained) return '货物暂扣中'
|
||||
if (row.mixedDestinations) return '多个目的地,需先核对'
|
||||
if (row.hasActiveAppointment) return '已有有效预约'
|
||||
const anchor = selectedRows.value[0]
|
||||
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
|
||||
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
|
||||
return ''
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: boolean | string | number) {
|
||||
if (!props.canWrite || resolving.value) return
|
||||
if (!checked) delete selected.value[key(row)]
|
||||
else if (!reason(row)) selected.value[key(row)] = row
|
||||
selectionError.value = null
|
||||
}
|
||||
async function load(nextPage = page.value) {
|
||||
if (!props.canWrite) return
|
||||
page.value = nextPage
|
||||
const current = ++generation; controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
|
||||
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
|
||||
if (current === generation) { rows.value = result.records; total.value = result.total }
|
||||
} catch (cause) {
|
||||
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
|
||||
} finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
keyword.value = String(query.value.keyword || '').trim()
|
||||
destination.value = String(query.value.destination || '').trim()
|
||||
void load(1)
|
||||
}
|
||||
async function loadDestinations() {
|
||||
if (!props.canWrite || destinationsLoading.value) return
|
||||
const current = ++destinationsRequest
|
||||
destinationsController?.abort(); destinationsController = new AbortController()
|
||||
destinationsLoading.value = true; destinationError.value = null
|
||||
try {
|
||||
const result = await listPlanningDestinations(destinationsController.signal)
|
||||
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
|
||||
} catch (cause) {
|
||||
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
|
||||
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
|
||||
}
|
||||
async function prepare() {
|
||||
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
|
||||
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
|
||||
resolving.value = true; selectionError.value = null
|
||||
try {
|
||||
const sources: SourceReference[] = []
|
||||
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
|
||||
for (const row of snapshot) {
|
||||
if (current !== selectionGeneration) return
|
||||
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
|
||||
if (current !== selectionGeneration) return
|
||||
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
|
||||
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
|
||||
}
|
||||
if (current === selectionGeneration) references.value = sources
|
||||
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
|
||||
finally { if (current === selectionGeneration) resolving.value = false }
|
||||
}
|
||||
async function back() { if (await props.canLeave()) references.value = null }
|
||||
function saved(appointment: FbaAppointmentDto) {
|
||||
created.value = appointment; references.value = null; selected.value = {}
|
||||
emit('created', appointment)
|
||||
}
|
||||
async function again() {
|
||||
if (!await props.canLeave()) return
|
||||
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
|
||||
await load()
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
|
||||
<template v-if="created">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
|
||||
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
|
||||
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
|
||||
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
|
||||
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
|
||||
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
|
||||
</section>
|
||||
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
|
||||
</template>
|
||||
<template v-else-if="references">
|
||||
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
|
||||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
|
||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||||
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||||
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
|
||||
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listExternalAppointmentCandidates, type ExternalAppointmentCandidate, type PlanningSource } from './planningApi'
|
||||
|
||||
defineProps<{ canArrange: boolean; selectedKeys: string[]; busy: boolean }>()
|
||||
const emit = defineEmits<{ select: [source: PlanningSource]; followup: [row: ExternalAppointmentCandidate] }>()
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
||||
const rows = ref<ExternalAppointmentCandidate[]>([]), error = ref<ApiErrorDetails | null>(null)
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '外接货', placeholder: '登记号、车次号、客户、来货号', width: 300 }]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 160, fixed: 'left' },
|
||||
{ key: 'booking', label: '原登记号', slotName: 'booking', minWidth: 190 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 130 },
|
||||
{ prop: 'tripNo', label: '车次号', minWidth: 190 },
|
||||
{ key: 'origin', label: '接仓点', slotName: 'origin', minWidth: 140 },
|
||||
{ key: 'destination', label: '目的FBA仓', slotName: 'destination', width: 110 },
|
||||
{ key: 'packages', label: '箱数', slotName: 'packages', width: 90 },
|
||||
{ key: 'coverage', label: '预约情况', slotName: 'coverage', minWidth: 180 },
|
||||
]
|
||||
let request = 0
|
||||
let controller: AbortController | undefined
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listExternalAppointmentCandidates({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||
if (current === request) { rows.value = result.records; total.value = result.total }
|
||||
} catch (cause) {
|
||||
if (current === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '外接候选加载失败') }
|
||||
} finally { if (current === request) loading.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #toolbar-actions><slot name="toolbar-actions" /></template>
|
||||
<template #filters>
|
||||
<section class="integrated-query-group" aria-label="外接货物类型与筛选">
|
||||
<slot name="query-tabs" />
|
||||
<QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||
</section>
|
||||
</template>
|
||||
<template #notices>
|
||||
<slot name="notices" />
|
||||
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-else>已确认承运的美国FBA货物,来货前后保留同一原登记;箱数未知可先保存待确认安排。</p>
|
||||
</template>
|
||||
<template #actions="{ row }"><template v-if="row.source && row.coverage">
|
||||
<el-button v-if="canArrange" link type="primary" :disabled="busy || selectedKeys.includes(row.source.sourceKey) || !row.source.canFollowup || row.coverage.appointments.length > 0" @click="emit('select', row.source)">{{ selectedKeys.includes(row.source.sourceKey) ? '已加入' : '加入合批' }}</el-button>
|
||||
<el-button link type="primary" @click="emit('followup', row as ExternalAppointmentCandidate)">跟进</el-button>
|
||||
</template></template>
|
||||
<template #booking="{ row }">{{ row.source?.bookingNo || '—' }}</template>
|
||||
<template #origin="{ row }">{{ row.source?.originWarehouseName || '—' }}</template>
|
||||
<template #destination="{ row }">{{ row.source?.destinationWarehouseCode || '—' }}</template>
|
||||
<template #packages="{ row }">{{ row.source?.packages ?? '待核实' }}</template>
|
||||
<template #coverage="{ row }"><span v-if="row.source && row.coverage">{{ row.coverage.appointments.length ? (row.coverage.changeReviewRequired ? '已有预约,待复核' : '已有预约') : (row.source.canFollowup ? '待安排' : row.source.blockReasons.join(';')) }}</span></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
|
||||
function render(canWrite = true, container?: ContainerFollowupContext) {
|
||||
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '<div><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElSelect: true, ElOption: true, ElDatePicker: true,
|
||||
DataTable: true, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
describe('appointment followup recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history)
|
||||
vi.mocked(getContainerFollowups).mockResolvedValue(history)
|
||||
vi.mocked(saveFollowup).mockResolvedValue({ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
|
||||
})
|
||||
it.each([null, '100'])('saves the correct container stage for warehouse %s without customer-scoped calls', async warehouseId => {
|
||||
const container = { containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId, warehouseName: warehouseId ? '洛杉矶仓' : null, canFollowup: true, blockReasons: [] }
|
||||
const wrapper = render(true, container); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('同柜统一联系')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveContainerFollowup).toHaveBeenCalledWith(container, expect.objectContaining({ stage: warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT', expectedVersion: 0, result: '同柜统一联系' }))
|
||||
expect(getContainerFollowups).toHaveBeenCalledTimes(2)
|
||||
expect(getFollowups).not.toHaveBeenCalled()
|
||||
expect(saveFollowup).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('describes FBA followup independently of batch action visibility', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('FBA收货跟进')
|
||||
expect(wrapper.text()).not.toContain('同柜多客户共用提柜')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'FBA_RECEIVING', expectedVersion: 0, result: '已联系,等待回复', followupDate: null }))
|
||||
const body = vi.mocked(saveFollowup).mock.calls[0]![2]
|
||||
expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId')
|
||||
expect(getFollowups).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('retries an unknown outcome with the exact same body and persisted command key', async () => {
|
||||
vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
|
||||
expect(first.idempotencyKey).toBeTruthy()
|
||||
})
|
||||
it('does not allow a write without write permission or a successfully loaded version', async () => {
|
||||
const readonly = render(false); await flushPromises()
|
||||
expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const failed = render(); await flushPromises()
|
||||
expect(failed.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
expect(saveFollowup).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type ContainerFollowupContext, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
|
||||
|
||||
const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean; canArrange?: boolean }>()
|
||||
const emit = defineEmits<{ close: []; arrange: [] }>()
|
||||
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
|
||||
const coverageNeeds: Record<string, string> = {
|
||||
DESTINATION: '目的仓与原安排不一致,请核对。',
|
||||
DESTINATION_UNVERIFIED: '来源目的地资料待核实,尚不能判断是否发生变更。',
|
||||
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
|
||||
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先保存待确认安排;正式确认前需补齐资料。',
|
||||
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
|
||||
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
|
||||
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
|
||||
RECEIVING_CONFIRMATION: '尚未取得全部货物的收货确认。',
|
||||
APPOINTMENT_TIMEZONE_UNVERIFIED: '原预约未记录时区,请核实收货地点当地时间。',
|
||||
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
|
||||
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
|
||||
}
|
||||
const allowedStages = computed(() => props.container ? props.container.warehouseId ? { WAREHOUSE_RECEIVING: stages.WAREHOUSE_RECEIVING } : { DRAYAGE_CONTACT: stages.DRAYAGE_CONTACT, TERMINAL_APPOINTMENT: stages.TERMINAL_APPOINTMENT } : { FBA_RECEIVING: stages.FBA_RECEIVING })
|
||||
const canFollowup = computed(() => props.container?.canFollowup ?? props.source?.canFollowup ?? false)
|
||||
const blockReasons = computed(() => props.container?.blockReasons ?? props.source?.blockReasons ?? [])
|
||||
const title = computed(() => props.container ? `${props.container.containerNo || '未回传柜号'} · ${props.container.warehouseName || '提柜'}` : props.source?.inboundCode || props.source?.bookingNo || '未关联入仓码')
|
||||
const form = reactive({ stage: (props.container ? props.container.warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT' : 'FBA_RECEIVING') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
|
||||
const rows = ref<FollowupRecord[]>([])
|
||||
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
|
||||
const page = ref(1), size = ref(10), total = ref(0)
|
||||
const loading = ref(false), saving = ref(false), ready = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<SaveFollowup | null>(null)
|
||||
let generation = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'stage', label: '事项', slotName: 'stage', width: 120 },
|
||||
{ prop: 'contactedParty', label: '联系对象', minWidth: 130 },
|
||||
{ prop: 'result', label: '联系结果', minWidth: 200 },
|
||||
{ prop: 'nextAction', label: '下一步', minWidth: 150 },
|
||||
{ prop: 'followupDate', label: '下次跟进日', width: 120 },
|
||||
{ prop: 'operatorName', label: '跟进人', minWidth: 110 },
|
||||
]
|
||||
function rowKey(row: object) { const value = row as FollowupRecord; return `${value.stage}:${value.version}` }
|
||||
async function load() {
|
||||
const request = ++generation
|
||||
loading.value = true; error.value = null; ready.value = false
|
||||
try {
|
||||
const result = props.container ? await getContainerFollowups(props.container, page.value, size.value) : await getFollowups(props.source!.sourceKey, props.source!.customerId, page.value, size.value)
|
||||
if (request !== generation) return
|
||||
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
|
||||
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!props.canWrite || !canFollowup.value || saving.value || !ready.value) return
|
||||
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
|
||||
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
|
||||
}
|
||||
pending.value ||= { stage: form.stage, expectedVersion: versions.value[form.stage], idempotencyKey: crypto.randomUUID(),
|
||||
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (props.container) await saveContainerFollowup(props.container, pending.value)
|
||||
else await saveFollowup(props.source!.sourceKey, props.source!.customerId, pending.value)
|
||||
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
|
||||
ElMessage.success('跟进记录已保存'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
function canLeave() {
|
||||
if (saving.value || pending.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
function arrange() { if (canLeave()) emit('arrange') }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="close" :before-close="beforeClose">
|
||||
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
|
||||
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
|
||||
<p v-if="container">同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
|
||||
<p v-else>本条记录用于此货源的FBA收货跟进;保存联系不自动确认预约或推进收货。</p>
|
||||
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
|
||||
<p v-if="coverage?.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
|
||||
<p v-if="coverage?.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
|
||||
<p v-else-if="coverage">尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
|
||||
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
|
||||
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.followup-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-6); }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.followup-form :deep(.el-select), .followup-form :deep(.el-date-editor) { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
|
||||
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal<typeof import('./impactApi')>(), getImpact: vi.fn(), handleImpact: vi.fn() }))
|
||||
const guards = vi.hoisted(() => ({ register: vi.fn(), unregister: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: guards.push }) }))
|
||||
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
|
||||
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
|
||||
describe('appointment impact handling', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) })
|
||||
function deferred() {
|
||||
let resolve!: (value: ImpactDetail) => void, reject!: (reason: Error) => void
|
||||
const promise=new Promise<ImpactDetail>((yes,no)=>{resolve=yes;reject=no})
|
||||
return {promise,resolve,reject}
|
||||
}
|
||||
const pageDetail=(label:string):ImpactDetail=>({...detail,history:{total:60,records:[{version:1,action:'RECORD',operatorName:null,result:label,nextAction:null,followupDate:null,createdAt:'2026-10-11T00:00:00'}]}})
|
||||
it('keeps the latest history page when responses finish in reverse order',async()=>{
|
||||
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
|
||||
const wrapper=render();await flushPromises()
|
||||
const second=deferred(),third=deferred()
|
||||
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
|
||||
const pager=wrapper.findComponent({name:'ElPagination'})
|
||||
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
|
||||
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
|
||||
await flushPromises()
|
||||
third.resolve(pageDetail('第三页最新'));await flushPromises()
|
||||
second.resolve(pageDetail('第二页过期'));await flushPromises()
|
||||
expect(wrapper.text()).toContain('第三页最新');expect(wrapper.text()).not.toContain('第二页过期')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores an obsolete failure and keeps loading until the current request finishes',async()=>{
|
||||
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
|
||||
const wrapper=render();await flushPromises()
|
||||
const second=deferred(),third=deferred()
|
||||
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
|
||||
const pager=wrapper.findComponent({name:'ElPagination'})
|
||||
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
|
||||
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
|
||||
second.reject(new Error('obsolete page failure'));await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('obsolete page failure')
|
||||
expect(pager.props('disabled')).toBe(true)
|
||||
expect(wrapper.findAll('button').find(b=>b.text()==='认领跟进')!.attributes('disabled')).toBeDefined()
|
||||
wrapper.findAllComponents({name:'ElButton'}).find(b=>b.text()==='认领跟进')!.vm.$emit('click')
|
||||
await flushPromises();expect(handleImpact).not.toHaveBeenCalled()
|
||||
third.resolve(pageDetail('第三页最新'));await flushPromises()
|
||||
expect(pager.props('disabled')).toBe(false);wrapper.unmount()
|
||||
})
|
||||
it('does not let a closed dialog response replace a newly opened impact',async()=>{
|
||||
const old=deferred();vi.mocked(getImpact).mockReturnValueOnce(old.promise)
|
||||
const first=render();await flushPromises();first.unmount()
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({...pageDetail('新影响历史'),impact:{...detail.impact,impactNo:'AI-NEW'}})
|
||||
const next=render();await flushPromises()
|
||||
old.resolve(pageDetail('已关闭历史'));await flushPromises()
|
||||
expect(next.text()).toContain('AI-NEW');expect(next.text()).not.toContain('已关闭历史');next.unmount()
|
||||
})
|
||||
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
|
||||
expect(wrapper.text()).toContain('2026-10-17T09:00:00')
|
||||
expect(wrapper.text()).toContain('2026-10-16T09:00:00')
|
||||
expect(wrapper.text()).toContain('无车次查看权限')
|
||||
expect(wrapper.text()).toContain('来源:carrier')
|
||||
expect(wrapper.text()).toContain('核对原因:到港预测变化')
|
||||
expect(wrapper.text()).not.toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('labels manual observations without exposing internal operator references and explains missing forecasts', async () => {
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({ ...detail, impact: { ...detail.impact, reason: 'FORECAST_MISSING', latest: { ...detail.impact.latest!, providerCode: 'MANUAL', sourceReference: 'MANUAL:1000001', estimatedAt: null } } })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源:人工维护')
|
||||
expect(wrapper.text()).toContain('来源预计到港缺失,待核实')
|
||||
expect(wrapper.text()).not.toContain('1000001')
|
||||
expect(wrapper.text()).not.toContain('MANUAL:')
|
||||
expect(wrapper.text()).not.toContain('已延误')
|
||||
expect(wrapper.text()).toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
||||
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(false)
|
||||
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(getImpact).toHaveBeenCalledTimes(2)
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(true)
|
||||
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('does not resolve without an actual handling result', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '完成本次核对')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getImpact, handleImpact, impactStatus, impactReason, type ImpactDetail, type ImpactCommand } from './impactApi'
|
||||
const props = defineProps<{ containerId: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ close: []; saved: [] }>()
|
||||
const router = useRouter(), detail = ref<ImpactDetail | null>(null), loading = ref(false), saving = ref(false), historyPage = ref(1)
|
||||
const error = ref<ApiErrorDetails | null>(null), pending = ref<ImpactCommand | null>(null)
|
||||
const form = reactive({ result: '', nextAction: '', followupDate: '' })
|
||||
const actions: Record<string, string> = { OBSERVED: '船期核对', CLAIM: '认领', RECORD: '记录处理', RESOLVE: '完成核对' }
|
||||
let loadGeneration = 0
|
||||
async function load() {
|
||||
const generation = ++loadGeneration
|
||||
loading.value = true; error.value = null
|
||||
try { const result = await getImpact(props.containerId, historyPage.value); if (generation === loadGeneration) detail.value = result }
|
||||
catch (cause) { if (generation === loadGeneration) error.value = getApiErrorDetails(cause, '影响详情加载失败') }
|
||||
finally { if (generation === loadGeneration) loading.value = false }
|
||||
}
|
||||
async function save(action: ImpactCommand['action']) {
|
||||
if (!detail.value || loading.value || saving.value || !props.canWrite) return
|
||||
if (!pending.value && action !== 'CLAIM' && !form.result.trim()) { ElMessage.warning('请填写实际处理结果'); return }
|
||||
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
|
||||
saving.value = true; error.value = null
|
||||
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
function openTrip(tripNo: string) { if (canLeave()) void router.push(`/overseas-master-data/trips/${encodeURIComponent(tripNo)}`) }
|
||||
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregister = register?.(canLeave)
|
||||
onBeforeUnmount(() => { ++loadGeneration; unregister?.() })
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close" :before-close="beforeClose">
|
||||
<LoadErrorState v-if="error" title="核对未完成" :message="error.message" :show-retry="!pending" @retry="load" />
|
||||
<template v-if="detail">
|
||||
<p>{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}</p>
|
||||
<p>核对原因:{{ detail.impact.reason === 'FORECAST_MISSING' ? '来源预计到港缺失,待核实' : impactReason[detail.impact.reason || ''] || '待核实' }}</p>
|
||||
<p>基准 ETA:{{ detail.impact.baseline?.estimatedAt || '待核实' }} → 最新 ETA:{{ detail.impact.latest?.estimatedAt || '待核实' }}</p>
|
||||
<p>来源:{{ detail.impact.latest?.providerCode === 'MANUAL' ? '人工维护' : detail.impact.latest?.providerCode || '待核实' }} · 观察时间:{{ detail.impact.latest?.observedAt || '—' }}</p>
|
||||
<p>实际到港:{{ detail.impact.latest?.actualAt || '未记录' }}。以上为来源原始时间,时区未核实;不能据此判定跨时区违约。</p>
|
||||
<details open><summary>直接关联货物 {{ detail.cargo.length }} 票</summary><div class="impact-list"><p v-for="(row, index) in detail.cargo" :key="index">{{ row.inboundCode || '未关联入仓码' }} · {{ row.customerName || '—' }} · {{ row.destination || '—' }} · {{ row.packages ?? '待核实' }} 箱</p></div></details>
|
||||
<details open><summary>需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)</summary><div class="impact-list"><p v-for="row in detail.appointments" :key="row.appointmentNo">{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}</p></div></details>
|
||||
<p v-if="!detail.transport">无车次查看权限,运输影响未加载。</p>
|
||||
<template v-else>
|
||||
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="openTrip(row.tripNo)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
|
||||
<details><summary>同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核</summary><div class="impact-list"><p v-for="row in detail.transport.sameTripBookings" :key="row.bookingNo">{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}</p></div></details>
|
||||
</template>
|
||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="canWrite && detail.impact.status === 'OPEN'">
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
|
||||
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
|
||||
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
|
||||
</div>
|
||||
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.impact-list { max-height: 220px; overflow-y: auto; overflow-wrap: anywhere; }</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
|
||||
describe('appointment inbound code list', () => {
|
||||
const codes = Array.from({ length: 20 }, (_, index) => `WT-LONG-INBOUND-CODE-${index + 1}`)
|
||||
|
||||
it('previews three codes, exposes the total and expands without losing full codes', async () => {
|
||||
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
expect(wrapper.text()).toContain('20 个')
|
||||
expect(wrapper.get('button').text()).toContain('还有 17 个')
|
||||
expect(wrapper.get('li').attributes('title')).toBe(codes[0])
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.findAll('li').map(row => row.text())).toEqual(codes)
|
||||
expect(wrapper.get('ul').classes()).toContain('expanded')
|
||||
expect(wrapper.get('ul').attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('button').attributes('aria-controls')).toBe(wrapper.get('ul').attributes('id'))
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not count missing or duplicate codes and shows no redundant toggle', () => {
|
||||
const wrapper = mount(AppointmentInboundCodes, { props: { codes: [' A ', 'A', '', null, undefined, 'B'] } })
|
||||
expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
|
||||
expect(wrapper.text()).toContain('2 个')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(mount(AppointmentInboundCodes, { props: { codes: [] } }).text()).toContain('—')
|
||||
})
|
||||
|
||||
it('preserves expansion on equivalent data but resets when the appointment codes change', async () => {
|
||||
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.setProps({ codes: [...codes] })
|
||||
expect(wrapper.findAll('li')).toHaveLength(20)
|
||||
await wrapper.setProps({ codes: codes.map(code => `${code}-NEW`) })
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ codes: Array<string | null | undefined> }>()
|
||||
const expanded = ref(false)
|
||||
const listId = useId()
|
||||
const codes = computed(() => [...new Set(props.codes.map(code => code?.trim()).filter((code): code is string => !!code))])
|
||||
const visibleCodes = computed(() => expanded.value ? codes.value : codes.value.slice(0, 3))
|
||||
watch(() => codes.value.join('\n'), () => { expanded.value = false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-codes" aria-label="关联入仓码">
|
||||
<div class="codes-heading">
|
||||
<span :title="`共 ${codes.length} 个入仓码`">入仓码 <span v-if="codes.length">({{ codes.length }} 个)</span></span>
|
||||
<button v-if="codes.length > 3" type="button" :aria-expanded="expanded" :aria-controls="listId" @click="expanded = !expanded">
|
||||
{{ expanded ? '收起' : `查看全部(还有 ${codes.length - 3} 个)` }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="codes.length" :id="listId" class="codes-list" :class="{ expanded }" :tabindex="expanded ? 0 : undefined" aria-label="入仓码列表">
|
||||
<li v-for="code in visibleCodes" :key="code" :title="code">{{ code }}</li>
|
||||
</ul>
|
||||
<span v-else class="codes-empty">—</span>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-codes { min-width: 0; margin: var(--admin-space-5) 0; font-size: var(--admin-font-body); }
|
||||
.codes-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.codes-heading button { border: 0; background: transparent; color: var(--el-color-primary); cursor: pointer; font: inherit; min-height: var(--admin-action-height); padding: 0; }
|
||||
.codes-list { list-style: none; padding: 0; margin: var(--admin-space-1) 0 0; line-height: 1.8; }
|
||||
.codes-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.codes-list.expanded { max-height: 216px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.codes-list.expanded li { white-space: normal; overflow-wrap: anywhere; }
|
||||
.codes-heading button:focus-visible, .codes-list:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.codes-empty { display: block; margin-top: var(--admin-space-1); }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, KeepAlive, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
describe('calendar cache lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
})
|
||||
it('aborts reads when cached and reloads on return without creating background work polling', async () => {
|
||||
const active = ref(true)
|
||||
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(AppointmentCalendarView) : h('div') }) })
|
||||
const wrapper = mount(host, { global: { stubs: { AdminPage: { template: '<main><slot /></main>' }, AppointmentCalendarPanel: { template: '<section />' }, AppDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||
const signal = vi.mocked(getCalendarSources).mock.calls[0]![1]!
|
||||
active.value = false; await flushPromises()
|
||||
expect(signal.aborted).toBe(true)
|
||||
active.value = true; await flushPromises()
|
||||
expect(getCalendarSources).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { listImpacts } from './impactApi'
|
||||
vi.mock('./impactApi', async original => ({ ...await original<typeof import('./impactApi')>(), listImpacts: vi.fn(), refreshImpacts: vi.fn() }))
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningContainer, type PlanningSource } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
|
||||
const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: '2026-10-10T13:48:15', orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }
|
||||
|
||||
describe('appointment tables use actual shared table slots', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(listImpacts).mockImplementation(async () => {
|
||||
const page = await vi.mocked(listPlanningContainers).mock.results.at(-1)!.value
|
||||
return { total: page.total, records: page.records.map((container: PlanningContainer) => ({ container, impact: container.impact || null })) }
|
||||
})
|
||||
})
|
||||
it('discovers external candidates lazily and mixes original bookings with selected first-leg cargo', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null }
|
||||
vi.mocked(listExternalAppointmentCandidates).mockResolvedValue({ records: [{ source: external, customerName: '客户乙', tripNo: 'TR-1', coverage: { status: 'UNSCHEDULED', appointments: [], missingFields: [], changeReviewRequired: false } }], total: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source, external], missingFields: [] })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.data-table__filters [role="tablist"]').text()).toContain('头程货物')
|
||||
await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
|
||||
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('.data-table__filters [role="tablist"]')).toHaveLength(1)
|
||||
expect(wrapper.get('.data-table__filters [role="tablist"]').isVisible()).toBe(true)
|
||||
expect(wrapper.get('.data-table__filters [role="tab"][aria-selected="true"]').text()).toBe('外接货物')
|
||||
expect(wrapper.text()).toContain('B-1')
|
||||
await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'BOOKING:B-1', customerId: 'B', destinationKey: 'FBA:ONT8' }])
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('combines an original external booking with first-leg cargo and retries the identical save command', async () => {
|
||||
const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null}
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',destinationTimezone:null,sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]})
|
||||
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}])
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
const first=vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
|
||||
expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenNthCalledWith(2,first);expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('retains the current selection when a linked source has a different origin', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
|
||||
vi.mocked(previewPlanningBatch).mockRejectedValue(new Error('stop after observing request'))
|
||||
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:{...source,sourceKey:'BOOKING:B-2',bookingNo:'B-2',originWarehouseId:'200'}},global:{plugins:[ElementPlus]}})
|
||||
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
|
||||
expect(wrapper.emitted('sourceAccepted')).toBeUndefined()
|
||||
await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]);wrapper.unmount()
|
||||
})
|
||||
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('合批核对'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'ORDER:2', customerId: 'B', destinationKey: 'FBA:ONT8' }])
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱')
|
||||
expect(wrapper.text()).toContain('保存前尚未占用货物')
|
||||
expect(wrapper.text()).toContain('America/Los_Angeles')
|
||||
expect(wrapper.text()).not.toContain('目的仓时区待核实')
|
||||
expect(wrapper.text()).toContain('2026-10-10 13:48:15')
|
||||
expect(wrapper.text()).toContain('来源时区未明确')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the cargo list usable when impact service is unavailable and never reports a false zero', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [] }] })
|
||||
vi.mocked(listImpacts).mockRejectedValueOnce(new Error('Resource not found'))
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CONTAINER-A')
|
||||
expect(wrapper.text()).toContain('船期跟进暂不可用,货柜列表仍可查看')
|
||||
expect(wrapper.text()).not.toContain('本页待处理 0')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('details').every(node => node.attributes('open') === undefined)).toBe(true)
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1); expect(listImpacts).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ impact: { containerBookingId: 'C1', impactNo: 'AI-T', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: 'A', ownerName: '运营甲', baseline: null, latest: null, forecastToken: 'x', updatedAt: '2026-10-10T00:00:00' }, containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
|
||||
vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] })
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('本页待处理 1')
|
||||
expect(wrapper.text()).toContain('处理船期变化')
|
||||
expect(wrapper.text()).toContain('运营甲')
|
||||
expect(resolvePlanningContainer).not.toHaveBeenCalled()
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text()).toContain('待核实')
|
||||
expect(rows[0]!.text()).toContain('洛杉矶仓')
|
||||
await rows[0]!.findAll('button').find(button => button.text() === '接仓跟进')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(resolvePlanningContainer).toHaveBeenCalledExactlyOnceWith('C1', '100')
|
||||
expect(wrapper.findComponent(AppointmentFollowupDialog).props('container')?.warehouseId).toBe('100')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders working source actions and readable progress through DataTable and RowActions', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
|
||||
await flushPromises()
|
||||
const row = wrapper.get('.el-table__body-wrapper .el-table__row')
|
||||
expect(row.text()).toContain('IN-A')
|
||||
expect(row.text()).toContain('尚未建入仓计划')
|
||||
const followup = row.findAll('button').find(button => button.text() === '跟进')
|
||||
expect(followup).toBeDefined()
|
||||
expect(row.findAll('button').some(button => button.text() === '查看来源')).toBe(true)
|
||||
await followup!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(resolvePlanningSource).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' })
|
||||
expect(wrapper.findComponent(AppointmentFollowupDialog).exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => {
|
||||
vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}})
|
||||
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /></section>'}}}})
|
||||
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
|
||||
expect(wrapper.emitted('arrange')).toHaveLength(1)
|
||||
await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
|
||||
await wrapper.setProps({canArrange:true,coverage:{...coverage,appointments:[{appointmentNo:'AP-EXISTING',confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}]}})
|
||||
expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false);wrapper.unmount()
|
||||
})
|
||||
it('renders followup stage names instead of blank table cells', async () => {
|
||||
vi.mocked(getFollowups).mockResolvedValue({ history: { records: [{ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '等待回复', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' }], total: 1, page: 1, size: 10, pages: 1 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 1 } })
|
||||
const wrapper = mount(AppointmentFollowupDialog, { props: { source, coverage, canWrite: false }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
const row = wrapper.get('.el-table__body-wrapper .el-table__row')
|
||||
expect(row.text()).toContain('FBA收货跟进')
|
||||
expect(row.text()).toContain('等待回复')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
const permissions = vi.hoisted(() => ({ canWrite: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !permission.endsWith('fba-appointment') || permissions.canWrite }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||
const source = { sourceKey: 'ORDER:9007199254740993', customerId: 'customer-1', destinationKey: 'FBA:ONT8' }
|
||||
const context: CargoVerificationContext = {
|
||||
source: { ...source, orderId: '9007199254740993', inboundCode: 'IN-TEST-001', bookingNo: null, containerNo: 'TEST-CN-001', originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null, canFollowup: true, blockReasons: [] },
|
||||
verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: null, availablePackages: null, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [],
|
||||
}
|
||||
function render() { return mount(CargoVerificationForm, { props: { source }, global: { stubs: { ElButton: { props: ['nativeType'], template: '<button :type="nativeType"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); permissions.canWrite = true; vi.mocked(getCargoVerification).mockResolvedValue(context)
|
||||
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: null, destinationKey: source.destinationKey, sourcePackages: null, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试调度', verifiedAt: '2026-10-15T03:00:00Z' })
|
||||
})
|
||||
it('saves independent cargo verification before any appointment exists, preserving unknown source values', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('待核实'); expect(wrapper.text()).not.toContain('9007199254740993')
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST\nFBA-TEST')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ expectedSourceVersion: null, expectedVersion: 0, packageCount: 20, shipmentReferences: ['FBA-TEST'] }))
|
||||
expect(vi.mocked(saveCargoVerification).mock.calls[0]![1]).not.toHaveProperty('appointmentStatus')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('uses the same key and payload after an unknown save outcome', async () => {
|
||||
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('network lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(saveCargoVerification).mock.calls[0]![1]
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, first); wrapper.unmount()
|
||||
})
|
||||
it('refreshes source conflicts without deleting the entered quantity or shipment numbers', async () => {
|
||||
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new ApiResponseError('原来源已变化', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(getCargoVerification).toHaveBeenCalledTimes(2)
|
||||
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
|
||||
expect((wrapper.get('textarea[aria-label="本票货件号"]').element as HTMLTextAreaElement).value).toBe('FBA-KEEP'); wrapper.unmount()
|
||||
})
|
||||
it('blocks invalid allocation bounds and denies writes for list-only users', async () => {
|
||||
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, source: { ...context.source, packages: 50 }, allocatedPackages: 30, availablePackages: 20 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[type=number]').setValue('20'); await wrapper.get('form').trigger('submit')
|
||||
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
permissions.canWrite = false; const denied = render(); await flushPromises()
|
||||
await denied.get('form').trigger('submit'); expect(saveCargoVerification).not.toHaveBeenCalled(); denied.unmount()
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ source: CargoVerificationSource }>()
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<SaveCargoVerification | null>(null)
|
||||
const form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
|
||||
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
|
||||
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
|
||||
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; error.value = null
|
||||
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
|
||||
try {
|
||||
context.value = await getCargoVerification(props.source)
|
||||
if (!preserve) {
|
||||
const record = context.value.verification
|
||||
Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
|
||||
baseline.value = JSON.stringify(form)
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!context.value || !canWrite.value || loading.value || error.value || saving.value) return
|
||||
if (!pending.value) {
|
||||
const count = String(form.packages).trim() ? Number(form.packages) : null
|
||||
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
|
||||
if (count !== null && (count < context.value.allocatedPackages || (context.value.source.packages != null && count > context.value.source.packages))) { ElMessage.warning('核实箱数不能低于已分配量,也不能超过已知原箱数'); return }
|
||||
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
|
||||
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
|
||||
pending.value = { destinationKey: context.value.source.destinationKey, expectedSourceVersion: context.value.source.sourceVersion, expectedVersion: Number(context.value.verification?.version || 0), packageCount: count, shipmentReferences: references, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() }
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
await saveCargoVerification(props.source, pending.value)
|
||||
pending.value = null; baseline.value = JSON.stringify(form)
|
||||
ElMessage.success('核实资料已保存,原订单与预约状态保持不变'); emit('saved'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
|
||||
if (rejectedCommand(cause)) { pending.value = null; await load(true) }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="货物核实资料">
|
||||
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
||||
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取货物资料…</p>
|
||||
<template v-if="context">
|
||||
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
|
||||
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||
<form class="density-form work-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
||||
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数"></label>
|
||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" /></label>
|
||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" /></label>
|
||||
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
|
||||
<LoadErrorState v-if="saveError" title="核实资料未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></form>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.cargo-facts { display: grid; grid-template-columns: 80px 1fr; gap: var(--admin-space-3); }.cargo-facts dd { margin: 0; overflow-wrap: anywhere; }.cargo-facts dt { color: var(--el-text-color-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-20 排车入口收敛
|
||||
|
||||
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
|
||||
|
||||
自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。
|
||||
|
||||
## 2026-10-19 四类日历恢复(当前结论)
|
||||
|
||||
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
|
||||
|
||||
| 页面 | 操作 | 当前确认承载与布局 | 实现结论 |
|
||||
|---|---|---|---|
|
||||
| 预约列表 | 月/周、四类选择、当前范围搜索 | 左日历右当天安排;日期格简洁标签,最多三条 | 恢复 `AppointmentCalendarPanel`,不再打开全局工作明细弹框;待人工截图确认 |
|
||||
| 预约列表 | 月视图周数 | 用户截图 `codex-clipboard-372a5dc5-0c82-4298-93db-089b6c97c8ca.png`;周一至周日,只覆盖当月所需完整周 | 4/5/6周自适应;2026年10月五周,保留末周11月1日,不追加11月2—8日整周;待人工截图确认 |
|
||||
| 预约列表 | 查看当天、未定日期 | 固定右侧区域,完整业务字段与滚动明细 | 类型仅到港、排车、还柜、计划到仓;待人工截图确认 |
|
||||
| 预约列表 | 新增预约 | 用户本轮要求恢复顶部按钮,随后确认先支持FBA;1080px居中弹框,先选择已有货物,再登记预约时间 | 复用真实来源预览及预约保存流程,原权限/幂等/未保存保护;保存成功显示预约编号和只读内容;普通客户仓不在本次范围;待人工截图确认 |
|
||||
| 新增FBA送仓预约 | 按目的仓查询 | 用户截图 `codex-clipboard-44b338fe-af49-45e2-abf2-3bc0bd27224a.png`;在原查询栏增加可搜索、可清空的目的FBA仓下拉框 | 选项使用同货物查询权限的权威美国FBA目录;提交查询后从第一页按目的仓过滤,翻页沿用条件;待人工截图确认 |
|
||||
| 预约列表 | 审核、删除 | 不恢复通用入口 | 在原业务处理,不在日历改业务状态 |
|
||||
| 计划到仓 | 调整 | 原居中弹框保留,小型时间表单 | 整柜原命令、原权限/版本/幂等与计划冻结门禁;待人工截图确认 |
|
||||
| 到港、排车、还柜 | 查看与办理 | 跳原货柜/车次正式详情页 | 保留日期/月周返回上下文,无独立工作流程导航 |
|
||||
| 原业务深链 | 精确来源/事项办理 | 仍使用既有居中原业务表单 | 只承接其他模块实际使用的精确入口,不恢复全局工作待办;待人工截图确认 |
|
||||
|
||||
2026-10-19 目的FBA仓筛选补修:原查询栏增加可搜索、可清空的下拉框,首次展开读取完整权威美国FBA目录;复用原货物读取权限,不新增仓库管理权限。查询回第一页,翻页沿用目的仓条件,重置清除条件;选项失败独立提示并可重试,不隐藏已加载货物或伪装为空结果。预约模块37文件/276项及生产构建(含类型检查)通过,Gateway路由合同5项通过。后端生产源码编译通过,本次两个测试文件单独javac编译并经Surefire执行13项通过;常规Maven流程仍被派送模块三份测试中的5项编译错误阻挡,不能据定向结果宣称后端完整门禁通过。Admin全量405文件/3388项通过、1项输入提示合同失败,原因是其他在途运输单表单的7个输入框缺少placeholder;单独复查相同失败,未修改其他模块。日志为`.logs/appointment-destination-{test,build,full-test,placeholder-check}.log`,后端为同名前缀的`route`、`backend`、`javac`及`targeted-backend`日志。本补修未提交或部署,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 新增FBA预约入口补修:顶部按钮要求原FBA预约写权限及尾程订单列表权限,点击后才读取候选;支持同起运仓、同目的仓的跨客户货物选择,核对最新真实来源后复用原创建表单。未知结果保留原请求并阻止关闭/返回,成功后移除创建表单、显示真实预约编号和只读内容,刷新原日历;不生成虚拟车次或第五类日历。本次已明确普通客户仓暂不加入。
|
||||
|
||||
验证:新增入口直接5文件/59项通过;`pnpm build`含类型检查通过,仅既有AnalyticsChart大分块提示。工作区首次全量为405文件/3385项通过、1项排车详情布局合同失败,后续该项单独6项复查通过;再次全量出现9文件/11项失败(以5秒超时为主),在不修改超时阈值和其他业务代码的情况下,以2个工作进程复查整个预约模块、密度及上述全部失败文件,47文件/385项全过。不将这些分次结果记录为单次全量绿灯。证据为`.logs/appointment-create-{focused-final,build,full-test,full-test-final,layout-recheck,bounded-recheck}.log`。额外临时候选核对未作为通过证据;工作区其他业务在途修改保留。本次未提交、发布或新增真实业务记录,人工1920×1080/100%视觉待确认。
|
||||
|
||||
2026-10-19 月视图周数补修:按月实际周数生成日期格,并同步查询开区间;覆盖2026年10月五周、跨年五周、完整二月四周与确需六周的八月。Admin全量405文件/3375项、类型检查及生产构建通过,`git diff --check`通过;仅既有AnalyticsChart大分块提示。本补修尚未提交或发布,人工视觉待确认;证据为`.logs/appointment-calendar-month-weeks-{test,build}.log`。
|
||||
|
||||
2026-10-19 发布候选检查:Admin全量405文件/3372项通过,定向输入提示、弹框密度及取货组件4文件/35项通过;`pnpm build`(含类型检查)通过,仅既有AnalyticsChart大分块提示。新增日历后端直接测试及受影响SQL、API、权限合同通过,Gateway路由/部署拓扑/回滚合同与Mapper注册合同通过。
|
||||
|
||||
发布检查中发现的既有问题已在本候选最小修正:调拨新增箱数和取货输入提示、取货弹框密度、尾程贴标文件页签结构与对应断言;取货测试Bean及API数量断言;国内空库SQL中误放的五张海外取货表归回海外空库基线,并同步现行车次用途字段和基线表数。这些仅修改源码基线,不对运行环境执行SQL。
|
||||
|
||||
本次发布单元为Admin、Firstleg、Lastleg;新增两个只读日历接口复用现有Gateway通配路由,日历功能不新增数据库结构。环境发布与真实HTTP结果只维护在`docs/测试环境单机部署与配置契约.md`。人工视觉待用户在1920×1080、100%缩放下截图确认。
|
||||
|
||||
2026-10-16 本次 UI 检查:用户重新要求检查后,运行 Admin 全量测试,397 文件 / 3292 项通过;发现并修正 QueryToolbar 必填 `ariaLabel` 属性的模板写法。补充真实 KeepAlive + Teleport 离开/恢复及点击提醒弹层回归后,最新定向检查 3 文件 / 66 项通过,最终 `pnpm typecheck`、`pnpm build` 和 `git diff --check` 通过。构建仍有既有 AnalyticsChart 约 588 kB 分块提示。此记录更新下文历史“未运行验证”的结论;本地自动检查不代表测试环境或人工视觉验收。所属客户显示缺项仍待后端合同补齐,INP 采集报错的脚本来源未确认,本次未部署。日志为 `.logs/appointment-ui-current-{test,focused,typecheck,build}.log`。
|
||||
|
||||
本轮依据:`C:/Users/BY-OY/.codex/visualizations/2026/10/14/01a13a8d-e968-7962-906b-1a514792d921/appointment-calendar-guided.html`(V13,SHA-256 `61902e410be755aa9cbf9fcfaa9d393bfa49dd8d592cd8773af5396e78c29836`)及 `docs/海外仓业务/预约工作日历V13变更说明.md`、开发协作契约。保留既有日历和居中弹框优化,不改变原业务实际执行入口。
|
||||
|
||||
本次密度调整依据:用户提供的主页面截图 `codex-clipboard-93984795-68cb-4082-96b3-c83189d8a357.png`、展开明细截图 `codex-clipboard-a98edd3b-82af-4f1b-989e-7d0f028e0965.png`,以及用户确认的压缩留白、日期定位、控件统一、零值弱化和统计范围说明。
|
||||
|
||||
2026-10-16 操作列样式核对:用户截图 `codex-clipboard-9d54345c-c9c1-4991-82ba-b319948f781b.png` 明确要求与外层表格统一。
|
||||
|
||||
| 页面 | 操作 | 截图依据与布局 | 当前实现 | 本轮结论 |
|
||||
|---|---|---|---|---|
|
||||
| 预约工作明细表格 | 查看详情或服务端提供的下一步操作 | 操作列内蓝色普通文字,无边框、底色或装饰图标 | `RowActions` 默认描边样式 | 使用公共 `text-actions` 模式,保留当前文案、权限、点击承载和长文案换行;待人工截图确认 |
|
||||
|
||||
2026-10-16 当前承载调整:用户反馈“点击外面关闭不了,也看不清”。日期/类型/全局明细、事项详情、原侧边编辑表单和到港参考统一改用带遮罩的居中 `AppDialog`,常规宽度 1120px、高度 88dvh,支持全屏;标题与分页固定、仅正文滚动。遮罩、Esc 和关闭按钮统一经过未保存/提交结果待确认保护。此结论优先于下表早期侧边承载描述;原业务独立页面跳转、确认流程和统计口径保持原样。改后待人工截图确认。
|
||||
|
||||
V13 日历布局:按已确认定高要求,月日格固定 146px,周日格固定 270px;只展示五类服务端计数和独立到港参考,不追加逐事项卡片、不依明细条数扩高。替代下表早期“随内容自然增高”的结论;不裁掉五类入口,仍需人工核对 1920×1080、100% 下完整内容。
|
||||
|
||||
## V13 前端本轮最终收口(2026-10-16)
|
||||
|
||||
已完成 cargo-refresh 按客户最新资料预览/明确采用、60+40 原排车单共车续办,以及原车当前/历史稳定批次引用。当前合同与具体范围以下述对应章节为准,早期分批检查数字仅为过程记录。
|
||||
|
||||
- 最新完整受影响组合:41 文件、357 项通过(18.64 秒),覆盖全部 appointment-calendar、Fleet trips API/页面/安排/交接、排车预排/详情/列表及密度合同。
|
||||
- 全量前端 `pnpm test`:397 文件、3290 项通过(137.44 秒),无失败;存在既有 Vue const reactive v-model 编译提示,本批未扩展修改无关模块。
|
||||
- 最终 `pnpm typecheck` 通过;`pnpm build`(另含 vue-tsc)通过,Vite 16.24 秒,仅既有 AnalyticsChart 588.19 kB 分块警告。`git diff --check` 通过。
|
||||
- 本地日志:`.logs/appointment-v13-final-combined.log`、`appointment-v13-final-full-test.log`、`appointment-v13-final-typecheck.log`、`appointment-v13-final-build.log`;冻结文件与 SHA-256 清单为 `.logs/appointment-v13-frontend-freeze.json`。日志为本地证据,不提交。
|
||||
- 本轮前端冻结;没有提交、推送、部署、SQL 或真实 HTTP。人工 1920×1080、100% 截图与真实 60+40 装载、客户移出后重进、后送 40/20、cargo-refresh 持久事实仍待总控组织验收。后端客户间 FAILED/PENDING 排车资格隔离由总控收口,不改变本轮前端合同。
|
||||
|
||||
## V13 第一批实现与接口依赖
|
||||
|
||||
### 本批收货覆盖合同接入
|
||||
|
||||
已按生产者 `FbaReceivingContracts.java`、`FbaReceivingController.java`、`FbaReceivingService.java` 和 `appointment-receiving-v1.yaml` 接入 `receivingApi.ts`,浏览器前缀为 `/platform/v1/last-leg/appointment-receiving`(公共 HTTP 层统一提供 `/api`)。真实入口如下:
|
||||
|
||||
- GET `/batches/{batchKey}` 和 `/agreements/{agreementKey}`:读取批次、当前覆盖、待确认变化、车辆范围版本及重约 eligible/excluded。
|
||||
- POST `/changes` 和 `/changes/{changeKey}/resolve`:逐来源先送 0..当前量,持久 WAITING;明确回复 CONFIRM 或撤回 WITHDRAW。保留原批次/来源/核实/transport 版本,未知结果重试原幂等命令。
|
||||
- POST `/bookings`:一份共同 booking 或每成员独立 booking,沿用当前批次,不新建货物;取消组携带原 agreementKey/version,只提交 eligible 成员。
|
||||
- POST `/agreements/{agreementKey}/reschedule`、`/cancel`:展示整份当前范围,提交真实范围版本;取消需要 delete 权限及二次确认,普通编辑和客户移出分别遵循 Controller 的独立权限组合。
|
||||
|
||||
接入文件:`WorkReceivingPanel.vue`、只读 `ReceivingScope.vue`;入口为 `WorkAppointmentForm.vue` 的“客户调整 / 共同预约范围”,由 `WorkDetailPanel.vue` 在原居中弹框内装配并保留结果。Member.customerName、Source.inboundCode 已消费,无名无号显示“—”,不以内部标识回退。原批次快照未提供客户名时只能从已返回的覆盖成员匹配或显示“—”,不猜客户名称。
|
||||
|
||||
结果只报告服务端实际 steps;本地原子事务失败不包装成部分成功。待确认结果明确原安排未变;保存成功但刷新失败只重试 GET。范围变化后保留输入并重新确认;重约成员集合变化时禁止把原输入套给新成员,显式重新填写。已返回的真实车次可跳原详情核对,后送步骤按 ALLOCATE_CARGO.targetKey 匹配成功批次,不取任意第一批;无业务号不拼车辆链接。未知箱数不替换为 0 或加入可预约范围。
|
||||
|
||||
本批前端直接验证:`receivingApi.test.ts`、`WorkReceivingPanel.test.ts`、`WorkDetailPanel.test.ts`、`WorkAppointmentForm.test.ts` 共 4 文件、36 项通过,`pnpm typecheck` 通过。未重复全模块测试或全量构建。生产者已通知新源码编译通过,但本批后端回归和真实环境验收尚无通过结论;不能据前端模拟 HTTP 的定向测试宣称端到端通过。
|
||||
|
||||
后续闭合情况见下节:子批真实事项解析、原车按需客户卡片、原来源过滤及单批真实托盘预选均已接入;重约按最新 executionScope/executionVersion 判断客户资格;普通调整和整份操作继续使用 transportVersion。以下早期依赖表用于逐场景定位,新合同的实际实现与剩余缺口以本节及下节为准。
|
||||
|
||||
已消费的合同为 `workApi.ts`、`batchApi.ts`、`planningApi.ts` 及原预约/排车 API。本批不新增接口或推导数量。已接核心冻结的 `FbaAppointmentBatchContext.currentBatches`:服务端解析全部替代分支、按稳定批次去重,前端只显示与 originalSourceRefs 的 customerId/sourceKey/destinationKey 完全匹配的当前明细,合并后的其他来源不算入原范围。未知箱数不显示 0,零量不生成去向卡片,空结果不回退历史替代链接。`replacementBatchKeys` 只用于识别历史快照,不能据此推导当前 20/40 箱去向。取消保留分配后的续办通过原预约更新接口提交版本和幂等键,不整票重建或重新分配。当前前端 `splitBatch` 与表单一次拆两批;服务端普通 repartition 的 DTO/OpenAPI 已支持 1 至 20 个非空目标批次,普通多批能力不构成接口依赖。本轮 100/60、40/20 保持两批交互。先送 0、已确认且有车的客户移出、共约范围变更仍需专用命令,不能以普通分批代替;尚未冻结的共享范围命令继续等待生产者。
|
||||
|
||||
| V13 场景 | 当前前端与直接检查 | 依赖/剩余验收 |
|
||||
|---|---|---|
|
||||
| 事项列表统一表格 | 用户截图 `codex-clipboard-1bde1991-b226-4956-8530-6de44074f818.png` 明确要求统一表格或卡片,不能混排 | 所有日期/类型/全局待办列表统一使用 DataTable 与 RowActions,替代早期多行卡片排版。操作固定最左;事项、类型、状态、作业时间、时区、流程、地点、负责人、下次跟进、提醒分列,时间保留实际/确认/目标口径。列表弹框加宽,表头与分页由共享表格承载;保留每页 8 项、筛选和返回上下文。当前调整覆盖旧卡片与独立分页结论;未运行验证或部署,待人工截图确认 |
|
||||
| 01 整客户 160 移出续办 | `WorkDetailPanel.test.ts` 保存后读取主要下一步;`WorkExistingDispatchForm.test.ts` 沿用真实排车单继续安排本批车辆 | 单客户移出命令、当前数量/车辆版本、原车 120 保留结果及后续目标;真实发车签收待集成 |
|
||||
| 02 100 先送、60 后送 | `AppointmentBatchForm.test.ts` 保留普通分批与来源版本;`WorkDetailPanel.test.ts` 区分未定作业日和跟进日 | 已确认预约/装载关系的部分移出与调整回复语义,不能以普通分批代替 |
|
||||
| 03 后送 60 加另一客户 40 共车 | 本批与另一客户按实际托盘各建排车单;结果页“与同仓其他排车单共车”沿业务号核对两单后复用 TripPreparationDialog/Fleet prepare,可加入已有计划车次;WorkDispatchForm、WorkSharedTripForm、TripPreparationDialog、trips API 测试覆盖传递与导航 | 实物须能对应本次数量,不能对应时先走原库存拆托;共车后的路线、容量、审核及发车须真实 HTTP 验收,非任意箱级拆托开发缺口 |
|
||||
| 04 后送 40 新批、20 待排 | `AppointmentBatchForm.test.ts` 消费 `currentBatches` 的 40/20 全部分支;逐客户/来源/目的仓过滤,排除合并的其他货物;未知量不当 0 | 已接客户变化确认及真实批次续办;已接当前/历史排车全量批次引用,实际装载链待集成;实物拆托是原库存前置 |
|
||||
| 05 全部再分配后的旧入口 | `AppointmentBatchForm.test.ts` 只显示当前终点,不显示中间替代批次;零量无卡片,空结果不回退旧链接,原快照明确仅供追溯 | 服务端负责图遍历及稳定键去重;真实多次再分配与跨页返回待集成 |
|
||||
| 06 待回复先保存再续办 | `WorkAppointmentForm` 保留计划/确认分开;`WorkDetailPanel` 保存后留在当前项 | 单客户调整草稿、仓库回复范围、待确认变化、原车阻断条件 DTO/命令 |
|
||||
| 07 共约旧约号冲突 | 原预约错误继续保留表单;不按文本约号判断共约 | 生产者共约身份、当前覆盖成员、唯一性冲突合同;真实冲突验收待集成 |
|
||||
| 08 整份改期、无变化保存 | `WorkAppointmentForm.test.ts` 无变化及空白字符归一化后不重复提交;跨日/偏移原样保留 | 整份范围、成员版本、车辆影响与无变化后端幂等;不能仅凭前端禁用证明不重复复核 |
|
||||
| 09 整份取消后共同重约 | `WorkAppointmentForm.test.ts` 已接取消保留分配后的原批更新续办,可先保存日期未定/PENDING;保留历史、版本及幂等键,不再创建整票;取消不能重复提交,只读权限不能续办 | 整组共同/分别重约已接 receiving Book,消费 eligible/excluded;新关联及原车实际资格待后端/集成验收 |
|
||||
| 10 分别重约与排除已另约成员 | 不复制现有逐票新建接口实现假整组成功 | 当前组资格、排除原因、分别重约结果、冲突/部分失败续办及所有时段核对 |
|
||||
| 11 保留量为 0、原车无货 | 当前前端普通拆分保留两批均非空的交互约束,不绕过服务端业务资格 | “取消本次安排/全部移出”专用语义、无货原车权威处理结果;不能用空批次代替 |
|
||||
| 12 未定日期、换月、图层、分页 | `AppointmentCalendarView.test.ts` 全局范围不带日历条件,保存后留在当前详情、返回保持原分页;既有前置返回/外部路由上下文继续检查;`WorkDetailPanel.test.ts` 跟进日不作作业日 | 新去向与新范围的上下文等待对应稳定标识;人工截图与真实再进入待验 |
|
||||
| 13 部分失败、超时、并发 | `WorkDispatchForm.test.ts` 重试原草稿、不重锁已提交实物;`WorkDetailPanel.test.ts` 保存成功但下一步读取失败仅重试 GET、旧操作禁用;`WorkAppointmentForm.test.ts` 原批重约结果未知同键重试,明确冲突保留输入、读取新版本后显式再提交 | 多步骤逐项结果/可重试动作及范围版本合同;现有草稿恢复不代表跨域 V13 全部已完成 |
|
||||
| 14 权限、失败、时区、DST | 既有 work/form/presentation/API 直接测试保留;来源失败显示明确错误 | 新增合同对应权限/版本/持久幂等与真实时区场景待生产者和集成验证 |
|
||||
| 15 五类计数、真实车次与执行门槛 | `WorkCalendarPanel.test.ts` 五类聚合固定数量入口,服务端 2 批送仓/1 车原样展示;日格定高,不在浏览器重算车辆数;原实际执行入口保留 | 去重由后端保证;直送卸货、还空、共车发车资格及接手结果继续真实验收 |
|
||||
|
||||
当前定向检查(含首批与 currentBatches/原批重约新增 6 项):`pnpm test src/modules/appointment-calendar src/styles/adminDensity.test.ts`,29 文件、210 项通过;`pnpm typecheck` 通过。完整测试/构建窗口按开发协作契约交总控安排,本批未执行。以上是本地实现与检查,不表示部署或 15 项全链已验收;居中弹框及新增去向/续办内容仍待人工截图确认。
|
||||
|
||||
|
||||
### 60+40 两步共车入口
|
||||
|
||||
`WorkSharedTripForm.vue` 仅按明确排车业务号读取两单,复用原 Fleet 安排弹框。真实 60/40 数量从两单读取,不自行生成装载结论;已关联、异仓、重复、错误业务号均不进入安排。子弹框打开时保护父工作面板,成功导航服务端返回的车次。增量联通检查覆盖 WorkDispatchForm、WorkSharedTripForm、既有 TripPreparationDialog 与 trips API;完整本轮验证见下一节。
|
||||
|
||||
### 最新货物资料显式采用
|
||||
|
||||
已消费 RefreshCargo/REFRESH_CARGO:WorkReceivingPanel 按客户提供“核对并采用本客户最新货物资料”,只按该客户原来源读取既有 cargo-verification(预约及尾程列表读权限),展示原票、本批箱数、最新核实/有效/已分配量及货件号。读取不会保存核实资料或自动采用。源/核实版本、身份、目的地、起运仓、未知分配、已分配超量及来源阻断均检查;任何一票失败不提交部分范围。
|
||||
|
||||
明确确认后 POST `/appointment-receiving/cargo-refresh` 只携带该客户成员及显示的 batchVersion、executionVersion、各来源最新版本;不提交箱数或正式确认。已确认、待变化、已完成客户禁用。冲突后重读并清除确认,未知结果保持原请求键,成功刷新事项并可继续本客户计划/正式预约,不跳回会影响其他客户的旧整批 PUT。
|
||||
|
||||
本轮共车与资料采用最初组合为 8 文件、81 项通过;已被下述当前/历史引用收口后的完整组合覆盖。未执行 HTTP、部署或人工截图。
|
||||
|
||||
### 客户级普通计划补齐(V13 06 / 09 / 10)
|
||||
|
||||
- 已消费实际 `Context.plans`、`CustomerPlan` 和 `Plan`,通过 POST `/appointment-receiving/plans` 提交明确 batchKey/customerId/batchVersion、executionVersion 与幂等键。`WorkReceivingPanel` 客户卡片和本批重约范围提供“保存目标 / 未约成”;不走旧整批 PUT,不改变甲的确认或借分批保存日期。
|
||||
- 只允许 PENDING/FAILED,FAILED 必须有原因;普通计划无外部约号和正式确认时间。资格同时要求 PLAN_RECEIVING、当前执行资格、无活动确认和无待变化记录。时区来自该客户计划(未有计划时来自批次目的仓);非空目标缺时区阻止提交。计划窗口支持跨日及 startOffset/endOffset,DST 歧义或不存在时间由权威接口校验;只填日期或全空目标不带偏移。
|
||||
- 回显优先当前 batch/customer 的 plans,明确空日期保持空;无独立客户计划时,仅无任何有效独立覆盖且 header 为 PENDING/FAILED 才带入旧计划和原因,剔除正式确认。混合覆盖不借甲日期,CANCELLED 不回填旧目标。混合状态测试为同一稳定批次中甲已有独立确认、乙与丁未确认;覆盖乙保存日期、未约成、清空日期、丁从空白开始、只读/已完成/待变化禁用、版本冲突保留输入、未知结果同键重试、UNCHANGED、跨日/DST 和未知时区。保存后刷新当前工作事项,保留 receiving 面板及真实结果,原“记录回复与跟进”入口保留。
|
||||
- 整组取消后支持按客户勾选子集,共同或分别确认只提交所选成员。通过实际只读 POST `/appointment-receiving/execution-scope` 查询所选 batch/customer 的执行资格,使用返回版本,不复用全组或猜测 token;保留展示时的 batchVersion/groupVersion。选择变化清除范围确认,读失败禁止提交,可单独重读;过期异步响应不覆盖新选择。新增同批仅甲独立确认、跨批仅乙子集、分别表单与客户匹配、查询失败及迟到响应回归。
|
||||
- 直接测试:`WorkReceivingPanel.test.ts`、`receivingApi.test.ts`、`WorkDetailPanel.test.ts`、`TripAppointmentPanel.test.ts` 共 4 文件、56 项通过;`pnpm typecheck` 和最终 `pnpm build`(含 vue-tsc)通过,构建 4.78 秒;仅既有 AnalyticsChart 约 588 kB 分块警告。`git diff --check` 通过。本批前端冻结。未执行真实 HTTP、部署或人工截图验收;服务端甲确认不变、客户计划工作域投影及日历归属仍由总控集成核对。
|
||||
|
||||
### 本批续办、原车入口和历史来源收敛
|
||||
|
||||
- `batchApi.ts` 必需字段 `originalSourceRefs` 已与生产者 SourceRef/OpenAPI 同步;`AppointmentBatchForm` 与 `TripAppointmentPanel` 均按完整客户/来源/目的地过滤当前全部去向。原批仍保留货物但另有后送批时也显示当前去向。全客户移出后不再依赖原批当前 items,且不拿历史来源授权可分配数量;新增全移出、40/20、合并其他来源隔离回归。
|
||||
- `workApi.ts`、`WorkBatchContinuation.vue` 接真实客户级解析;保存原/后送预约后继续当前子批事项,原事项留作返回上下文,多客户不默认取第一项。
|
||||
- `TripDetailView.vue` 按需装配公开 `TripAppointmentPanel`:本车当前 appointmentBatches 与 previousDispatches 稳定引用→批次/currentBatches/receiving 范围;客户卡片列当前所有匹配去向,直接进入对应客户调整或当前批次续办。新弹框复用未保存/未知结果保护,刷新车次和路由离开也经过保护,业务提交后刷新本车事实。
|
||||
- `WorkDispatchForm.vue` 从单客户单批入口仅预选当前合格实物,候选跨批/客户、重复实物、未知箱数均不误选;提交仍复制服务端 allocations。可直接进入本批逐来源分批,60→40/20 测试只提交该 60 的原批和来源版本,不把原总量 160 再分配。多客户、超车型容量不自动全选;真实整托无法覆盖的部分箱量不伪造实物。
|
||||
- `WorkReceivingPanel.test.ts` 新增跨批 A 已另约、仅 B/D eligible 的重约回归:提交 B/D 的真实 batchKeys 与 GroupContext.executionScope.version(Book.executionVersion),不带回 A,也不使用原 A/B/D 范围版本或运输版本。待确认 WITHDRAW 仍可提交,不以普通货物可变性条件禁用撤回。
|
||||
|
||||
- 最新接收合同已消费:ChangeIntent.ownerId 由本人、当前事项及关联事项负责人候选选择,父事项传入候选,不新增用户目录或用自由姓名充当账号;Pending.followup 展示服务端 ownerName/nextFollowupDate。普通正式预约按 Context.executionScope 排除 completed 或 canRebook=false 的客户,整组按服务端 eligibleMembers;Book 只提交对应 executionVersion,运输展示或无关客户的运输阻断不再一刀切禁止重约。货物调整、确认/撤回和整份改期/取消仍使用 transportVersion。新增负责人候选/真实姓名、已完成 A 不挡未完成 B、缺失执行资格不擅自放行等回归。
|
||||
|
||||
当前合同接入、实物前置与真实验收边界:
|
||||
|
||||
1. 原车当前/历史引用已按冻结 DTO 接入:`DispatchResponse.appointmentBatches`、`TripBinding.appointmentBatches` 与 `Detail.previousDispatches` 均为必需数组,`batchNo` 允许 null,不依赖可空 `fbaAppointmentNo` 或内部排车 ID。`TripAppointmentPanel` 按批次/客户聚合并对排车业务号、来源和当前终点去重,精确匹配客户/来源/目的地,只展示 `currentBatches` 当前量。历史卡片只提供追溯和权威解析后的当前工作入口,不使用历史数量授权调整或排车。`appointmentHistoryUnavailableReason` 单独提示,缺乏证据的引用不开入口,当前车次仍可读。已补空原约号、多批引用、历史客户移出后重进、40/20 全去向、其他客户/来源隔离、重复记录、缺失历史提示和错误身份回归。真实持久历史及装载链待集成验收。
|
||||
2. 60+40 按真实两步流程收口:各客户按本批分配及实际托盘生成排车单,WorkDispatchForm 结果页进入 WorkSharedTripForm,按另一张业务号重读两单,核对当前状态、未绑定及真实起运仓一致后交给既有 TripPreparationDialog;Fleet prepare 复用多个 dispatchNos,可新建或加入原计划车次。未新增共车命令、未扫描全局待办、未按名称推仓。数量不能映射完整实物时必须先走原库存拆托,这属于实物前置,不列为待开发任意拆托;仍待真实 60+40 HTTP 和装载链验收。
|
||||
3. receiving 仍以当前源码接入,待核心与工作事项后端回归及真实场景。UI 本地通过不证明服务端初始化、同步及最终执行门槛已经部署验收。
|
||||
|
||||
验证:最新完整定向命令 `pnpm test src/modules/appointment-calendar src/modules/overseas-master-data/pages/TripsBehavior.test.ts src/styles/adminDensity.test.ts` 34 文件、260 项通过;`pnpm typecheck` 通过。`pnpm build`(含 vue-tsc)通过,Vite 构建 3.92 秒;既有 AnalyticsChart 约 588 kB 分块警告保留,无构建失败。`git diff --check` 通过;未提交、发布或部署,真实页面待人工截图确认。
|
||||
|
||||
### 下一批精确接入点与合同门槛
|
||||
|
||||
下表路径除特别标注外相对 `src/modules/appointment-calendar/`。新增语义仅列需求,不预定义字段名、HTTP 路径或动作代码。生产者交付真实 DTO/OpenAPI、权限、直接测试后再接写入;不在现有普通分批或跟进命令中夹带未实现的共约调整。
|
||||
|
||||
| 接入点与现有真实入口 | 可用部分 | 尚需生产者提供的精确语义 |
|
||||
|---|---|---|
|
||||
| `AppointmentBatchForm.vue`、`batchApi.ts`;GET `/appointment-planning/batches/{batchKey}`、POST `/appointment-planning/appointments/repartition` | 原来源核实、当前前端一次拆两批(服务端支持最多 20 批)、全部当前终点 | 车次/排车单到逐客户逐来源当前装载的引用;当前可调整量及版本;先送 0/全量/中间量各自资格;移出确认后原车保留结果与后送批次,是否有待确认草稿。不能把现有 splitBatch 的非空限制直接删除 |
|
||||
| `WorkAppointmentForm.vue`、`overseas-warehouse/api/inboundOrders.ts` 的 get/updateFbaAppointment;`api.ts` 的 deleteAppointment | 单批计划、确认、取消、原批续办、同键重试 | 独立共同预约身份、范围版本、当前成员/箱数/原确认时段/关联车辆、仅本客户或整份的允许动作;读取待回复变化及保存/确认/拒绝语义。旧事实与待确认输入分别返回,不能写普通计划来冒充移出成功 |
|
||||
| `WorkAppointmentForm.vue` 的重约衔接,后续独立共约表单只在合同冻结后装配 | 已取消单批沿原号 update,保留分配 | 整组重约的当前候选、已退出/已另约/已完成/其他处理中成员与原因、各成员当前去向;共用/独立结果、新有效关系身份、仓库覆盖确认依据、原车复用资格及范围并发校验 |
|
||||
| `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue`;`overseas-warehouse/api/dispatches.ts` 的 getBatchDispatchCandidates、草稿锁定/预排;既有 TripPreparationDialog | 按真实托盘、每客户排车,再沿已有排车单安排车辆 | 已按当前真实整托候选分别创建客户排车单,再从结果页使用另一张已备排车单业务号共车;本批默认只选当前合格实物。部分箱量无法映射时先走原库存拆托,非本轮新建任意拆托接口需求;真实资源资格和装载链待 HTTP |
|
||||
| `overseas-master-data/pages/TripDetailView.vue` 本车排车单及 `overseas-warehouse/pages/DispatchesView.vue` | 原业务排车、审核、装载、发车规则继续使用;当前任务未修改这些页面 | 本车客户卡片的客户业务名称、batchKey/来源/数量/当前预约覆盖、未确认变化、当前去向与可调整目标;对应允许动作;原车无货的权威关闭结果。不能以解除排车单关联替代客户移出 |
|
||||
| `WorkDetailPanel.vue`、`workApi.ts` 的 GET `/appointment-work/items/{key}` | 消费 allowedActions、nextAction、blockReasons、relatedItems;保存成功后刷新 | 新命令的稳定目标与白名单动作、受影响工作键、原批到当前批次的工作项引用。当前 currentBatches 只有预约快照,子批保存后不能凭业务号或拼接 batchKey 猜新工作键;目前仍刷新进入时的原事项 |
|
||||
| `WorkDetailPanel.vue`、`AppointmentCalendarView.vue`、`workNavigation.ts` | 日期、月份、流程、类型、范围、分页及原事项返回已有支持 | 新结果逐步骤成功/失败/待补、已持久保存对象、失败步骤允许的重试动作和幂等身份;用于只重试失败部分,并明确原车和后送需求都存在 |
|
||||
|
||||
独立修复:`WorkAppointmentForm` 首次读取失败后重试须初始化真实确认资料和偏移;版本冲突后仍保留已编辑输入。`AppointmentBatchForm` 切换当前去向先清除旧快照,首次读新批失败再重试须按新批初始化日期/数量,不能复用旧批草稿。对应新增直接测试分别在同名 `.test.ts`;不涉及新合同或环境写入。本次仅运行上述两个测试文件,16 项通过(新增 2 项);未重复运行上一批完整模块检查。
|
||||
|
||||
只读联审的子批续办缺口已接入真实 GET `/appointment-work/items/resolve?batchKey=...&customerId=...`:`BatchWorkResolution` 返回唯一 detail 或多客户 candidates。`WorkBatchContinuation.vue` 多候选必须明确选客户,再读权威 detail;读失败只重试 GET。`WorkAppointmentForm` 保存返回真实预约快照,`WorkDetailPanel` 用其 batchKey 定位子批,刷新摘要但不再先刷新旧事项/弹回旧事项。`WorkDetailPanel.test.ts` 与 `AppointmentCalendarView.test.ts` 已覆盖子批下一步、车辆续办、返回旧事项及原未定日期分页;工作键不在浏览器拼接。
|
||||
|
||||
### V13 集成操作与判定清单(待合同与集成窗口)
|
||||
|
||||
以下是验收设计,不是通过记录。01–11 每项恢复独立的甲 80、乙 160、丁 40 基准,均按客户+来源+目的地核对,不跨场景累计;涉及环境写入时由总控按授权窗口执行。
|
||||
|
||||
| 场景 | 操作与必须观察到的结果 |
|
||||
|---|---|
|
||||
| 01 | 原车乙卡片先送 0,确认仓库回复后原车 120、乙后送 160;沿乙入口重新约仓/用车,其他客户确认不变,无新增整票占量 |
|
||||
| 02 | 乙先送 100、后送 60,后送不填日期;原车 220,60 在全局未定日期可续办,跟进日不填入送货日 |
|
||||
| 03 | 从 60 入口按实际托盘形成客户排车单;另一客户 40 同理。结果页核对两单业务号与真实同仓资格后,沿 Fleet 安排同一车次;分别/共同约仓范围可辨,一车 100,客户归属保留。实物不匹配先走原库存拆托 |
|
||||
| 04 | 后送 60 再选 40,原车客户卡片列 40 新批与 20 待排各一次;两者可直达,不把合并的其他客户数算入乙 |
|
||||
| 05 | 后送量全部再分配,旧入口显示全部当前终点;中间历史、0 箱卡片和重复后送待办均不出现 |
|
||||
| 06 | 调整后先保存待仓库回复,退出再进入仍有待确认输入,旧有效安排保留;未解决前发车被阻断,继续明确回复后只影响对应范围 |
|
||||
| 07 | 退出客户填写他人有效旧约号,提交冲突后保留号码、时段及输入;不自动加入共同预约;重新读取范围后明确再提交 |
|
||||
| 08 | 选择整份共同改期,先核对完整当前成员和车辆;保存同一新时段只影响相关已安排工作,再次无变化保存与复核不新生复核 |
|
||||
| 09 | 整份取消后旧确认和失效目标清除、历史保留;共同重约只选仍需履约成员,仓库确认覆盖后产生新有效关联,沿可用原车续办 |
|
||||
| 10 | 一名成员先独立另约,再进入整组重约;其被排除并显示去向,退出/已完成/其他处理中成员同样不可静默加入;其他成员可分别重约 |
|
||||
| 11 | 先送为 0 合法,全量先送不产生空后送;原车所有有效货物移出后按原车命令关闭待办,已发车则进入原异常处理,不能普通移出 |
|
||||
| 12 | 未定日期分页进入→切月份/图层→当前批续办→返回;原范围、分页、日期及筛选恢复,全局待办不因日历筛选丢失 |
|
||||
| 13 | 分别在货物保存、车辆关联、读取下一步失败处中断;只重试失败步骤,未知结果同键,已成功批次/排车/车次不重复创建;显示真实部分结果 |
|
||||
| 14 | 只读权限、来源不可用、版本变化、约号冲突、跨日和 DST 逐项验证;首次 GET 失败重试不得出现空白可写旧事实,冲突后保留输入,未知数量不作 0 |
|
||||
| 15 | 五类高计数仍固定日格,同车多批只计一辆车;中转与直送分别走真实备货/卸货/还空门槛,未完成卸货或未接手责任不能借复核结束工作 |
|
||||
|
||||
阻塞场景不以按钮禁用、静态截图或 Mock 成功响应记为已验收。合同冻结后先扩充对应组件/API 定向测试,再由总控安排真实场景及 1920×1080、100% 人工截图。本次读取恢复修复后的 `pnpm typecheck` 和 `git diff --check` 均通过;未执行全量构建、真实 HTTP 或部署。
|
||||
|
||||
| 本次操作 | 证据与现状 | 调整结论 |
|
||||
|---|---|---|
|
||||
| 未定日期范围内搜索 | 用户截图 `codex-clipboard-07b5ab03-e467-4ce1-8410-13d05ac5bc51.png` 反馈大量数据缺少搜索,且测试环境仍显示旧卡片 | 全局待办弹框接入 QueryToolbar,支持柜号、批次号、车次号、地点关键词;沿现有 items API 同时传 view(含 UNDATED)与 keyword,服务端全范围筛选后分页,不过滤当前 8 条。搜索/清空回第 1 页,返回明细保留搜索词和分页,原业务跳转的 return URL 保留 workListKeyword。保留全局统计口径;表格与搜索均为当前工作区改动,本任务未部署,未运行验证 |
|
||||
| 所属客户缺失排查 | 用户截图 `codex-clipboard-645f5354-0dfd-4b90-b6f7-05ff9b1f2ffb.png`;`BusinessReference` 只有 kind/key/businessNo/label,客户引用存在 `CUSTOMER + 内部客户键 + null + 所属客户` 的生产路径,详情查询仅合并引用 | 前端按 `businessNo || '—'` 展示,缺少真实客户名称;不能据此认定没有关联客户,也不能回退内部客户键。待工作事项后端按权威客户目录补充名称/业务编号并明确展示合同;遵循开发协作契约,本任务不保留后端改动。未完成接口或环境验收 |
|
||||
| 提醒列与跳转遮罩 | 用户截图 `codex-clipboard-54e0b56d-448c-40da-a147-15798c932091.png` 和按钮反馈 | 提醒列固定 105px,仅显示去重后的条数,点击展开完整可滚动提醒,不撑高表格行。预约页被 KeepAlive 停用时卸载挂到 body 的弹框,恢复时保留父级事项/分页上下文。用户 startTime 截图实际是 INP 采集的空 entries 读取,脚本来源尚待确认,不能将该报错认定为已修复;本轮未运行验证或部署 |
|
||||
| 事项详情的信息层级 | 用户 2026-10-16 截图 `codex-clipboard-cad4abc6-51d1-42f2-8e7b-7914b264348e.png` 反馈排版不清楚、缺少重点 | 顶部突出事项名称与状态;作业时间、时区、地点、负责人和下次跟进复用共享描述表;原有提醒去重后集中展示,保留完成态与跟进日期语义;当前操作与关联资料/历史分区,保留所有动作资格、原业务跳转、默认历史展开条件和未保存保护。空原业务进度不渲染空标题;已知事件代码使用中文名称,未知代码保留在辅助信息中,时间保留原时区。本轮未运行验证或部署,待人工截图确认 |
|
||||
| 列表信息层级与日历视觉细化 | 用户 2026-10-16 截图 `codex-clipboard-6858cde3-eda3-4a02-a043-e1a47143f67c.png`、`codex-clipboard-5a68965d-cdbd-4ce9-b8b3-385d9d76b4f2.png`,并明确要求按建议优化 | 查看详情/历史使用普通描边按钮,办理动作保留主色且仍在左侧;完整标题、状态、作业时间、时区、地点、流程、负责人、跟进日期及异常分层显示。日历类型改浅色底与弱边框,保留语义色、今天/选中态;周末表头与到港参考保持辅助层级,零值弱化、超期数字提示。保留现有覆盖面板、字号、统计和动作链;改后待人工截图确认 |
|
||||
| 工作明细左侧长操作按钮 | 用户 2026-10-16 截图 `codex-clipboard-b36b905a-a223-4fa6-98ca-7ece08149a37.png` 反馈按钮过大;原 108px 列宽、全宽实心按钮导致长文案换行撑高 | 操作列调整为 184px,按钮按文案宽度使用共享 `density-action` 描边样式和 28px 最小高度;完整保留服务端动作标签及点击行为,超长文案仍可换行。业务编号使用深色标题,辅助信息使用说明字号,行间距收紧。适用全部工作明细弹框及全屏态,覆盖早期实心主按钮结论;改后待人工截图确认,本轮未运行验证或部署 |
|
||||
| 流程与工具栏分组 | 用户后续流程下拉、日期工具栏及上下操作区截图(`115df974`、`d217350b`、`80dd9fa1`) | 流程改为四个直接切换按钮;全局待办、流程、日期与类型筛选收进连续白底工作区,各行靠左,保留全局与区间统计边界 |
|
||||
| 日历浏览与日期选择 | 主页面截图;原型 `renderCalendar()`、`.day.selected`、`.todaymark` | 缩小日格空白,今天与选择态分别标识;零项日期只显示日期;显示实际查询起止日期,保留加载失败状态 |
|
||||
| 日期/类型/全局明细 | 展开截图中日历被挤窄;原型第 11 行 `.workspace`、`.panel` 为右侧覆盖布局 | 恢复覆盖式侧边工作面板,不压缩日历;增加展开/收起,标题和关闭固定,正文滚动,分页固定;仍保留左侧业务操作 |
|
||||
| 详情与编辑 | 原型 `renderTask()`、`followForm()` | 保留原侧边详情/表单和原业务跳转;关闭、切换和返回仍经过未保存保护,扩宽不重建表单 |
|
||||
| 今日需跟进 | 仅核对 `AppointmentWorkPolicy.followToday` 和 `AppointmentWorkQueryService.summary` | 保留服务端统计、接口参数及前端数字,不在本次视觉调整中改口径 |
|
||||
|
||||
| 页面/操作 | 原型证据 | 承载与布局 | 实现入口及核对范围 |
|
||||
|---|---|---|---|
|
||||
| 日历首屏、五类计数 | `.calendar`、`renderCalendar()` | 月/周日历,紧凑日格随内容自然增高,五类勾选与类型计数;到港参考单列且不计工作 | `AppointmentCalendarView.vue`、`WorkCalendarPanel.vue`;服务端聚合,实际日期优先由服务端给出 |
|
||||
| 全部待办、今日、超期、未定日期、复核 | `.inboxbar`、`backlog()`、`renderList()` | 日历上方全局入口;侧边分页明细,操作在左,范围明确 | `workApi.ts`;summary 不带月历条件,全关图层不影响全局查询 |
|
||||
| 日期、类型与本柜连续工作 | `openDay()`、`renderList()` 的 container 模式 | 同一侧边面板;确认路线后回本柜未完成事项 | `WorkDetailPanel.vue`;保存刷新摘要、日历和当前范围 |
|
||||
| 下一步、前置事项与返回 | `nextAction()`、`renderTask()`、`.origin-context` | 主按钮直接处理当前下一步;前置项保留原事项返回入口 | 动作 code/target 白名单,重新读取目标资格;原业务执行仍回原入口 |
|
||||
| 回复、路线、收货要求、复核、还空责任 | `planForm()`、`followForm()` | 侧边表单;保留原因、反馈、地点时区与历史;无需预约不造确认号 | `WorkCommandForm.vue`;显式跨日结束日期/时间成对填写,完整当地结束时间严格晚于开始;日期未含时刻时不补造;版本、权限、未知结果同键重试、冲突保留输入 |
|
||||
| 未预约补核实资料 | `cargoFields()`、`cargoSourceHtml()` | 侧边独立表单;逐来源客户、原票、数量与货件 | `CargoVerificationForm.vue`;原箱数与可分配量未知不显示0,不覆盖原订单 |
|
||||
| 编辑/确认/取消原预约 | `planForm()` 及 cancel 分支 | 侧边原预约表单,取消原因和明确确认按钮;不增加假预约 | `WorkAppointmentForm.vue`;复用既有时区与夏令时字段、版本及原业务命令 |
|
||||
| 新建原预约 | `planForm()` | 侧边先读取实际货物预览,再登记预约安排 | `WorkCreateAppointmentForm.vue`;复用原来源预览/创建 API,明确客户与 ORDER/BOOKING 来源,未知结果同键重试 |
|
||||
| 分批、合并 | `splitForm()`、`mergeForm()` | 侧边逐来源数量分配,数量守恒 | `AppointmentBatchForm.vue` 按真实 batches/repartition 合同提交逐来源数量、来源/核实版本、原批次版本和幂等键;被替代快照只读,显示新业务号;无分批资格时仍显示 appointment.items 货物快照 |
|
||||
| 提柜安排、实际提柜、还空 | `executeForm()` | 原货柜页独立表单;返回预约时恢复原事项与筛选 | `ContainerDetailView.vue`;真实车队检索、当前版本和原权限,固定 PICKED_UP 原船节点命令;实际时间由操作者填写,未知结果保留原请求与幂等键 |
|
||||
| 整柜直送逐票卸货 | `executeForm()` | 原货柜页逐来源清单及签收表单,已登记只读 | 原 direct-deliveries API;真实装柜来源、当地时间、IANA 时区、签收人与凭据号,服务端核验路线和还空前置 |
|
||||
| FBA 批次排车与用车 | `executeForm()`、原车次安排入口 | 侧边按真实托盘选择;创建原排车单后进入原车次安排弹窗 | `WorkDispatchForm.vue`;逐来源 batchAllocations 原样提交原草稿分组,每单同客户,未知结果重试原草稿;已有车次按业务号进入原详情 |
|
||||
| 客户仓批次用车 | 原排车与车次安排入口 | 按 DISPATCH 引用读取原排车单,再进入原车次安排 | `WorkExistingDispatchForm.vue`;核对不可变批次键与真实业务号,保留原实物;不调用 FBA 候选,不新建排车单;原派送进度按明确业务引用进入 |
|
||||
| 本柜接仓计划与记录 | `executeForm()`、原入库单详情 | 单计划进入对应客户原单,多计划列出本柜实际关联清单;缺计划时先预览整柜分组 | `WorkReceiptPlans.vue`;复用 container-summaries、原整柜预览/生成 API 和原入库单路由,快照与原资源重试键保持一致;实际收货仍由原仓库作业流程登记 |
|
||||
| 失败、无权限、未保存 | 真实接口与应用验收规则 | 明确失败和重试;错误不改为0项,保留输入和原上下文 | 各组件直接回归;未知命令不执行,未确认保存结果禁止离开 |
|
||||
| 保存后工作清单同步等待 | 用户截图 `codex-clipboard-40520e09-5cbf-489f-9941-325bdf22bdea.png`,顶部保存成功、正文续办 A0409 | 保留当前事项弹框和保存结果,不改变承载方式 | `useWorkSyncRead.ts` 仅对当前完整工作清单同步守卫重读 GET;间隔2秒,累计等待最多30秒,超限显示原错误;日历、汇总、明细与批次续办一致,不重复写入 |
|
||||
| 查看原排车单、继续安排本批车辆 | 用户截图 `codex-clipboard-4791b73f-7a9f-4be5-910a-38911e135ef4.png`、`codex-clipboard-e5b41c4b-a00c-4b99-ac06-cd23ec910aa9.png` | 原排车单进入正式路由,事项弹框随缓存页停用而移除;继续配车沿目标事项读取资格 | 保留已有 `pageActive/onDeactivated` 修复;跨事项 `nextAction` 仅导航,目标事项重新核对 `allowedActions`,不继承送仓资格或执行旧动作。修复后待人工截图确认 |
|
||||
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
|
||||
|
||||
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReceivingMember, ReceivingTransport } from './receivingApi'
|
||||
defineProps<{ members: ReceivingMember[]; transport?: ReceivingTransport }>()
|
||||
</script>
|
||||
<template>
|
||||
<div aria-label="当前覆盖范围">
|
||||
<div v-for="(member, index) in members" :key="`${member.batchKey}:${member.customerId}`" class="receiving-member">
|
||||
<b>{{ member.appointmentNo }} · {{ member.customerName || '—' }} · 范围 {{ index + 1 }}</b>
|
||||
<p v-for="source in member.sources" :key="`${source.sourceKey}:${source.destinationKey}`">{{ source.inboundCode || '—' }} · {{ source.packageCount }} 箱</p>
|
||||
</div>
|
||||
<p v-if="!members.length">当前无可纳入的货物。</p>
|
||||
<template v-if="transport"><p v-for="(vehicle, index) in transport.vehicles" :key="index">车辆:{{ vehicle.tripNo || '车次未关联' }} · 排车单 {{ vehicle.dispatchNo || '未关联' }} · {{ vehicle.packageCount }} 箱</p><p v-for="reason in transport.blockReasons" :key="reason" role="status">{{ reason }}</p><p v-if="!transport.vehicles.length">当前未关联车辆。</p></template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>.receiving-member { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import SourceAppointmentPanel from './SourceAppointmentPanel.vue'
|
||||
import { resolvePlanningSource, resolvePlanningOrder } from './planningApi'
|
||||
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), resolvePlanningOrder: vi.fn() }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('shows every associated appointment for a multi-batch source and opens the chosen business number', async () => {
|
||||
const source = { customerId: 'customer-a', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({source:{...source,orderId:'1',inboundCode:'IN-TEST',bookingNo:null,containerNo:'CN-TEST',originWarehouseId:'warehouse-1',originWarehouseName:'测试仓',destinationWarehouseCode:'ONT8',packages:100,sourceVersion:'v1',canFollowup:true,blockReasons:[]},coverage:{status:'MULTIPLE',missingFields:[],changeReviewRequired:false,appointments:['AP-A','AP-B'].map(appointmentNo=>({appointmentNo,confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}))}})
|
||||
const wrapper = mount(SourceAppointmentPanel,{props:{source,canLeave:async()=>true},global:{stubs:{ElButton:{template:'<button><slot/></button>'},CargoVerificationForm:true,WorkAppointmentForm:{name:'WorkAppointmentForm',props:['appointmentNo'],template:'<div>{{ appointmentNo }}</div>'},AppointmentBatchForm:true,LoadErrorState:true,ApiErrorMeta:true}}})
|
||||
await flushPromises(); expect(wrapper.text()).toContain('共 2 个关联预约')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='AP-B')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({name:'WorkAppointmentForm'}).props('appointmentNo')).toBe('AP-B')
|
||||
expect(resolvePlanningSource).toHaveBeenCalledExactlyOnceWith(source); wrapper.unmount()
|
||||
})
|
||||
it('resolves an arrived external order to canonical BOOKING and passes only that source to cargo verification', async () => {
|
||||
const input = { orderId: '9007199254740993', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue({source:{sourceKey:'BOOKING:external-booking',customerId:'customer-a',destinationKey:'FBA:ONT8',orderId:input.orderId,inboundCode:'IN-EXTERNAL',bookingNo:'BK-EXTERNAL',containerNo:null,originWarehouseId:'warehouse-1',originWarehouseName:'测试仓',destinationWarehouseCode:'ONT8',packages:100,sourceVersion:'booking-v1',canFollowup:true,blockReasons:[]},coverage:{status:'MULTIPLE',missingFields:[],changeReviewRequired:false,appointments:[]}})
|
||||
const wrapper = mount(SourceAppointmentPanel,{props:{source:input,canLeave:async()=>true},global:{stubs:{ElButton:true,CargoVerificationForm:{name:'CargoVerificationForm',props:['source'],template:'<div />'},WorkAppointmentForm:true,AppointmentBatchForm:true,LoadErrorState:true,ApiErrorMeta:true}}})
|
||||
await flushPromises()
|
||||
expect(resolvePlanningOrder).toHaveBeenCalledExactlyOnceWith(input)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.getComponent({name:'CargoVerificationForm'}).props('source')).toEqual({sourceKey:'BOOKING:external-booking',customerId:'customer-a',destinationKey:'FBA:ONT8'})
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { resolvePlanningSource, resolvePlanningOrder, type ResolvedPlanningSource, type PlanningSourceLookup } from './planningApi'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
const props = defineProps<{ source: PlanningSourceLookup; canLeave: LeaveGuard }>()
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
const context = ref<ResolvedPlanningSource | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const appointmentNo = ref<string | null>(null), batchKey = ref<string | null>(null)
|
||||
const canonicalSource = computed(() => context.value ? { sourceKey: context.value.source.sourceKey, customerId: context.value.source.customerId, destinationKey: context.value.source.destinationKey } : null)
|
||||
async function load() {
|
||||
loading.value = true; error.value = null
|
||||
try { context.value = 'sourceKey' in props.source ? await resolvePlanningSource(props.source) : await resolvePlanningOrder(props.source) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '货源预约关联读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function openAppointment(number: string) { if (await props.canLeave()) { appointmentNo.value = number; batchKey.value = null } }
|
||||
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; appointmentNo.value = null } }
|
||||
async function back() { if (await props.canLeave()) { appointmentNo.value = null; batchKey.value = null; await load() } }
|
||||
async function updated() { emit('updated'); await load() }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="货源与全部预约批次">
|
||||
<el-button v-if="appointmentNo || batchKey" @click="back">返回本票全部预约</el-button>
|
||||
<AppointmentBatchForm v-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="updated" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="appointmentNo" :key="appointmentNo" :appointment-no="appointmentNo" :cancel="false" :can-write="true" @saved="updated" @batch="openBatch" />
|
||||
<template v-else>
|
||||
<h3>本票预约与货物核实</h3>
|
||||
<LoadErrorState v-if="error" title="货源预约关联读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取全部关联预约…</p>
|
||||
<template v-else-if="context && !error"><p>{{ context.source.inboundCode || context.source.bookingNo || '原票业务号未提供' }} · {{ context.source.destinationWarehouseCode || '目的仓待核实' }}</p><p v-for="reason in context.source.blockReasons" :key="reason">{{ reason }}</p><p>共 {{ context.coverage.appointments.length }} 个关联预约,分别查看和处理。</p><div v-for="appointment in context.coverage.appointments" :key="appointment.appointmentNo" class="source-appointment"><el-button @click="openAppointment(appointment.appointmentNo)">{{ appointment.appointmentNo }}</el-button><span>{{ appointment.confirmedAt || '尚无确认时段' }} · {{ appointment.timezone || '地点时区待核实' }}</span></div><CargoVerificationForm v-if="canonicalSource" :source="canonicalSource" @saved="updated" /></template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.source-appointment { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); padding-block: var(--admin-space-3); border-bottom: 1px solid var(--cc-border); }</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import TripAppointmentPanel from './TripAppointmentPanel.vue'
|
||||
import { getDispatch, getFbaAppointment, type DispatchBatchReference } from '@/modules/overseas-warehouse'
|
||||
import { getAppointmentBatch } from './batchApi'
|
||||
import { getReceivingContext } from './receivingApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getFbaAppointment: vi.fn() }))
|
||||
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn() }))
|
||||
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
|
||||
const reference = (batchKey = 'FBA_APPOINTMENT:1', batchNo: string | null = 'AP-001'): DispatchBatchReference => ({ batchKey, batchNo, recordVersion: 0, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 70 }] })
|
||||
function render(overrides: Partial<InstanceType<typeof TripAppointmentPanel>['$props']> = {}) { return mount(TripAppointmentPanel, { props: { dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }], previousDispatches: [], historyUnavailableReason: null, canWrite: true, ...overrides }, global: { stubs: {
|
||||
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
|
||||
WorkReceivingPanel: { name: 'WorkReceivingPanel', props: ['batchKey', 'initialCustomerId'], template: '<div data-receiving />' },
|
||||
WorkBatchContinuation: { name: 'WorkBatchContinuation', props: ['batchKey', 'customerId'], template: '<div data-continuation />' },
|
||||
AppointmentBatchForm: true, WorkAppointmentForm: true,
|
||||
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' },
|
||||
} } }) }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
|
||||
vi.mocked(getReceivingContext).mockResolvedValue({ batch: batchContext().appointment, agreements: [], pendingChanges: [], plans: [], executionScope: { version: 'execution-v1', members: [] }, transport: { version: 'scope-v1', vehicles: [], blockReasons: [] }, allowedActions: [], blockReasons: [] })
|
||||
})
|
||||
it('renders all current 40/20 destinations for the bound customer and directly opens the exact customer adjustment', async () => {
|
||||
const context = batchContext(), forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment, twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||
forty.items[0]!.packageCount = 40; forty.items[1]!.packageCount = 999; twenty.items = [{ ...context.appointment.items[0]!, packageCount: 20 }]
|
||||
context.currentBatches = [forty, twenty]; vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).toContain('20 箱'); expect(wrapper.text()).not.toContain('999')
|
||||
expect(wrapper.text()).not.toContain('internal-dispatch'); expect(wrapper.text()).not.toContain('customer-a')
|
||||
await wrapper.findAll('button').find(button => button.text() === '调整本客户先送 / 后送')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkReceivingPanel' }).props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:1', initialCustomerId: 'customer-a' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('continues a current destination by its batch and customer rather than guessing a work key', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '继续本批当前工作')!.trigger('click'); await flushPromises()
|
||||
const continuation = wrapper.getComponent({ name: 'WorkBatchContinuation' })
|
||||
expect(continuation.props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a' })
|
||||
continuation.vm.$emit('open', 'real-work-key'); expect(wrapper.emitted('work')).toEqual([['real-work-key']]); wrapper.unmount()
|
||||
})
|
||||
it('uses historical source refs when the bound customer has completely left the original batch', async () => {
|
||||
const context = batchContext(), moved = context.appointment.items[0]!
|
||||
context.appointment.items = [context.appointment.items[1]!]
|
||||
const current = batchContext('AP-LATER', 'FBA_APPOINTMENT:2').appointment
|
||||
current.items = [{ ...moved, packageCount: 70 }]; context.currentBatches = [context.appointment, current]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('AP-LATER')
|
||||
expect(wrapper.text()).toContain('IN-A · 70 箱')
|
||||
expect(wrapper.text()).not.toContain('范围当前不可匹配'); wrapper.unmount()
|
||||
})
|
||||
it('reports missing references and read failures without looking up legacy appointment numbers', async () => {
|
||||
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [] }] }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('没有预约批次引用')
|
||||
expect(getDispatch).not.toHaveBeenCalled(); expect(getFbaAppointment).not.toHaveBeenCalled(); expect(getAppointmentBatch).not.toHaveBeenCalled()
|
||||
vi.mocked(getAppointmentBatch).mockRejectedValueOnce(new Error('read failed'))
|
||||
await wrapper.setProps({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('读取失败'); wrapper.unmount()
|
||||
})
|
||||
it('reads all stable references without a legacy appointment number and deduplicates repeated bindings', async () => {
|
||||
vi.mocked(getAppointmentBatch).mockImplementation(async key => batchContext(key === 'FBA_APPOINTMENT:1' ? 'AP-001' : 'AP-002', key))
|
||||
vi.mocked(getReceivingContext).mockImplementation(async key => ({ batch: batchContext('AP', key).appointment, agreements: [], pendingChanges: [], plans: [], executionScope: { version: 'v1', members: [] }, transport: { version: 'v1', vehicles: [], blockReasons: [] }, allowedActions: [], blockReasons: [] }))
|
||||
const refs = [reference(), reference('FBA_APPOINTMENT:2', 'AP-002')]
|
||||
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'customer-a', packageCount: 100, appointmentBatches: refs }, { dispatchNo: 'DS-B', customerId: 'customer-a', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
|
||||
expect(wrapper.findAll('article')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('DS-A、DS-B'); expect(wrapper.text()).toContain('AP-002')
|
||||
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(getReceivingContext).toHaveBeenCalledTimes(2)
|
||||
expect(getDispatch).not.toHaveBeenCalled(); expect(getFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('reenters a removed customer through history and displays only current 40/20 source destinations', async () => {
|
||||
const context = batchContext(), source = context.appointment.items[0]!
|
||||
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment, twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||
forty.items = [{ ...source, packageCount: 40 }, { ...source, id: 'other', sourceKey: 'ORDER:OTHER', inboundCode: 'OTHER-SOURCE', packageCount: 888 }]
|
||||
twenty.items = [{ ...source, packageCount: 20 }, { ...context.appointment.items[1]!, packageCount: 777 }]
|
||||
context.originalSourceRefs.push({ customerId: 'customer-a', sourceKey: 'ORDER:OTHER', destinationKey: 'FBA:ONT8' })
|
||||
context.appointment.items = []; context.currentBatches = [forty, twenty, forty]
|
||||
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||
const previous = { dispatchNo: 'DS-REMOVED', customerId: 'customer-a', recordVersion: 4, recordedAt: '2026-10-16T01:00:00Z', appointmentBatches: [{ ...reference(), sources: [{ ...reference().sources[0]!, packageCount: 9999 }] }] }
|
||||
const wrapper = render({ dispatches: [], previousDispatches: [previous, { ...previous, recordVersion: 5 }] }); await flushPromises()
|
||||
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('仅供追溯'); expect(wrapper.text()).toContain('40 箱'); expect(wrapper.text()).toContain('20 箱')
|
||||
for (const unexpected of ['9999', '888', '777', 'OTHER-SOURCE', '调整本客户先送 / 后送']) expect(wrapper.text()).not.toContain(unexpected)
|
||||
const buttons = wrapper.findAll('button').filter(button => button.text() === '继续本批当前工作'); expect(buttons).toHaveLength(2)
|
||||
await buttons[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WorkBatchContinuation' }).props()).toMatchObject({ batchKey: 'FBA_APPOINTMENT:20', customerId: 'customer-a' })
|
||||
expect(getAppointmentBatch).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('keeps current cards readable with incomplete history and never fabricates historical entries', async () => {
|
||||
const wrapper = render({ historyUnavailableReason: '部分原绑定记录缺少来源证据' }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('部分原绑定记录缺少来源证据')
|
||||
expect(wrapper.findAll('article')).toHaveLength(1); expect(wrapper.text()).toContain('当前关联排车单:DS-A')
|
||||
expect(wrapper.text()).not.toContain('历史关联排车单:'); expect(wrapper.text()).toContain('70 箱'); wrapper.unmount()
|
||||
})
|
||||
it('blocks invalid reference customer identity before reading or opening current work', async () => {
|
||||
const wrapper = render({ dispatches: [{ dispatchNo: 'DS-A', customerId: 'another-customer', packageCount: 70, appointmentBatches: [reference()] }] }); await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('客户或来源范围不完整')
|
||||
expect(getAppointmentBatch).not.toHaveBeenCalled(); expect(wrapper.text()).not.toContain('继续本批当前工作'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { type DispatchBatchReference, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getAppointmentBatch, type FbaAppointmentBatchContext } from './batchApi'
|
||||
import { getReceivingContext, type ReceivingContext } from './receivingApi'
|
||||
import WorkReceivingPanel from './WorkReceivingPanel.vue'
|
||||
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import type { TripPreviousDispatch } from '@/modules/overseas-master-data'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const props = defineProps<{ dispatches: Array<{ dispatchNo: string; customerId: string; packageCount: number; appointmentBatches: DispatchBatchReference[] }>; previousDispatches: TripPreviousDispatch[]; historyUnavailableReason: string | null; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; work: [key: string]; external: [path: string] }>()
|
||||
type Card = { key: string; batchKey: string; batchNo: string | null; customerId: string; current: boolean; dispatchNos: string[]; historyNos: string[]; sources: DispatchBatchReference['sources']; batch: FbaAppointmentBatchContext | null; receiving: ReceivingContext | null; error: ApiErrorDetails | null }
|
||||
const access = useAccessPreviewStore(), cards = ref<Card[]>([]), loading = ref(false)
|
||||
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
||||
const selected = ref<{ batchKey: string; customerId: string } | null>(null), mode = ref<'receiving' | 'batch' | 'appointment' | 'continue'>('receiving'), appointmentNo = ref('')
|
||||
const guards = new Set<LeaveGuard>()
|
||||
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
|
||||
defineExpose({ canLeave })
|
||||
onBeforeRouteLeave(canLeave)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (!readable.value) return
|
||||
const current = ++generation; loading.value = true
|
||||
const batches = new Map<string, Promise<FbaAppointmentBatchContext>>(), receiving = new Map<string, Promise<ReceivingContext>>()
|
||||
const references = new Map<string, Card>()
|
||||
const add = (binding: { dispatchNo: string; customerId: string; appointmentBatches: DispatchBatchReference[] }, historical: boolean) => {
|
||||
for (const reference of binding.appointmentBatches) {
|
||||
const key = JSON.stringify([reference.batchKey, reference.customerId])
|
||||
let card = references.get(key)
|
||||
if (!card) { card = { key, batchKey: reference.batchKey, batchNo: reference.batchNo, customerId: reference.customerId, current: false, dispatchNos: [], historyNos: [], sources: [], batch: null, receiving: null, error: null }; references.set(key, card) }
|
||||
if (reference.customerId !== binding.customerId || !reference.batchKey || !reference.sources.length) card.error = { message: '排车引用的客户或来源范围不完整,请核对原排车单。' }
|
||||
card.current ||= !historical
|
||||
const numbers = historical ? card.historyNos : card.dispatchNos
|
||||
if (!numbers.includes(binding.dispatchNo)) numbers.push(binding.dispatchNo)
|
||||
for (const source of reference.sources) if (!card.sources.some(existing => existing.sourceKey === source.sourceKey && existing.destinationKey === source.destinationKey)) card.sources.push(source)
|
||||
}
|
||||
}
|
||||
props.dispatches.forEach(binding => add(binding, false))
|
||||
props.previousDispatches.forEach(binding => add(binding, true))
|
||||
const loaded = await Promise.all([...references.values()].map(async card => {
|
||||
if (card.error) return card
|
||||
try {
|
||||
if (!batches.has(card.batchKey)) batches.set(card.batchKey, getAppointmentBatch(card.batchKey))
|
||||
if (!receiving.has(card.batchKey)) receiving.set(card.batchKey, getReceivingContext(card.batchKey))
|
||||
const [batch, scope] = await Promise.all([batches.get(card.batchKey)!, receiving.get(card.batchKey)!])
|
||||
if (batch.appointment.batchKey !== card.batchKey || scope.batch.batchKey !== card.batchKey) throw new Error('批次范围不匹配')
|
||||
card.batch = batch; card.receiving = scope
|
||||
} catch (cause) { card.error = getApiErrorDetails(cause, '本客户预约与去向读取失败') }
|
||||
return card
|
||||
}))
|
||||
if (current === generation) { cards.value = loaded; loading.value = false }
|
||||
}
|
||||
function customerName(card: Card) { return card.receiving?.agreements.flatMap(row => row.members).find(member => member.customerId === card.customerId)?.customerName || '—' }
|
||||
function destinations(card: Card) {
|
||||
if (!card.batch) return []
|
||||
const original = new Set(card.batch.originalSourceRefs.filter(item => item.customerId === card.customerId && card.sources.some(source => source.sourceKey === item.sourceKey && source.destinationKey === item.destinationKey)).map(item => JSON.stringify([item.sourceKey, item.destinationKey])))
|
||||
return [...new Map(card.batch.currentBatches.map(batch => [batch.batchKey, batch])).values()].map(batch => ({ batch, items: batch.items.filter(item => item.customerId === card.customerId && original.has(JSON.stringify([item.sourceKey, item.destinationKey])) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
|
||||
}
|
||||
async function open(batchKey: string, customerId: string, next: 'receiving' | 'batch' | 'continue') { if (await canLeave()) { selected.value = { batchKey, customerId }; mode.value = next } }
|
||||
async function openAppointment(number: string) { if (await canLeave()) { appointmentNo.value = number; mode.value = 'appointment' } }
|
||||
async function close(done: () => void) { if (await canLeave()) { done(); selected.value = null } }
|
||||
function saved(appointment: FbaAppointmentDto) { if (appointment.batchKey && selected.value) { selected.value = { ...selected.value, batchKey: appointment.batchKey }; mode.value = 'continue' }; updated() }
|
||||
function updated() { emit('updated'); void load() }
|
||||
watch(() => JSON.stringify([props.dispatches, props.previousDispatches]), () => { void load() })
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="本车客户预约与后送去向">
|
||||
<p v-if="!readable">需要预约及排车列表权限才能读取客户预约范围。</p>
|
||||
<p v-if="loading">正在核对本车客户的预约与当前去向…</p>
|
||||
<el-button :loading="loading" @click="load">刷新客户预约与去向</el-button>
|
||||
<p v-if="historyUnavailableReason" role="alert">历史预约引用读取不完整:{{ historyUnavailableReason }}。当前车次仍可查看,请重试读取后再核对历史去向。</p><p v-for="binding in dispatches.filter(row => !row.appointmentBatches.length)" :key="binding.dispatchNo" role="status">{{ binding.dispatchNo }}:本排车单没有预约批次引用,请从原排车单核对业务来源。</p><article v-for="card in cards" :key="card.key" class="trip-customer-card">
|
||||
<h4>{{ customerName(card) }} · {{ card.batchNo || '批次业务号未提供' }}</h4><p v-if="card.current">当前关联排车单:{{ card.dispatchNos.join('、') }}</p><p v-if="card.historyNos.length">历史关联排车单:{{ card.historyNos.join('、') }} · 仅供追溯,不代表当前装载或可排数量。</p>
|
||||
<LoadErrorState v-if="card.error" title="本客户范围不可用" :message="card.error.message" @retry="load" />
|
||||
<template v-if="card.batch && card.receiving">
|
||||
<p v-for="pending in card.receiving.pendingChanges.filter(row => row.intent.customerId === card.customerId)" :key="pending.changeKey" role="status">待仓库回复:{{ pending.intent.reason }}。原安排保留,请继续处理。</p>
|
||||
<el-button v-if="card.current" :disabled="loading || !canWrite" @click="open(card.batch.appointment.batchKey!, card.customerId, 'receiving')">调整本客户先送 / 后送</el-button>
|
||||
<el-button v-if="card.current" :disabled="loading" @click="open(card.batch.appointment.batchKey!, card.customerId, 'batch')">查看批次与完整去向</el-button>
|
||||
<div v-for="destination in destinations(card)" :key="destination.batch.batchKey"><b>{{ destination.batch.appointmentNo }}</b><p>{{ destination.batch.plannedDate || '日期未定' }}</p><p v-for="item in destination.items" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><el-button :disabled="loading" @click="open(destination.batch.batchKey!, card.customerId, 'continue')">继续本批当前工作</el-button></div>
|
||||
<p v-if="!destinations(card).length">原客户来源范围当前不可匹配,请核对完整批次去向;此处不代表已完成或没有后送需求。</p>
|
||||
</template>
|
||||
</article>
|
||||
<p v-if="readable && !loading && !cards.length">当前没有可读取的预约批次引用。</p>
|
||||
<AppDialog :model-value="!!selected" title="客户预约与后送安排" width="min(1120px, calc(100vw - 48px))" class="admin-density-dialog admin-density" fullscreenable append-to-body :show-footer="false" :before-close="close" :close-on-click-modal="true" :close-on-press-escape="true" destroy-on-close>
|
||||
<template v-if="selected">
|
||||
<WorkReceivingPanel v-if="mode === 'receiving'" :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :initial-customer-id="selected.customerId" :can-write="canWrite" @updated="updated" @appointment="openAppointment" @batch="key => open(key, selected!.customerId, 'batch')" @external="path => emit('external', path)" />
|
||||
<AppointmentBatchForm v-else-if="mode === 'batch'" :key="selected.batchKey" :batch-key="selected.batchKey" @updated="updated" @appointment="openAppointment" />
|
||||
<WorkAppointmentForm v-else-if="mode === 'appointment'" :key="appointmentNo" :appointment-no="appointmentNo" :cancel="false" :can-write="canWrite" @saved="saved" @batch="key => open(key, selected!.customerId, 'batch')" @receiving="key => open(key, selected!.customerId, 'receiving')" />
|
||||
<WorkBatchContinuation v-else :key="`${selected.batchKey}:${selected.customerId}`" :batch-key="selected.batchKey" :customer-id="selected.customerId" @open="key => emit('work', key)" />
|
||||
</template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.trip-customer-card { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkAppointmentForm from './WorkAppointmentForm.vue'
|
||||
import { getFbaAppointment, updateFbaAppointment, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { deleteAppointment } from './api'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
|
||||
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
|
||||
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
|
||||
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
|
||||
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
|
||||
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).exists()).toBe(false)
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
expect(model.confirmationNo).toBe('EXT-001'); expect(model.appointmentStatus).toBe('SUCCESS')
|
||||
expect(model.appointmentAt).toBe('2026-10-15T23:30:00'); expect(model.startOffset).toBe('-07:00')
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('preserves both dates of an existing cross-day appointment and its explicit offsets in the original business command', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').appointmentRemark = '已核对仓库回复'
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 3, details: expect.objectContaining({ appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', startOffset: '-07:00', endOffset: '-07:00' }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not resubmit unchanged appointment facts, including whitespace-only edits', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const save = wrapper.findAll('button').find(button => button.text() === '保存预约安排')!
|
||||
expect(save.attributes('disabled')).toBeDefined()
|
||||
wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model').confirmationNo = ' EXT-001 '
|
||||
await flushPromises(); await save.trigger('click')
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('无需重复保存'); wrapper.unmount()
|
||||
})
|
||||
it('does not edit or cancel replaced snapshots and offers the stable batch context for replacement history', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue({ ...detail, replacementAppointmentNos: ['AP-NEW'], replacementBatchKeys: ['FBA_APPOINTMENT:2'], repartitionReason: '分批送仓' })
|
||||
const wrapper = render(true); await flushPromises()
|
||||
expect(wrapper.text()).toContain('原预约已被替代'); expect(wrapper.text()).toContain('AP-NEW')
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.trigger('click')
|
||||
expect(deleteAppointment).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看替代批次')!.trigger('click')
|
||||
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
|
||||
})
|
||||
const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null }
|
||||
it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物分配仍保留')
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
|
||||
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
|
||||
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存预约安排')!.attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retries an uncertain rebooking using the identical command, and preserves input on an explicit conflict', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 6 })
|
||||
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('unknown outcome')).mockRejectedValueOnce(new ApiResponseError('外部约号已占用', 'A0409', 409))
|
||||
const wrapper = render(); await flushPromises()
|
||||
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
|
||||
Object.assign(fields.props('model'), { appointmentStatus: 'SUCCESS', confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
expect(fields.props('disabled')).toBe(true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '重试本次保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2, 'AP-001', first)
|
||||
expect(fields.props('model').confirmationNo).toBe('NEW-EXT'); expect(fields.props('disabled')).toBe(false)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
|
||||
const next = vi.mocked(updateFbaAppointment).mock.calls[2]![1]
|
||||
expect(next.version).toBe(6); expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('cannot cancel twice or rebook with read-only access', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(cancelled)
|
||||
const wrapper = render(true); await flushPromises()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '确认取消并继续跟进')!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.setProps({ cancel: false, canWrite: false }); await flushPromises()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.attributes('disabled')).toBeDefined()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); expect(deleteAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getFbaAppointment, updateFbaAppointment, FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { deleteAppointment } from './api'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
|
||||
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
|
||||
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
let initialized = false
|
||||
type SaveRequest = { kind: 'update'; payload: { details: ReturnType<typeof appointmentPayload>; version: number; idempotencyKey: string } } | { kind: 'cancel'; version: number; reason: string }
|
||||
const pending = ref<SaveRequest | null>(null)
|
||||
const changed = computed(() => JSON.stringify(appointmentPayload(form)) !== baseline.value)
|
||||
useWorkDraft(computed(() => changed.value || !!reason.value), computed(() => saving.value || !!pending.value))
|
||||
const replaced = computed(() => !!detail.value?.replacementAppointmentNos?.length || !!detail.value?.replacementBatchKeys?.length)
|
||||
const rebooking = computed(() => !props.cancel && detail.value?.appointmentStatus === 'CANCELLED' && !replaced.value)
|
||||
const writable = computed(() => props.canWrite && !replaced.value && (!props.cancel || detail.value?.appointmentStatus !== 'CANCELLED') && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||
async function load(preserve = false) {
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
detail.value = await getFbaAppointment(props.appointmentNo)
|
||||
if (!preserve || !initialized) {
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
|
||||
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
|
||||
}
|
||||
}
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '预约详情读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!detail.value || !writable.value || loading.value || error.value || saving.value) return
|
||||
if (!props.cancel && !pending.value && !changed.value && !rebooking.value) return
|
||||
if (!pending.value) {
|
||||
if (props.cancel) {
|
||||
if (!reason.value.trim()) { ElMessage.warning('请填写取消原因'); return }
|
||||
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
|
||||
} else {
|
||||
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
|
||||
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
|
||||
}
|
||||
}
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (pending.value.kind === 'cancel') await deleteAppointment(props.appointmentNo, pending.value.version, pending.value.reason)
|
||||
else detail.value = await updateFbaAppointment(props.appointmentNo, pending.value.payload)
|
||||
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(appointmentPayload(form))
|
||||
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved', detail.value); await load()
|
||||
} catch (cause) { saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="预约安排">
|
||||
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
|
||||
<LoadErrorState v-if="error" title="预约详情读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在读取原预约…</p>
|
||||
<template v-if="detail">
|
||||
<p>{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}</p>
|
||||
<p v-if="rebooking">原预约已取消,货物分配仍保留。重新预约将更新本批安排,保留取消历史;请重新填写目标日期或仓库确认,日期未定也可先保存待预约。</p>
|
||||
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
|
||||
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
|
||||
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
|
||||
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" /></label></template>
|
||||
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
|
||||
<p v-if="!writable">当前预约仅可查看。</p>
|
||||
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
|
||||
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error || (!cancel && !rebooking && !pending && !changed)" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : rebooking ? '保存本批重新预约' : '保存预约安排' }}</el-button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import WorkBatchContinuation from './WorkBatchContinuation.vue'
|
||||
import { resolveBatchWork } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function render() { return mount(WorkBatchContinuation, { props: { batchKey: 'FBA_APPOINTMENT:40', labels: [{ customerId: 'customer-a', label: 'IN-A' }] }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></div>' }, ApiErrorMeta: true } } }) }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('shows synchronization after a successful save and continues by reading without resubmitting', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new ApiResponseError('预约批次或运输关联已变化,服务端正在协调完整工作清单', 'A0409', 409)).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('同步中')
|
||||
expect(wrapper.text()).toContain('无需重复提交')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(2000); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toEqual([['actual-key']])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires a customer selection for multiple real work candidates and uses the selected resolver result', async () => {
|
||||
vi.mocked(resolveBatchWork).mockResolvedValueOnce({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: null, candidates: [{ customerId: 'customer-a', workItemKey: 'real-work-a', batchNo: 'AP-40' }, { customerId: 'customer-b', workItemKey: 'real-work-b', batchNo: 'AP-40' }] }).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: workDetail({ item: workItem({ workItemKey: 'current-work-a' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.emitted('open')).toBeUndefined(); expect(wrapper.text()).toContain('IN-A'); expect(wrapper.text()).not.toContain('customer-a')
|
||||
await wrapper.findAll('button')[0]!.trigger('click'); await flushPromises()
|
||||
expect(resolveBatchWork).toHaveBeenLastCalledWith('FBA_APPOINTMENT:40', 'customer-a', expect.any(AbortSignal))
|
||||
expect(wrapper.emitted('open')).toEqual([['current-work-a']]); wrapper.unmount()
|
||||
})
|
||||
it('keeps saved results on resolution failure and only retries the read', async () => {
|
||||
vi.mocked(resolveBatchWork).mockRejectedValueOnce(new Error('同步未完成')).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: null, detail: workDetail({ item: workItem({ workItemKey: 'actual-key' }) }), candidates: [] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('无需重复提交'); expect(wrapper.emitted('open')).toBeUndefined()
|
||||
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||
expect(resolveBatchWork).toHaveBeenCalledTimes(2); expect(wrapper.emitted('open')).toEqual([['actual-key']]); wrapper.unmount()
|
||||
})
|
||||