Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81c7e30bb | ||
|
|
97277b031d | ||
|
|
8b02f1d870 | ||
|
|
3953453090 | ||
|
|
2333bd8bab | ||
|
|
eb0b0f85d1 | ||
|
|
db3b5f7157 | ||
|
|
876930b07e | ||
|
|
0a1968a134 | ||
|
|
2f9a3330e5 | ||
|
|
1c771066ea | ||
|
|
e60f1b300a | ||
|
|
13e4aa56ec | ||
|
|
8afd04aa55 | ||
|
|
00b1fefecb | ||
|
|
88632e6e4f | ||
|
|
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 | ||
|
|
e93d00b79e | ||
|
|
73dd411763 | ||
|
|
b529430ea2 | ||
|
|
f24ed30ae8 | ||
|
|
19a64fbaa5 | ||
|
|
ec4b31a46f | ||
|
|
3fff8c8ea0 | ||
|
|
92ec1e11b1 | ||
|
|
e665877737 |
@@ -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,13 +1,13 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。
|
||||
# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
# 可选:本地分别直连 Auth、IAM、Warehouse;未设置时复用 API_PROXY_TARGET。
|
||||
# AUTH_API_PROXY_TARGET=http://localhost:9001
|
||||
# IAM_API_PROXY_TARGET=http://localhost:9002
|
||||
# WAREHOUSE_API_PROXY_TARGET=http://localhost:9003
|
||||
# 海外业务浏览器请求使用独立代理,代理会将 /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=
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
## 快速读取顺序
|
||||
|
||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
||||
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。
|
||||
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
||||
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
|
||||
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。
|
||||
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
|
||||
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
|
||||
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
||||
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.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` 对应章节。
|
||||
|
||||
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
|
||||
|
||||
## 项目定位与技术基线
|
||||
|
||||
@@ -29,62 +32,59 @@
|
||||
|
||||
- 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。
|
||||
- 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。
|
||||
- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
||||
- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
||||
- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
||||
- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
||||
|
||||
## 模块快速路由
|
||||
|
||||
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
||||
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
|
||||
|
||||
全量验收按业务从 `../../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 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
| 业务 | 首个入口 |
|
||||
|---|---|
|
||||
| 登录、会话与统一 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` |
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `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/` |
|
||||
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
|
||||
|
||||
## 业务、安全与共享装配
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
|
||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
|
||||
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||
@@ -104,7 +104,7 @@
|
||||
## 开发验收身份
|
||||
|
||||
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
|
||||
- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。
|
||||
- 视觉验收由人工操作真实页面并截图,AI 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
|
||||
|
||||
## 命令与质量门禁
|
||||
|
||||
@@ -121,7 +121,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,250 +1,60 @@
|
||||
def sendProductionWeComNotification(String title, String status, String color, String detail) {
|
||||
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
|
||||
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
|
||||
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
|
||||
String content = """## ${title}
|
||||
> 应用:管理后台
|
||||
> Job:${env.JOB_NAME}
|
||||
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
|
||||
> 提交:${sourceVersion}
|
||||
> 状态:<font color="${color}">${status}</font>
|
||||
${detail}
|
||||
> 耗时:${duration}
|
||||
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
|
||||
"""
|
||||
String payloadFile = 'wecom-production-notification.json'
|
||||
writeFile(
|
||||
file: payloadFile,
|
||||
text: groovy.json.JsonOutput.toJson([
|
||||
msgtype: 'markdown',
|
||||
markdown: [content: content]
|
||||
]),
|
||||
encoding: 'UTF-8'
|
||||
)
|
||||
sh '''
|
||||
set -eu
|
||||
payload_file='wecom-production-notification.json'
|
||||
trap 'rm -f "$payload_file"' EXIT HUP INT TERM
|
||||
response="$(curl --fail --silent --show-error \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$payload_file" \
|
||||
"$WECOM_WEBHOOK")"
|
||||
printf '%s\n' "$response"
|
||||
printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
|
||||
}
|
||||
|
||||
environment {
|
||||
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
|
||||
DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'
|
||||
REGISTRY_HOST = '172.31.198.7:10082'
|
||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||
NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
|
||||
NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
|
||||
PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/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.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false'
|
||||
if (env.IS_PRODUCTION == 'true') {
|
||||
sh '''
|
||||
set -eu
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
|
||||
echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2
|
||||
exit 1
|
||||
}
|
||||
'''
|
||||
if (env.JOB_BASE_NAME?.endsWith('-dev')) {
|
||||
error('此流水线仅支持 test 和本轮授权的 prod 灰度')
|
||||
}
|
||||
env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
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' && env.IS_PRODUCTION != 'true' } }
|
||||
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
docker compose -f docker-compose.test.yml build --pull=false
|
||||
docker compose -f docker-compose.test.yml up -d --remove-orphans
|
||||
'''
|
||||
}
|
||||
sh '''set -eu
|
||||
docker compose -f docker-compose.test.yml build --pull=false
|
||||
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
|
||||
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并发布生产镜像') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
stage('发布临时生产灰度') {
|
||||
when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout git.buggoglobal.com >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com
|
||||
image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG"
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||
--build-arg VITE_CLIENT_WEB_URL=https://app.buggoglobal.com/ \
|
||||
-t "$image" .
|
||||
docker push "$image"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
lock(resource: 'firstleg-production-admin-web-deploy', inversePrecedence: true) {
|
||||
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')
|
||||
]) {
|
||||
withEnv([
|
||||
'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend',
|
||||
'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST
|
||||
]) {
|
||||
sh "sh scripts/deploy-production.sh admin-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml"
|
||||
}
|
||||
sh 'sh scripts/deploy-production.sh'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并部署开发前端') {
|
||||
when { expression { env.IS_DEVELOPMENT == 'true' } }
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
sh '''
|
||||
set -eu
|
||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
mkdir -p "$DOCKER_CONFIG"
|
||||
cleanup_registry() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
rm -rf "$DOCKER_CONFIG"
|
||||
}
|
||||
trap cleanup_registry EXIT HUP INT TERM
|
||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
image="admin-web-development:${SOURCE_TAG}"
|
||||
|
||||
docker build \
|
||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
||||
--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \
|
||||
-t "$image" .
|
||||
export IMAGE_TAG="$SOURCE_TAG"
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
|
||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署检查') {
|
||||
when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } }
|
||||
steps {
|
||||
sh '''
|
||||
set -eu
|
||||
docker compose -f docker-compose.test.yml ps
|
||||
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'✅ 生产部署成功',
|
||||
'成功',
|
||||
'info',
|
||||
'> 结果:Admin 生产前端已完成发布和部署流程'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failure {
|
||||
sh '''
|
||||
if [ "${IS_PRODUCTION:-false}" = "true" ]; then
|
||||
echo "生产部署失败,请查看上方远程部署日志"
|
||||
elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
|
||||
docker logs --tail=100 admin-web-development || true
|
||||
else
|
||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
||||
fi
|
||||
'''
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'❌ 生产发布失败',
|
||||
'失败',
|
||||
'warning',
|
||||
'> 说明:请打开 Jenkins 构建日志查看失败阶段'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
aborted {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'⏹ 生产部署已取消',
|
||||
'已取消',
|
||||
'comment',
|
||||
'> 说明:生产部署流程被人工取消或中止'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
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
|
||||
@@ -54,16 +54,16 @@ pnpm test
|
||||
```dotenv
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
||||
VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
```
|
||||
|
||||
国内业务继续使用 `http` 和 `/api` 代理;海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。
|
||||
全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。
|
||||
|
||||
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
|
||||
本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。
|
||||
|
||||
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。
|
||||
唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。
|
||||
|
||||
容器启动通过 `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` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||
|
||||
@@ -74,3 +74,7 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。
|
||||
|
||||
登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。
|
||||
|
||||
同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
|
||||
|
||||
TEST 入口同时转发两个独立 PDA 静态应用:`/pda-domestic/` 到共享网络中的 `pda-domestic-h5-test:80`,`/pda-overseas/` 到 `pda-overseas-h5-test:80`。各自构建、镜像和静态文件由对应 PDA 项目维护,Admin 不打包 PDA 源码。请求时解析 Docker DNS,PDA 未启动不会阻止 Admin 启动。国内 H5 的 `/gateway-api/` 在此转换为同一 `API_UPSTREAM` 的 `/api/`,海外 H5 直接使用既有 `/api/`;所有业务认证与仓绑定仍由统一 Gateway 和服务端校验。发布时先启动 PDA 容器,再验收两个路径的 HTML、资产、登录、权限与缺失资产 404。H5 通过不能替代 Android 真机扫码验收。
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
services:
|
||||
admin-web:
|
||||
image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: admin-web-development
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:9001:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"
|
||||
API_UPSTREAM: "http://firstleg-development-gateway:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
|
||||
networks:
|
||||
- development-backend
|
||||
- development-overseas-backend
|
||||
|
||||
networks:
|
||||
development-backend:
|
||||
external: true
|
||||
name: firstleg-development-network
|
||||
development-overseas-backend:
|
||||
external: true
|
||||
name: firstleg-development-overseas-network
|
||||
@@ -1,24 +1,23 @@
|
||||
name: firstleg-prod-admin-web
|
||||
|
||||
services:
|
||||
admin-web:
|
||||
image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
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:-9001}:80"
|
||||
- "127.0.0.1:${HOST_PORT:-29011}:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "https://app.buggoglobal.com/"
|
||||
API_UPSTREAM: "http://gateway-active:9000"
|
||||
OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
|
||||
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:
|
||||
- infra-network
|
||||
- routing-network
|
||||
|
||||
- backend
|
||||
networks:
|
||||
infra-network:
|
||||
backend:
|
||||
external: true
|
||||
name: firstleg-prod-infra-network
|
||||
routing-network:
|
||||
external: true
|
||||
name: firstleg-production-routing
|
||||
name: firstleg-production-network
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
name: firstleg-test-admin-web
|
||||
services:
|
||||
admin-web:
|
||||
build:
|
||||
@@ -6,16 +7,23 @@ services:
|
||||
args:
|
||||
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||
image: admin-web-test:local
|
||||
image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
container_name: admin-web-test
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
ports:
|
||||
- "${HOST_PORT:-9001}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||
environment:
|
||||
# Runtime URL used by the secure customer-workbench handoff. Each
|
||||
# environment may override it without rebuilding the Admin image.
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"
|
||||
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
|
||||
OVERSEAS_API_UPSTREAM: "${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` |
|
||||
@@ -6,6 +6,21 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Resolve independent PDA containers at request time; their restart must not block Admin startup.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
location = /pda-domestic { return 308 /pda-domestic/; }
|
||||
location = /pda-overseas { return 308 /pda-overseas/; }
|
||||
location ^~ /pda-domestic/ {
|
||||
set $pda_domestic_origin http://pda-domestic-h5-test:80;
|
||||
proxy_pass $pda_domestic_origin$request_uri;
|
||||
proxy_set_header Host $proxy_host;
|
||||
}
|
||||
location ^~ /pda-overseas/ {
|
||||
set $pda_overseas_origin http://pda-overseas-h5-test:80;
|
||||
proxy_pass $pda_overseas_origin$request_uri;
|
||||
proxy_set_header Host $proxy_host;
|
||||
}
|
||||
|
||||
# Docker 容器运行正常的检查地址,供 Jenkins 使用。
|
||||
location = /healthz {
|
||||
default_type text/plain;
|
||||
@@ -30,7 +45,7 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到国内 Gateway。
|
||||
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。
|
||||
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
|
||||
location /openapi/ {
|
||||
client_max_body_size 55m;
|
||||
@@ -42,11 +57,10 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
||||
location /overseas-api/ {
|
||||
# Domestic PDA keeps its H5 module-safe prefix; authorization still belongs to the same Gateway.
|
||||
location /gateway-api/ {
|
||||
client_max_body_size 55m;
|
||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
||||
proxy_pass ${API_UPSTREAM}/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -54,6 +68,7 @@ server {
|
||||
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,10 +5,11 @@
|
||||
"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:development": "node scripts/phase4-development-frontend-acceptance.mjs",
|
||||
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,10 +17,12 @@
|
||||
"@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": {
|
||||
|
||||
@@ -20,6 +20,9 @@ importers:
|
||||
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))
|
||||
@@ -32,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))
|
||||
@@ -641,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'}
|
||||
@@ -1150,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==}
|
||||
|
||||
@@ -1325,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:
|
||||
@@ -1406,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':
|
||||
@@ -1960,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
|
||||
@@ -2436,6 +2462,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
@@ -2563,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
|
||||
@@ -2658,3 +2691,7 @@ snapshots:
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -1,150 +1,58 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "用法: $0 <服务名> <镜像仓库> <镜像标签> <Compose 文件>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SERVICE="$1"
|
||||
IMAGE_REPOSITORY="$2"
|
||||
IMAGE_TAG="$3"
|
||||
COMPOSE_FILE="$4"
|
||||
IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG"
|
||||
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
|
||||
SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}"
|
||||
SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}"
|
||||
SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}"
|
||||
REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}"
|
||||
: "${REGISTRY_USER:?REGISTRY_USER 未设置}"
|
||||
: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}"
|
||||
|
||||
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 "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@"
|
||||
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 "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@"
|
||||
scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@"
|
||||
}
|
||||
|
||||
run_ssh install -d -m 750 "$REMOTE_DIR"
|
||||
REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml"
|
||||
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp"
|
||||
run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE'
|
||||
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
|
||||
compose_tmp="$1"
|
||||
compose_target="$2"
|
||||
chmod 644 "$compose_tmp"
|
||||
mv -f "$compose_tmp" "$compose_target"
|
||||
REMOTE_PREPARE
|
||||
|
||||
printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM
|
||||
run_ssh docker pull "$IMAGE"
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
remote_dir="$1"
|
||||
service="$2"
|
||||
image_repository="$3"
|
||||
image_tag="$4"
|
||||
compose_file="docker-compose.$service.yml"
|
||||
project_name="firstleg-prod-$service"
|
||||
cd "$remote_dir"
|
||||
export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag"
|
||||
|
||||
compose() {
|
||||
docker compose --project-name "$project_name" -f "$compose_file" "$@"
|
||||
}
|
||||
|
||||
case "$service" in
|
||||
admin-web)
|
||||
base_port=9001
|
||||
nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf
|
||||
legacy_container=firstleg-prod-admin-web
|
||||
;;
|
||||
client-web)
|
||||
base_port=9002
|
||||
nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf
|
||||
legacy_container=firstleg-prod-client-web
|
||||
;;
|
||||
*) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
state_root=/data/firstleg/deploy/blue-green-state
|
||||
state_file="$state_root/frontend-$service.active"
|
||||
routing_network=firstleg-production-routing
|
||||
mkdir -p "$state_root"
|
||||
chmod 750 "$state_root"
|
||||
docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null
|
||||
|
||||
for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do
|
||||
alias_name="${pair%%:*}"
|
||||
container="${pair#*:}"
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \
|
||||
|| docker network connect --alias "$alias_name" "$routing_network" "$container"
|
||||
fi
|
||||
done
|
||||
|
||||
active_slot=""
|
||||
active_container=""
|
||||
active_port=""
|
||||
if [ -s "$state_file" ]; then
|
||||
IFS='|' read -r active_slot active_container active_port active_image < "$state_file"
|
||||
elif docker inspect "$legacy_container" >/dev/null 2>&1; then
|
||||
active_slot=legacy
|
||||
active_container="$legacy_container"
|
||||
active_port="$base_port"
|
||||
fi
|
||||
|
||||
if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi
|
||||
[ "$active_slot" = legacy ] && target_slot=green
|
||||
if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi
|
||||
candidate="${legacy_container}-${target_slot}"
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
|
||||
if ! compose run -d --no-deps --name "$candidate" \
|
||||
-p "127.0.0.1:$target_port:80" "$service" >/dev/null; then
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
healthy=false
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt 120 ]; do
|
||||
if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
|
||||
healthy=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
done
|
||||
if [ "$healthy" != true ]; then
|
||||
docker logs --tail 100 "$candidate" >&2 || true
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
backup="$nginx_config.blue-green-backup"
|
||||
cp -f "$nginx_config" "$backup"
|
||||
if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \
|
||||
|| cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then
|
||||
cp -f "$backup" "$nginx_config"
|
||||
nginx -t && nginx -s reload || true
|
||||
docker rm -f "$candidate" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$backup"
|
||||
|
||||
if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
|
||||
docker stop --time 30 "$active_container" >/dev/null
|
||||
docker rm "$active_container" >/dev/null
|
||||
fi
|
||||
printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp"
|
||||
chmod 640 "$state_file.tmp"
|
||||
mv -f "$state_file.tmp" "$state_file"
|
||||
echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag"
|
||||
REMOTE_DEPLOY
|
||||
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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function option(name) {
|
||||
@@ -15,22 +14,13 @@ function normalizeOrigin(value, name) {
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function acceptancePassword() {
|
||||
if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
|
||||
const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env'
|
||||
if (!existsSync(file)) return ''
|
||||
const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m)
|
||||
return match?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
const environment = option('--environment')
|
||||
if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development')
|
||||
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway')
|
||||
const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway')
|
||||
const password = acceptancePassword()
|
||||
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件')
|
||||
if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test')
|
||||
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway')
|
||||
const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
|
||||
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD')
|
||||
|
||||
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], {
|
||||
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
@@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p
|
||||
ADMIN_PHASE4_LIVE: '1',
|
||||
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
|
||||
ADMIN_ACCEPTANCE_PASSWORD: password,
|
||||
ADMIN_DEVELOPMENT_GATEWAY: gateway,
|
||||
ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway,
|
||||
ADMIN_TEST_GATEWAY: gateway,
|
||||
VITE_API_BASE_URL: `${gateway}/api`,
|
||||
VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`,
|
||||
},
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
@@ -2,14 +2,14 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8')
|
||||
const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('development runner requires an explicit development environment and gateways', () => {
|
||||
test('test runner requires an explicit test environment and unified gateway', () => {
|
||||
assert.match(runner, /--environment/)
|
||||
assert.match(runner, /environment !== 'development'/)
|
||||
assert.match(runner, /environment !== 'test'/)
|
||||
assert.match(runner, /--gateway/)
|
||||
assert.match(runner, /--overseas-gateway/)
|
||||
assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/)
|
||||
assert.match(runner, /shell: process\.platform === 'win32'/)
|
||||
assert.match(runner, /if \(result\.error\) throw result\.error/)
|
||||
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
|
||||
@@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa
|
||||
|
||||
test('live suite uses production auth clients and domain adapters', () => {
|
||||
assert.match(suite, /useAuthStore/)
|
||||
assert.match(suite, /clearOverseasAccessToken/)
|
||||
assert.match(suite, /refreshAuthenticatedSession/)
|
||||
assert.match(suite, /createQuote/)
|
||||
assert.match(suite, /createUsTruckOrder/)
|
||||
assert.match(suite, /createLocalLooseboardOrder/)
|
||||
@@ -8,7 +8,7 @@ import { defineComponent, nextTick } from 'vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { refreshAuthenticatedSession } from '@/shared/api/http'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
|
||||
import {
|
||||
@@ -45,7 +45,7 @@ import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/Invent
|
||||
import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
|
||||
type Region = 'domestic' | 'overseas'
|
||||
type Region = 'unified'
|
||||
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
|
||||
interface CoverageGap { area: string; reason: string }
|
||||
|
||||
@@ -150,7 +150,7 @@ async function masterDataLifecycle() {
|
||||
}
|
||||
|
||||
async function cleanupTransport(path: string, customerId: string, version: number) {
|
||||
await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
|
||||
await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
|
||||
}
|
||||
async function overseasBusinessLifecycle() {
|
||||
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
|
||||
@@ -355,25 +355,25 @@ async function pageRequestCoverage() {
|
||||
{ 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/warehouse-admin/pallets')
|
||||
await exerciseListSearch(pallets, '/platform/v1/warehouse-admin/pallets',
|
||||
const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets')
|
||||
await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets',
|
||||
{ warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' },
|
||||
{ keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' })
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => {
|
||||
describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => {
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
|
||||
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
|
||||
installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas')
|
||||
installTracingAdapter(http, 'unified')
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(pinia)
|
||||
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
|
||||
clearOverseasAccessToken()
|
||||
await refreshAuthenticatedSession()
|
||||
await listVehicles({ page: 1, size: 1 })
|
||||
expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true)
|
||||
expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true)
|
||||
expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true)
|
||||
expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true)
|
||||
})
|
||||
it('runs marker-owned adapter lifecycles and real page requests', async () => {
|
||||
await masterDataLifecycle()
|
||||
@@ -388,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', (
|
||||
const reportDir = resolve(process.cwd(), 'reports')
|
||||
mkdirSync(reportDir, { recursive: true })
|
||||
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
|
||||
const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
|
||||
const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
|
||||
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
set +x
|
||||
export DOCKER_CONFIG
|
||||
DOCKER_CONFIG=$(mktemp -d)
|
||||
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
docker login -u "$1" --password-stdin "$2"
|
||||
docker pull "$3"
|
||||
@@ -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+/)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, markRaw } from 'vue'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ElMenu, ElMenuItem, ElSubMenu } from 'element-plus'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||
|
||||
@@ -63,10 +66,12 @@ describe('AdminSidebarMenu', () => {
|
||||
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')
|
||||
@@ -83,6 +88,72 @@ describe('AdminSidebarMenu', () => {
|
||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||
})
|
||||
|
||||
it('fits collapsed icons inside the scrolling rail and keeps hover submenus outside it', async () => {
|
||||
const host = document.createElement('div')
|
||||
host.className = 'app-shell is-collapsed'
|
||||
host.innerHTML = '<aside class="app-sidebar"><nav class="side-nav"></nav></aside>'
|
||||
document.body.append(host)
|
||||
const style = document.createElement('style')
|
||||
style.textContent = readFileSync(resolve(process.cwd(), 'src/styles/shell.css'), 'utf8')
|
||||
document.head.append(style)
|
||||
const rail = host.querySelector<HTMLElement>('.side-nav')!
|
||||
const router = routerPlugin()
|
||||
await router.push('/')
|
||||
const icon = markRaw(adminNavigation[0].icon)
|
||||
const items: AdminNavigationNode[] = [
|
||||
{ key: 'home', title: '首页', icon, path: '/' },
|
||||
{
|
||||
key: 'resources', title: '资源管理', icon,
|
||||
children: [{ key: 'warehouses', title: '仓库管理', icon, path: '/warehouses' }],
|
||||
},
|
||||
]
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
attachTo: rail,
|
||||
props: { items, activePath: '/', collapsed: true },
|
||||
global: { plugins: [router], components: { ElMenu, ElMenuItem, ElSubMenu }, stubs: { transition: false } },
|
||||
})
|
||||
try {
|
||||
await flushPromises()
|
||||
const menu = wrapper.get('.side-nav-menu.el-menu--collapse')
|
||||
const group = wrapper.get('.side-nav-menu > .el-sub-menu')
|
||||
const leafTrigger = wrapper.get('.el-menu-tooltip__trigger')
|
||||
expect(getComputedStyle(host.querySelector('.app-sidebar')!).width).toBe('76px')
|
||||
expect(getComputedStyle(rail).overflowX).toBe('hidden')
|
||||
expect(getComputedStyle(rail).overflowY).toBe('auto')
|
||||
expect(getComputedStyle(menu.element).width).toBe('100%')
|
||||
for (const trigger of [leafTrigger, group.get('.el-sub-menu__title')]) {
|
||||
expect(getComputedStyle(trigger.element).paddingLeft).toBe('0px')
|
||||
expect(getComputedStyle(trigger.element).paddingRight).toBe('0px')
|
||||
expect(getComputedStyle(trigger.element).justifyContent).toBe('center')
|
||||
expect(getComputedStyle(trigger.get('.menu-icon').element).marginRight).toBe('0px')
|
||||
}
|
||||
|
||||
await group.trigger('mouseenter')
|
||||
await vi.waitFor(() => expect(group.attributes('aria-expanded')).toBe('true'))
|
||||
const popup = document.body.querySelector('.shell-nav-popper .el-menu--popup')
|
||||
expect(popup).not.toBeNull()
|
||||
expect(popup?.closest('.side-nav')).toBeNull()
|
||||
expect(popup?.textContent).toContain('仓库管理')
|
||||
|
||||
const destination = popup!.querySelector<HTMLElement>('.el-menu-item')!
|
||||
destination.focus()
|
||||
expect(document.activeElement).toBe(destination)
|
||||
expect(destination.getAttribute('role')).toBe('menuitem')
|
||||
destination.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||
|
||||
await wrapper.setProps({ collapsed: false })
|
||||
host.classList.remove('is-collapsed')
|
||||
expect(getComputedStyle(host.querySelector('.app-sidebar')!).width).toBe('240px')
|
||||
expect(wrapper.find('.el-menu--collapse').exists()).toBe(false)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
style.remove()
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders only entries included in the filtered navigation input', () => {
|
||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-sub-menu v-if="node.children?.length" :index="node.key">
|
||||
<el-sub-menu v-if="node.children?.length" :index="node.key" popper-class="admin-workbench shell-nav-popper">
|
||||
<template #title>
|
||||
<component
|
||||
v-if="!level"
|
||||
|
||||
@@ -44,6 +44,17 @@ describe('WorkspaceTabs', () => {
|
||||
})
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
it('retains its own theme on the teleported management menu', async () => {
|
||||
const { wrapper } = await mountTabs()
|
||||
await wrapper.get('[aria-label="页签管理"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const popper = document.body.querySelector('.shell-header-popper')
|
||||
expect(popper?.classList.contains('admin-workbench')).toBe(true)
|
||||
expect(popper?.closest('.workspace-tabs')).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a dirty tab open when discard confirmation is cancelled', async () => {
|
||||
const { wrapper, workspace, unsaved } = await mountTabs()
|
||||
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -125,7 +126,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace-tabs" aria-label="页面工作区">
|
||||
<section class="workspace-tabs admin-workbench" aria-label="页面工作区">
|
||||
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
|
||||
<div
|
||||
v-for="(tab, index) in workspace.tabs"
|
||||
@@ -156,7 +157,7 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
|
||||
<el-dropdown trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper" @command="handleCommand">
|
||||
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
|
||||
<MoreHorizontal :size="18" aria-hidden="true" />
|
||||
</el-button>
|
||||
@@ -181,9 +182,8 @@ watch(
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--cc-border);
|
||||
background: #f7f9fc;
|
||||
box-shadow: 0 5px 14px rgba(23, 42, 70, .045);
|
||||
border-bottom: 1px solid var(--workbench-border);
|
||||
background: var(--workbench-surface);
|
||||
}
|
||||
.workspace-tab-list {
|
||||
display: flex;
|
||||
@@ -208,10 +208,10 @@ watch(
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: #526178;
|
||||
color: var(--workbench-muted);
|
||||
}
|
||||
.workspace-tab:hover { background: #eef3fa; color: #24344d; }
|
||||
.workspace-tab.is-active { border-color: var(--cc-border); background: #fff; color: #172033; }
|
||||
.workspace-tab:hover { background: var(--workbench-surface-soft); color: var(--workbench-text); }
|
||||
.workspace-tab.is-active { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
|
||||
.workspace-tab-activate {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -239,19 +239,19 @@ watch(
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #7b8799;
|
||||
color: var(--workbench-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workspace-tab-close:hover { background: #dde6f2; color: #26364d; }
|
||||
.workspace-tab-close:hover { background: var(--workbench-accent-border); color: var(--workbench-primary); }
|
||||
.workspace-tab-activate:focus-visible,
|
||||
.workspace-tab-close:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||||
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--cc-border); color: #526178; }
|
||||
.workspace-tab-close:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: -2px; }
|
||||
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--workbench-border); color: var(--workbench-muted); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.workspace-tabs { top: 64px; height: 48px; }
|
||||
.workspace-tab-list { align-items: center; padding: 4px 6px; }
|
||||
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
|
||||
.workspace-tab.is-active { border-color: #cbd7e7; }
|
||||
.workspace-tab.is-active { border-color: var(--workbench-accent-border); }
|
||||
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
|
||||
.workspace-tab-more { height: 48px; }
|
||||
}
|
||||
|
||||
@@ -114,7 +114,8 @@ describe('AppLayout', () => {
|
||||
|
||||
it('shows the authenticated identity and the localhost full-access control', async () => {
|
||||
const { wrapper } = await mountLayout()
|
||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
||||
expect(wrapper.get('.user-copy').text()).toBe('平台管理员(admin)')
|
||||
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
|
||||
const accessToggle = wrapper.get('[role="switch"]')
|
||||
expect(accessToggle.text()).toContain('本地全权限')
|
||||
expect(accessToggle.text()).toContain('已关闭')
|
||||
@@ -130,10 +131,29 @@ describe('AppLayout', () => {
|
||||
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('avoids repeating identical display names and accounts while retaining the full accessible identity', async () => {
|
||||
const { wrapper } = await mountLayout()
|
||||
const auth = useAuthStore()
|
||||
auth.user = { ...auth.user!, name: 'admin', account: 'admin' }
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.get('.user-copy').text()).toBe('admin')
|
||||
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:admin(admin)')
|
||||
expect(wrapper.get('.user-copy').attributes('title')).toBe('登录身份:admin(admin)')
|
||||
})
|
||||
|
||||
it('limits the shell theme to navigation and header instead of inheriting it into routed pages', async () => {
|
||||
const { wrapper } = await mountLayout('/customers')
|
||||
|
||||
expect(wrapper.get('.app-sidebar').classes()).toContain('admin-workbench')
|
||||
expect(wrapper.get('.app-header').classes()).toContain('admin-workbench')
|
||||
expect(wrapper.get('.cached-count').element.closest('.admin-workbench')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the shell heading when the route opts out', async () => {
|
||||
const { wrapper } = await mountLayout('/customers')
|
||||
expect(wrapper.find('.page-heading').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
||||
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
|
||||
})
|
||||
|
||||
it('logs out after confirmation', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -26,9 +27,12 @@ const collapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const isMobileViewport = ref(false)
|
||||
let mobileViewportMedia: MediaQueryList | undefined
|
||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
||||
const userIdentity = computed(() => {
|
||||
const name = auth.user?.name, account = auth.user?.account
|
||||
return name && account && name !== account ? `${name}(${account})` : name || account || '—'
|
||||
})
|
||||
const userIdentityLabel = computed(() => `登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`)
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
const menuButtonLabel = computed(() => {
|
||||
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
|
||||
@@ -139,7 +143,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
|
||||
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
||||
<aside class="app-sidebar">
|
||||
<aside class="app-sidebar admin-workbench">
|
||||
<div class="brand-block">
|
||||
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
|
||||
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
|
||||
@@ -150,7 +154,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
|
||||
<header class="app-header admin-workbench" :class="{ 'app-header--without-page-heading': !showPageHeading }">
|
||||
<button
|
||||
class="shell-menu-button"
|
||||
type="button"
|
||||
@@ -158,13 +162,12 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
:aria-expanded="menuExpanded"
|
||||
@click="handleMenuToggle"
|
||||
><Menu :size="22" /></button>
|
||||
<div v-if="showPageHeading" class="page-heading">
|
||||
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
|
||||
</div>
|
||||
<div v-if="showPageHeading" class="page-heading" aria-hidden="true" />
|
||||
<div class="header-user">
|
||||
<button
|
||||
v-if="access.localFullAccessAvailable"
|
||||
class="local-full-access-toggle"
|
||||
class="local-full-access-toggle workbench-tone"
|
||||
:data-tone="access.localFullAccessEnabled ? 'green' : 'blue'"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="access.localFullAccessEnabled"
|
||||
@@ -175,7 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
<span>本地全权限</span>
|
||||
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
|
||||
</button>
|
||||
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
|
||||
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper">
|
||||
<button class="notification-trigger" type="button" aria-label="消息中心">
|
||||
<Bell :size="18" /><span>消息中心</span>
|
||||
</button>
|
||||
@@ -186,7 +189,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||||
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
|
||||
<div class="user-copy" :aria-label="userIdentityLabel" :title="userIdentityLabel"><strong>{{ userIdentity }}</strong></div>
|
||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -209,14 +212,14 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
|
||||
<style scoped>
|
||||
.app-header--without-page-heading .header-user { margin-left: auto; }
|
||||
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
|
||||
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
|
||||
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
|
||||
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
|
||||
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
|
||||
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
|
||||
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
|
||||
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
|
||||
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid var(--workbench-border); border-radius: 10px; background: var(--workbench-surface); color: var(--workbench-muted); font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
|
||||
.local-full-access-toggle:hover { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); }
|
||||
.local-full-access-toggle:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
|
||||
.local-full-access-toggle[aria-checked="true"] { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-accent); }
|
||||
.local-full-access-toggle strong { color: inherit; font-size: 12px; }
|
||||
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid var(--workbench-border); border-radius: 10px; background: var(--workbench-surface); color: var(--workbench-text); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
|
||||
.notification-trigger:hover { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
|
||||
.notification-trigger:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
|
||||
.notification-trigger > svg { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import { computed } from 'vue'
|
||||
import { ShieldX } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -16,7 +17,7 @@ async function returnToAccessiblePage() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="forbidden-page" aria-labelledby="forbidden-title">
|
||||
<section class="forbidden-page admin-workbench" aria-labelledby="forbidden-title">
|
||||
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
|
||||
<p class="section-kicker">403 · 权限不足</p>
|
||||
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
|
||||
|
||||
@@ -32,6 +32,9 @@ 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(),
|
||||
@@ -77,6 +80,9 @@ const router = createRouter({
|
||||
...downloadRoutes,
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
...appointmentCalendarRoutes,
|
||||
...selfOperatedRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
|
||||
@@ -60,13 +60,11 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
|
||||
expiresIn: 1800,
|
||||
refreshToken,
|
||||
refreshExpiresIn: 604800,
|
||||
overseasAccessToken: 'overseas-token',
|
||||
overseasExpiresIn: 900,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function domesticOnlyLoginResponse() {
|
||||
function unifiedLoginResponse() {
|
||||
return {
|
||||
code: '200',
|
||||
message: 'success',
|
||||
@@ -91,8 +89,8 @@ describe('admin auth session', () => {
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
||||
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
||||
it('accepts the unified access and refresh token response', async () => {
|
||||
mocks.post.mockResolvedValue(unifiedLoginResponse())
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -116,8 +114,6 @@ describe('admin auth session', () => {
|
||||
expiresIn: '7200',
|
||||
refreshToken: 'refresh-serialized',
|
||||
refreshExpiresIn: '604800',
|
||||
overseasAccessToken: null,
|
||||
overseasExpiresIn: '0',
|
||||
},
|
||||
})
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
@@ -245,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) {
|
||||
@@ -254,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,75 +8,56 @@ 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('keeps outsourced read access separate from owned access and write-only permissions', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
|
||||
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
|
||||
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
|
||||
})
|
||||
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 === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
|
||||
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(58)
|
||||
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', () => {
|
||||
@@ -96,7 +77,7 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/container-loading/overseas')
|
||||
expect(paths).not.toContain('/container-loading/smart-loading')
|
||||
expect(paths).toContain('/loading-plans')
|
||||
expect(paths).not.toContain('/loading-lists')
|
||||
expect(paths).toContain('/loading-lists')
|
||||
expect(paths).not.toContain('/port-schedules')
|
||||
expect(paths).not.toContain('/customs')
|
||||
expect(paths).not.toContain('/outbound-dispatches')
|
||||
@@ -117,7 +98,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)
|
||||
})
|
||||
|
||||
@@ -169,7 +150,6 @@ describe('adminNavigation', () => {
|
||||
'/overseas-warehouse/outbounds',
|
||||
'/overseas-warehouse/delivery-orders',
|
||||
'/overseas-warehouse/exception-orders',
|
||||
'/overseas-warehouse/customer-visits',
|
||||
'/overseas-warehouse/relabel-orders',
|
||||
'/overseas-warehouse/pallets',
|
||||
'/overseas-warehouse/inventory',
|
||||
@@ -209,12 +189,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')
|
||||
@@ -234,12 +212,15 @@ 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 split container workbenches without a standalone smart-loading menu', () => {
|
||||
@@ -252,9 +233,9 @@ describe('adminNavigation', () => {
|
||||
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||
'/dashboard',
|
||||
'/loading-plans',
|
||||
'/container-loading/domestic',
|
||||
'/container-loading/overseas',
|
||||
'/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,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
PrototypeOverseasWarehouseIcon,
|
||||
PrototypeSalesIcon,
|
||||
PrototypeSystemIcon,
|
||||
PrototypeVehicleIcon,
|
||||
} from './prototypeMenuIcons'
|
||||
|
||||
export interface AdminNavigationNode {
|
||||
@@ -60,16 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
},
|
||||
{
|
||||
key: 'domestic-warehouse',
|
||||
title: '国内仓',
|
||||
title: '国内头程',
|
||||
icon: PrototypeDomesticWarehouseIcon,
|
||||
children: [
|
||||
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
||||
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
|
||||
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
|
||||
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
||||
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
|
||||
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
|
||||
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
|
||||
{ key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -91,8 +94,50 @@ 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: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' },
|
||||
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage: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-outsourced-business', title: '外配任务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,33 +148,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: '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: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
|
||||
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
|
||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
||||
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
|
||||
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
||||
{ key: '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: '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: 'customer-service',
|
||||
title: '客户服务',
|
||||
icon: ContactRound,
|
||||
icon: PrototypeCustomerServiceIcon,
|
||||
children: [
|
||||
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
|
||||
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -155,23 +183,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: '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: '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: 'mojia-sku-sync', title: 'SKU同步', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync: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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -184,7 +222,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' },
|
||||
{ key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO
|
||||
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [
|
||||
h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }),
|
||||
h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
|
||||
h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
|
||||
])
|
||||
|
||||
export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [
|
||||
h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
|
||||
h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
])
|
||||
|
||||
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
|
||||
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
|
||||
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8'))
|
||||
.replaceAll('\r\n', '\n')
|
||||
|
||||
test('production frontend starts and validates the candidate before switching nginx', () => {
|
||||
const start = script.indexOf('compose run -d --no-deps --name "$candidate"')
|
||||
const health = script.indexOf('docker exec "$candidate" wget')
|
||||
const nginx = script.indexOf('nginx -t || ! nginx -s reload')
|
||||
const stop = script.indexOf('docker stop --time 30 "$active_container"')
|
||||
const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"')
|
||||
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state)
|
||||
})
|
||||
|
||||
test('production frontend supports both slots and restores nginx on failure', () => {
|
||||
assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/)
|
||||
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
|
||||
assert.match(script, /00-blue-green-admin-web\.conf/)
|
||||
assert.match(script, /00-blue-green-client-web\.conf/)
|
||||
assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/)
|
||||
assert.match(script, /cp -f "\$nginx_config" "\$backup"/)
|
||||
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
|
||||
assert.match(script, /docker rm -f "\$candidate"/)
|
||||
assert.match(script, /gateway-active:firstleg-production-gateway/)
|
||||
})
|
||||
@@ -1,85 +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 productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8')
|
||||
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
|
||||
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
|
||||
const root = process.cwd()
|
||||
const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8')
|
||||
const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8')
|
||||
const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8')
|
||||
const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8')
|
||||
|
||||
describe('admin nginx deployment config', () => {
|
||||
it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => {
|
||||
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(3)
|
||||
expect(config).not.toContain('client_max_body_size 12m;')
|
||||
expect(config).not.toContain('client_max_body_size 0;')
|
||||
})
|
||||
test('all business traffic uses one Gateway without rewriting API paths', () => {
|
||||
assert.match(config, /location \/api\//)
|
||||
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
|
||||
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
|
||||
assert.match(config, /client_max_body_size 55m;/)
|
||||
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
||||
assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
|
||||
})
|
||||
|
||||
it('prevents the application shell from retaining stale chunk references', () => {
|
||||
expect(config).toContain('location = /index.html')
|
||||
expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"')
|
||||
})
|
||||
test('test deployment requires an explicit shared network, Gateway and version', () => {
|
||||
for (const key of ['IMAGE_TAG']) {
|
||||
assert.ok(compose.includes('${' + key + ':?'))
|
||||
}
|
||||
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
|
||||
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
|
||||
assert.match(compose, /external: true/)
|
||||
assert.match(compose, /127\.0\.0\.1:/)
|
||||
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
|
||||
assert.match(jenkins, /'java-builder' : 'java-test'/)
|
||||
assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
|
||||
assert.match(jenkins, /docker network inspect/)
|
||||
assert.match(jenkins, /scripts\/deploy-production\.sh/)
|
||||
assert.match(jenkins, /firstleg-production-ssh/)
|
||||
|
||||
it('serves hashed assets immutably and never falls back missing chunks to HTML', () => {
|
||||
expect(config).toContain('location /assets/')
|
||||
expect(config).toContain('try_files $uri =404;')
|
||||
expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Vue history routes on the application shell without caching them', () => {
|
||||
expect(config).toContain('try_files $uri $uri/ /index.html;')
|
||||
expect(config).toContain('Cache-Control "no-cache"')
|
||||
})
|
||||
test('application shell is revalidated and missing chunks remain 404', () => {
|
||||
assert.match(config, /location = \/index\.html/)
|
||||
assert.match(config, /no-store/)
|
||||
assert.match(config, /max-age=31536000, immutable/)
|
||||
assert.match(config, /try_files \$uri =404;/)
|
||||
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
|
||||
})
|
||||
|
||||
it('proxies overseas API requests to the gateway API prefix', () => {
|
||||
expect(config).toContain('location /overseas-api/')
|
||||
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
|
||||
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
|
||||
})
|
||||
test('PDA static apps keep independent paths and share the authenticated Gateway', () => {
|
||||
assert.match(config, /resolver 127\.0\.0\.11 valid=10s ipv6=off;/)
|
||||
for (const app of ['domestic', 'overseas']) {
|
||||
assert.ok(config.includes(`location = /pda-${app} { return 308 /pda-${app}/; }`))
|
||||
assert.ok(config.includes(`location ^~ /pda-${app}/ {`))
|
||||
assert.ok(config.includes(`set $pda_${app}_origin http://pda-${app}-h5-test:80;`))
|
||||
assert.ok(config.includes(`proxy_pass $pda_${app}_origin$request_uri;`))
|
||||
}
|
||||
assert.match(config, /location \/gateway-api\/\s*\{[^}]*client_max_body_size 55m;[^}]*proxy_pass \$\{API_UPSTREAM\}\/api\//)
|
||||
assert.doesNotMatch(config, /proxy_pass.*(?:auth:|lastleg-service:|firstleg-service:)/)
|
||||
})
|
||||
|
||||
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
||||
expect(testCompose).toContain('- "host.docker.internal:host-gateway"')
|
||||
expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"')
|
||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"')
|
||||
expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com')
|
||||
expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com')
|
||||
expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"')
|
||||
expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
|
||||
expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
|
||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
|
||||
expect(config).toContain('proxy_set_header Host $proxy_host;')
|
||||
})
|
||||
|
||||
it('keeps local environments on target nodes and uses credential-managed SSH for production', () => {
|
||||
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'")
|
||||
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
|
||||
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
|
||||
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
|
||||
expect(jenkinsfile).not.toContain('docker save')
|
||||
expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'")
|
||||
expect(jenkinsfile).toContain('git rev-parse origin/main')
|
||||
expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80')
|
||||
expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"')
|
||||
expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"')
|
||||
expect(productionCompose).toContain('name: firstleg-production-routing')
|
||||
expect(productionCompose).toContain('name: firstleg-prod-infra-network')
|
||||
})
|
||||
test('container build excludes local environment values and waits for Nginx health', () => {
|
||||
const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8')
|
||||
assert.match(ignore, /^\.env$/m)
|
||||
assert.match(ignore, /^\.env\.\*$/m)
|
||||
assert.match(compose, /healthcheck:/)
|
||||
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
|
||||
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
|
||||
})
|
||||
|
||||
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
|
||||
expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine')
|
||||
expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine')
|
||||
expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build')
|
||||
expect(dockerfile).toContain('FROM ${NGINX_IMAGE}')
|
||||
expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'")
|
||||
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
|
||||
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
|
||||
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
|
||||
expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
|
||||
})
|
||||
|
||||
it('proxies Mojia measurement OpenAPI requests to the domestic gateway', () => {
|
||||
expect(config).toContain('location /openapi/')
|
||||
expect(config).toContain('location /openapi/ {\n client_max_body_size 55m;\n proxy_pass ${API_UPSTREAM};')
|
||||
})
|
||||
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/)
|
||||
})
|
||||
|
||||
@@ -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,185 @@
|
||||
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
|
||||
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: {
|
||||
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||
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[data-picker-kind="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,135 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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 class="density-form appointment-native-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}分出箱数`" class="workbench-input"></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()" class="workbench-input"></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' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" class="workbench-input" /></label>
|
||||
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></div></form>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--workbench-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,149 @@
|
||||
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)
|
||||
const picker = dialog.getComponent({ name: 'ElDatePicker' })
|
||||
expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' })
|
||||
expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px')
|
||||
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(picker.props('modelValue')).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') === '调整计划到仓')!
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||
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('clears the Element arrival picker without saving an empty required time', 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') === '调整计划到仓')!
|
||||
const picker = dialog.getComponent({ name: 'ElDatePicker' })
|
||||
expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false)
|
||||
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
picker.vm.$emit('update:modelValue', null); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
expect(saveWarehouseArrival).not.toHaveBeenCalled()
|
||||
})
|
||||
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,114 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||
import { projectTripCalendar, type TripCalendarEntry } from './tripCalendar'
|
||||
|
||||
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[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||
function reportedDispatch() {
|
||||
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
|
||||
}
|
||||
describe('four-type appointment calendar', () => {
|
||||
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||
const wrapper = render(reportedDispatch(), '2026-10-23')
|
||||
const search = wrapper.get('[aria-label="搜索当前日历"]')
|
||||
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
|
||||
await search.setValue(keyword)
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无安排')
|
||||
expect(wrapper.get('[data-date="2026-10-23"]').findAll('.event-chip')).toHaveLength(0)
|
||||
expect(wrapper.get('[data-date="2026-10-22"]').findAll('.event-chip')).toHaveLength(1)
|
||||
await wrapper.get('[data-date="2026-10-22"]').trigger('click')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('09:00')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
|
||||
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not match an unrelated container and can find the same event by the dispatch number', async () => {
|
||||
const wrapper = render(reportedDispatch(), '2026-10-22')
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('UNRELATED9999')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||
await wrapper.get('[aria-label="搜索当前日历"]').setValue('PC-CALENDAR-001')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.event-chip')).toHaveLength(1)
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
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,119 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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 admin-workbench" :aria-busy="loading">
|
||||
<div class="calendar-main workbench-panel is-flush">
|
||||
<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 workbench-tabs" 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 class="workbench-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 workbench-panel is-flush" 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 { 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 var(--workbench-border); }
|
||||
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.calendar-label > svg { color: var(--workbench-primary); }.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 var(--workbench-border); }
|
||||
.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 { width: 190px; }
|
||||
.view-toggle { display: flex; }
|
||||
.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; }
|
||||
.undated-button[aria-pressed='true'] { background: var(--workbench-surface); color: var(--workbench-primary); font-weight: 600; }.nav-button { border: 1px solid var(--workbench-border); 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: var(--workbench-muted); background: var(--workbench-surface-soft); padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
|
||||
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: var(--workbench-surface-soft); }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: 8px; background: var(--workbench-surface); text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||
.calendar-day.outside { background: var(--workbench-surface-soft); color: var(--workbench-muted); }.calendar-day:hover { border-color: var(--workbench-accent-border); }.calendar-day.selected { border-color: var(--workbench-primary); background: var(--workbench-accent-soft); }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid var(--workbench-primary); 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 var(--workbench-border); }.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: var(--workbench-primary); }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid var(--workbench-border); 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 var(--workbench-border); 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,185 @@
|
||||
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
|
||||
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, type TripCalendarEntry } 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 }
|
||||
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||
}
|
||||
export function renderSchedule() {
|
||||
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||
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[data-temporal-picker]').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: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], 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()
|
||||
})
|
||||
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => {
|
||||
const good = dispatchEntry()
|
||||
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const dispatches = (panel.props('events') as ScheduleEvent[]).filter(event => event.type === 'dispatch')
|
||||
expect(dispatches).toHaveLength(1)
|
||||
expect(dispatches[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00' })
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
const notice = wrapper.get('.data-notice').text()
|
||||
expect(notice).toContain('1 条排车未能定位到日历日期')
|
||||
expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实')
|
||||
expect(notice).toContain('无法确定当地日期')
|
||||
expect(notice).not.toContain('PC-CALENDAR-001')
|
||||
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
|
||||
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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, tripIssues } 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) {
|
||||
const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';')
|
||||
notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`)
|
||||
}
|
||||
} 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 admin-workbench is-dialog' }) }
|
||||
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 admin-workbench" 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 admin-workbench is-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 admin-workbench is-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="150px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></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 admin-workbench is-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,98 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
|
||||
import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
|
||||
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||
|
||||
const access = vi.hoisted(() => ({ read: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
|
||||
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
|
||||
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
|
||||
function render(active = true, disabled = false) {
|
||||
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
|
||||
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
|
||||
InlineHelp: { template: '<span />' }, ApiErrorMeta: true,
|
||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试核对</button></div>' },
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.read = true
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
|
||||
vi.mocked(getCargoVerification).mockResolvedValue(context)
|
||||
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
|
||||
})
|
||||
it('names the missing material and supplements the canonical booking inside the appointment', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
|
||||
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
|
||||
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
|
||||
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
|
||||
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||
})
|
||||
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
expect(resolvePlanningOrder).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ active: true }); await flushPromises()
|
||||
expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||
})
|
||||
it('does not ask for known materials to be entered again', async () => {
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||
})
|
||||
it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
|
||||
await wrapper.setProps({ active: false })
|
||||
expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
|
||||
expect(await wrapper.vm.canLeave()).toBe(false)
|
||||
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(await wrapper.vm.canLeave()).toBe(false)
|
||||
const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
|
||||
})
|
||||
it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
|
||||
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
|
||||
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
|
||||
})
|
||||
it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
|
||||
vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.find('[role=alert]').exists()).toBe(true)
|
||||
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
|
||||
await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
|
||||
expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
|
||||
vi.clearAllMocks(); access.read = false
|
||||
const denied = render(); await flushPromises()
|
||||
expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
|
||||
})
|
||||
it('keeps the supplement action disabled for read-only appointment views', async () => {
|
||||
const wrapper = render(true, true); await flushPromises()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
|
||||
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
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 CargoVerificationForm from './CargoVerificationForm.vue'
|
||||
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||
import { resolvePlanningOrder, resolvePlanningSource, type PlanningSourceLookup, type ResolvedPlanningSource } from './planningApi'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
|
||||
const props = defineProps<{ sources: PlanningSourceLookup[]; active: boolean; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ ready: [value: boolean] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
type Row = { resolved: ResolvedPlanningSource; cargo: CargoVerificationContext }
|
||||
const rows = ref<Row[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const editing = ref<string | null>(null), editor = ref<InstanceType<typeof CargoVerificationForm> | null>(null)
|
||||
const editingRow = computed(() => rows.value.find(row => key(row) === editing.value))
|
||||
let generation = 0
|
||||
const guards = new Set<LeaveGuard>()
|
||||
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
|
||||
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 }
|
||||
const unregister = register?.(canLeave)
|
||||
defineExpose({ canLeave })
|
||||
function key(row: Row) { return `${row.resolved.source.customerId}:${row.resolved.source.sourceKey}` }
|
||||
function issues(row: Row) {
|
||||
const missing: string[] = []
|
||||
if (!row.resolved.source.orderId) missing.push('尚未来货建单,请先在原来货登记完成实收关联')
|
||||
if (row.cargo.effectivePackages == null || row.cargo.effectivePackages <= 0) missing.push('缺少本票箱数')
|
||||
if (row.resolved.coverage.missingFields.includes('FBA_SHIPMENT_REFERENCES')) missing.push('缺少本票 FBA 货件号或追踪号(至少一种)')
|
||||
if (row.cargo.sourceChanged) missing.push('原货物资料已变化,请重新核对箱数和货件号')
|
||||
return missing
|
||||
}
|
||||
const ready = computed(() => rows.value.length === props.sources.length && rows.value.length > 0 && !loading.value && !error.value && !editing.value && rows.value.every(row => !issues(row).length))
|
||||
watch(ready, value => emit('ready', value), { immediate: true })
|
||||
async function load() {
|
||||
const current = ++generation
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||
error.value = { message: '无法读取本票核实资料,请联系有预约列表和尾程订单查看权限的同事补齐。' }; return
|
||||
}
|
||||
const result: Row[] = []
|
||||
// 只核对本次明确选择的货物,并由服务端解析原 ORDER / BOOKING 身份。
|
||||
for (const lookup of props.sources) {
|
||||
const resolved = 'sourceKey' in lookup ? await resolvePlanningSource(lookup) : await resolvePlanningOrder(lookup)
|
||||
if (current !== generation) return
|
||||
const cargo = await getCargoVerification(resolved.source)
|
||||
if (current !== generation) return
|
||||
result.push({ resolved, cargo })
|
||||
}
|
||||
if (current === generation) rows.value = result
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '货物缺项读取失败,请重试核对') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
async function closeEditor() {
|
||||
if (editor.value && !await editor.value.canLeave()) return
|
||||
editing.value = null
|
||||
}
|
||||
async function saved() { editing.value = null; await load() }
|
||||
watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) => { if (active && !editing.value) void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||
</script>
|
||||
<template>
|
||||
<section v-show="active || editing" class="density-dialog-section" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
|
||||
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
|
||||
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
|
||||
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
|
||||
<tr v-for="row in rows" :key="key(row)">
|
||||
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
|
||||
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
|
||||
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
|
||||
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
<div v-if="editingRow">
|
||||
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
|
||||
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
|
||||
</div>
|
||||
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
|
||||
</section>
|
||||
</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,161 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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: 260, singleLine: false, 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="选择预约货物">
|
||||
<div class="workbench-table"><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></div>
|
||||
</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,80 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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; confirmation?: boolean; disabled?: boolean }>()
|
||||
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)
|
||||
const canLeave = useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
|
||||
defineExpose({ canLeave })
|
||||
const canWrite = computed(() => !props.disabled && 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: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), 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 (props.confirmation && (count === null || !references.length)) { ElMessage.warning('请填写本票箱数,以及至少一个 FBA 货件号或追踪号;外部约号不能代替货件号'); return }
|
||||
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>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
|
||||
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
|
||||
<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><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></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 appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
|
||||
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
|
||||
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
|
||||
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></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>
|
||||
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canWrite || loading || !!error">{{ pending ? '重试本次保存' : '保存核实资料' }}</el-button></div></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,285 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-21 排车候选分页
|
||||
|
||||
| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 |
|
||||
|
||||
## 2026-10-20 日期时间组件统一(当前确认)
|
||||
|
||||
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
|
||||
|
||||
| 页面/组件 | 操作与承载 | 本次调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 真实浏览器检查通过:日期面板、选值、空值禁用、全屏保值、未保存保护及放弃返回;未提交业务保存 |
|
||||
| WorkReceivingPanel | 原业务深链中的客户计划、后送与跟进,原表单 | 5处日期/日期时间输入;日期与秒精度的当地时间分别保存;调整开始/结束时段后仍清除原时差依据 | 自动检查通过;待人工截图确认 |
|
||||
| WorkCommandForm | 跟进、收货要求,原表单 | 3处日期、2处时间输入;跨日结束日期显式保留;不推断次日、不转换地点时区 | 自动检查通过;待人工截图确认 |
|
||||
| AppointmentBatchForm | 原分批/合并表单 | 2处可空目标日期,保留跨批分配、版本、幂等与原写权限 | 自动检查通过;待人工截图确认 |
|
||||
|
||||
共4组件13处。日期使用 `el-date-picker`,时间使用 `el-time-picker`;保留原权限、处理中及未知结果锁定、清空与未保存保护。新增控件显式接收原禁用条件,宽度复用 `density-form`,不新增全局日期控件外观规则。
|
||||
|
||||
自动验证:受影响7个测试文件88项通过;类型检查与生产构建通过。全量回归执行464个文件4203项,4201项通过,两项旧公共样式断言(标签宽度、追加样式类)更新后,该样式文件65项全部复验通过。后台业务模板原生日期/时间控件残留检查通过。本轮未部署TEST,未进行真实HTTP或人工页面截图验收。
|
||||
|
||||
补充浏览器验收:用户明确要求打开页面后,使用已发布TEST页面,页面报告1920×1080、DPR1、visual scale1。TSTU5446539的默认计划、Element日期面板、快捷选值、全屏保值、清空禁用、未保存保护与放弃返回通过;原计划仍2026-10-01,未点击保存。MSCU7264017现已有入库计划,按冻结时间显示且无调整入口。本轮仅覆盖日历与调整到仓弹框,其余三表单仍待逐页操作;输入09:30的最终确认未单独完成,不计通过。证据见 `.logs/appointment-element-release-20261020/ui/result.json` 及同目录截图。
|
||||
|
||||
## 2026-10-20 蓝白工作台续批(写权已协调)
|
||||
|
||||
本次续批修改文件(相对本模块):
|
||||
|
||||
- `AppointmentBatchForm.vue`
|
||||
- `AppointmentCalendarPanel.vue`
|
||||
- `AppointmentCalendarView.vue`
|
||||
- `AppointmentCreatePanel.vue`
|
||||
- `ReceivingScope.vue`
|
||||
- `SourceAppointmentPanel.vue`
|
||||
- `TripAppointmentPanel.vue`
|
||||
- `WorkDetailPanel.vue`
|
||||
- `WorkReceivingPanel.vue`
|
||||
|
||||
总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。
|
||||
|
||||
| 实际入口 | 操作/承载与冻结结论 | 验收 |
|
||||
|---|---|---|
|
||||
| 预约日历主页面及原业务深链的实际表单、工作详情;保留四类、月周日期与原弹框承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
|
||||
|
||||
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
|
||||
|
||||
当前无正式入口引用的11个旧面板保留原样:AppointmentCancellationDialog、AppointmentCancellationHistory、AppointmentCancellationRecords、AppointmentCargoPanel、AppointmentCargoTabs、AppointmentContainerPanel、AppointmentExternalCandidates、AppointmentFollowupDialog、AppointmentImpactDialog、AppointmentInboundCodes、WorkCalendarPanel。不是新增或恢复入口;TripAppointmentPanel仍由正式车次详情引用,已纳入本次续批。
|
||||
|
||||
## 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 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
|
||||
|
||||
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。
|
||||
|
||||
|
||||
### 2026-10-21 弹框表单可用性修复
|
||||
|
||||
依据:本轮弹框可用性反馈;沿用本文件原有逐操作承载,不调整业务字段与提交逻辑。
|
||||
|
||||
| 页面/文件 | 操作与承载 | 结构问题与修复 | 验收 |
|
||||
|---|---|---|---|
|
||||
| WorkCommandForm、AppointmentBatchForm、WorkReceivingPanel、CargoVerificationForm | 预约工作弹框中的命令、批次、收货范围与货物核实 | 原生控件未命中 Element 表单样式;显式复用 workbench-input,并通过 appointmentNativeForms.css 补齐标签/控件排列、fieldset 间距及底部提交区 | 定向回归通过;待人工1920×1080、100%截图确认 |
|
||||
| WorkAppointmentForm、WorkDispatchForm | 预约取消、按预约批次排车 | 取消原因与计划托位原生控件接入同一局部表单样式;复选框与既有候选分页保留 | 定向回归通过;待人工截图确认 |
|
||||
|
||||
本次6个Vue仅变更样式导入、class与操作区容器;与修改前快照逐项核对,业务脚本、v-model、事件、权限/禁用条件均一致。类型检查、全量回归与构建由总控统一收口。
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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(--workbench-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,42 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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(--workbench-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,104 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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 admin-workbench is-dialog" 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(--workbench-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', mounted() { this.$emit('confirmationReady', true) }, 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,85 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
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 confirmationReady = ref(false)
|
||||
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | 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 }
|
||||
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
|
||||
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
|
||||
if (saving.value || !writable.value) 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 appointment-native-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" class="workbench-input" /></label></template>
|
||||
<FbaAppointmentFields v-else ref="appointmentFields" :sources="detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))" @confirmation-ready="confirmationReady = $event" :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()
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, 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 { resolveBatchWork, type BatchWorkResolution } from './workApi'
|
||||
import { useWorkSyncRead } from './useWorkSyncRead'
|
||||
const props = defineProps<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> }>()
|
||||
const emit = defineEmits<{ open: [key: string] }>()
|
||||
const resolution = ref<BatchWorkResolution | null>(null), error = ref<ApiErrorDetails | null>(null), loading = ref(false)
|
||||
const controller = new AbortController()
|
||||
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
|
||||
async function load(customerId = props.customerId) {
|
||||
loading.value = true; error.value = null
|
||||
try { const value = await syncRead.read(() => resolveBatchWork(props.batchKey, customerId, controller.signal), controller.signal); if (controller.signal.aborted) return; resolution.value = value; if (value.detail) emit('open', value.detail.item.workItemKey) }
|
||||
catch (cause) { if (!controller.signal.aborted) error.value = getApiErrorDetails(cause, '当前批次下一步读取失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => controller.abort())
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="当前批次续办">
|
||||
<p role="status">{{ syncing ? '预约工作清单同步中,正在自动读取当前批次下一步;已保存内容无需重复提交。' : '正在定位当前批次的工作事项;已保存的内容无需重复提交。' }}</p>
|
||||
<LoadErrorState v-if="error" title="当前批次下一步读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-if="resolution && !resolution.detail && !error"><p>本批有多个客户,请选择本次继续办理的货物。</p><el-button v-for="(candidate, index) in resolution.candidates" :key="candidate.workItemKey" :disabled="loading" @click="load(candidate.customerId)">{{ candidate.batchNo || '—' }} · {{ labels?.find(row => row.customerId === candidate.customerId)?.label || `客户范围 ${index + 1}` }}</el-button><p v-if="!resolution.candidates.length">当前没有可定位的送仓事项,请核对服务端同步结果。</p></template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import WorkCalendarPanel from './WorkCalendarPanel.vue'
|
||||
import { workCalendar } from './work.test-support'
|
||||
import { workTypes } from './workApi'
|
||||
function render() { return mount(WorkCalendarPanel, { props: { anchor: '2026-10-15', mode: 'month', flow: '', types: [...workTypes], calendar: workCalendar, loading: false, failed: false, reference: false, arrivals: [], referenceReady: false } }) }
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('keeps a busy day at five type summaries and consumes authoritative transport counts unchanged', async () => {
|
||||
const wrapper = render()
|
||||
const counts = { PICKUP: 300, WAREHOUSE_RECEIPT: 300, DELIVERY: 2, TRANSPORT: 1, EMPTY_RETURN: 300 }
|
||||
await wrapper.setProps({ calendar: { ...workCalendar, days: [{ ...workCalendar.days[0]!, total: 903, typeCounts: counts }] } })
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.findAll('.type-counts button')).toHaveLength(5)
|
||||
expect(day.get('[data-type="TRANSPORT"]').text()).toContain('1')
|
||||
expect(day.get('[data-type="DELIVERY"]').text()).toContain('2')
|
||||
await day.get('[data-type="TRANSPORT"]').trigger('click')
|
||||
expect(wrapper.emitted('day')).toEqual([['2026-10-15', 'TRANSPORT']]); wrapper.unmount()
|
||||
})
|
||||
it('offers all four flows directly and emits the original filter values with an exclusive selection', async () => {
|
||||
const wrapper = render()
|
||||
const buttons = wrapper.findAll('.flow-controls button')
|
||||
expect(buttons.map(button => button.text())).toEqual(['全部流程', '海外仓中转', '整柜直送', '路线待确认'])
|
||||
expect(wrapper.find('select').exists()).toBe(false)
|
||||
for (const [index, flow] of (['', 'TRANSFER', 'DIRECT', 'UNKNOWN'] as const).entries()) {
|
||||
await buttons[index]!.trigger('click')
|
||||
expect(wrapper.emitted('flow')?.at(-1)).toEqual([flow])
|
||||
await wrapper.setProps({ flow })
|
||||
expect(wrapper.findAll('.flow-controls button[aria-pressed=true]')).toHaveLength(1)
|
||||
expect(buttons[index]!.attributes('aria-pressed')).toBe('true')
|
||||
}
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('distinguishes today from the selected day and type without changing the selected scope', async () => {
|
||||
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-15T12:00:00'))
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ selectedDate: '2026-10-16' })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-current')).toBe('date')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.get('[data-date="2026-10-16"]').classes()).toContain('is-selected')
|
||||
await wrapper.setProps({ selectedDate: '2026-10-15', selectedType: 'DELIVERY' })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] [data-type="DELIVERY"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] [data-type="PICKUP"]').attributes('aria-pressed')).toBe('false')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows the actual cross-month interval and hides empty-day zeros while keeping dates clickable', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('2026-09-28 ~ 2026-11-08')
|
||||
const empty = wrapper.get('[data-date="2026-10-01"] .day-heading')
|
||||
expect(empty.text()).toBe('1')
|
||||
expect(empty.attributes('disabled')).toBeUndefined()
|
||||
await empty.trigger('click'); expect(wrapper.emitted('day')).toEqual([['2026-10-01']])
|
||||
expect(wrapper.get('footer').text()).toContain('未定日期请从上方待办查看')
|
||||
await wrapper.setProps({ mode: 'week' })
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(7)
|
||||
expect(wrapper.get('.view-switch button:last-child').attributes('aria-pressed')).toBe('true')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders server type counts instead of individual event cards and opens exact date/type details', async () => {
|
||||
const wrapper = render()
|
||||
expect(wrapper.findAll('.work-day')).toHaveLength(35)
|
||||
const day = wrapper.get('[data-date="2026-10-15"]')
|
||||
expect(day.get('.day-heading').text()).toContain('2 项')
|
||||
await day.get('[data-type="DELIVERY"]').trigger('click')
|
||||
expect(wrapper.emitted('day')).toEqual([['2026-10-15', 'DELIVERY']]); wrapper.unmount()
|
||||
})
|
||||
it('does not conflate arrivals, no-booking requirements or completed work with confirmed work', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ reference: true, referenceReady: true, arrivals: [{ containerBookingId: '1', cabinetNo: 'D-1', arrivalAt: '2026-10-15T10:00:00', arrivalType: 'ESTIMATED' }] })
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').text()).toContain('2 项')
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('已完成 1')
|
||||
expect(wrapper.get('.calendar-counts').text()).toContain('已落实 0')
|
||||
await wrapper.get('.arrival-reference').trigger('click'); expect(wrapper.emitted('arrivals')).toEqual([['2026-10-15']]); wrapper.unmount()
|
||||
})
|
||||
it('shows explicit all-off and failure states without manufacturing zero aggregates', async () => {
|
||||
const wrapper = render(); await wrapper.setProps({ types: [], calendar: null })
|
||||
expect(wrapper.text()).toContain('全局待办仍可查看'); expect(wrapper.findAll('.type-counts button')).toHaveLength(0)
|
||||
await wrapper.setProps({ types: [...workTypes], failed: true })
|
||||
expect(wrapper.get('.calendar-counts').text()).toBe('工作统计加载失败')
|
||||
expect(wrapper.get('[data-date="2026-10-15"] .day-heading').text()).not.toContain('0 项'); wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { calendarDates, dateKey, type CalendarMode } from './calendar'
|
||||
import { workTypes, type WorkCalendar, type WorkFlow, type WorkType } from './workApi'
|
||||
import { flowLabels, typeLabels } from './workPresentation'
|
||||
import type { ArrivalCalendarEntry } from './api'
|
||||
const props = defineProps<{ anchor: string; mode: CalendarMode; flow: WorkFlow | ''; types: WorkType[]; calendar: WorkCalendar | null; loading: boolean; failed: boolean; reference: boolean; arrivals: ArrivalCalendarEntry[]; referenceReady: boolean; selectedDate?: string; selectedType?: WorkType }>()
|
||||
const emit = defineEmits<{ anchor: [offset: number]; today: []; mode: [value: CalendarMode]; flow: [value: WorkFlow | '']; types: [value: WorkType[]]; reference: [value: boolean]; day: [date: string, type?: WorkType]; arrivals: [date: string] }>()
|
||||
const days = computed(() => calendarDates(props.anchor, props.mode))
|
||||
const today = dateKey(new Date())
|
||||
const flows: Array<{ value: WorkFlow | ''; label: string }> = [{ value: '', label: '全部流程' }, { value: 'TRANSFER', label: flowLabels.TRANSFER }, { value: 'DIRECT', label: flowLabels.DIRECT }, { value: 'UNKNOWN', label: flowLabels.UNKNOWN }]
|
||||
const aggregates = computed(() => new Map(props.calendar?.days.map(day => [day.date, day]) || []))
|
||||
const heading = computed(() => props.mode === 'month' ? `${props.anchor.slice(0, 4)}年${Number(props.anchor.slice(5, 7))}月` : `${days.value[0]} — ${days.value.at(-1)}`)
|
||||
function toggle(type: WorkType, checked: boolean) { emit('types', checked ? [...props.types, type] : props.types.filter(value => value !== type)) }
|
||||
function arrivalCount(date: string) { return props.arrivals.filter(row => row.arrivalAt.slice(0, 10) === date).length }
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-calendar" :class="{ 'week-view': mode === 'week' }" aria-label="预约工作日历" :aria-busy="loading">
|
||||
<div v-if="$slots.inbox" class="global-work"><slot name="inbox" /></div>
|
||||
<header class="calendar-toolbar">
|
||||
<div class="flow-controls" role="group" aria-label="日历流程"><span class="muted">流程</span><button v-for="option in flows" :key="option.value" type="button" :data-flow="option.value" :aria-pressed="flow === option.value" @click="emit('flow', option.value)">{{ option.label }}</button></div>
|
||||
<div class="period-controls"><div class="view-switch" aria-label="日历视图"><button type="button" :aria-pressed="mode === 'month'" @click="emit('mode', 'month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="emit('mode', 'week')">周</button></div><button type="button" aria-label="上一时段" @click="emit('anchor', -1)">‹</button><strong>{{ heading }}</strong><button type="button" aria-label="下一时段" @click="emit('anchor', 1)">›</button><button type="button" title="定位到浏览器当前日期" @click="emit('today')">今天</button></div>
|
||||
<span class="muted">作业地点当地时间</span>
|
||||
</header>
|
||||
<div class="work-layers"><span class="muted">显示类型</span><label v-for="type in workTypes" :key="type" :data-type="type"><input type="checkbox" :checked="types.includes(type)" @change="toggle(type, ($event.target as HTMLInputElement).checked)">{{ typeLabels[type] }}</label><label><input type="checkbox" :checked="reference" @change="emit('reference', ($event.target as HTMLInputElement).checked)">到港参考</label></div>
|
||||
<div class="calendar-counts" aria-live="polite">
|
||||
<span v-if="loading">正在读取工作统计…</span><span v-else-if="failed">工作统计加载失败</span><span v-else-if="!types.length">未勾选工作类型,全局待办仍可查看</span>
|
||||
<template v-else-if="calendar"><strong>当前显示区间 {{ calendar.from }} ~ {{ calendar.to }} · {{ calendar.total }} 项工作</strong><span :class="{ 'is-zero': calendar.counts.pending === 0 }">待安排 {{ calendar.counts.pending }}</span><span :class="{ 'is-zero': calendar.counts.confirmed === 0 }">已落实 {{ calendar.counts.confirmed }}</span><span :class="{ 'is-zero': calendar.counts.noBookingRequired === 0 }">无需预约 {{ calendar.counts.noBookingRequired }}</span><span :class="{ 'is-zero': calendar.counts.review === 0 }">需复核 {{ calendar.counts.review }}</span><span :class="{ 'is-zero': calendar.counts.completed === 0 }">已完成 {{ calendar.counts.completed }}</span></template>
|
||||
</div>
|
||||
<div class="weekdays"><span v-for="day in ['一', '二', '三', '四', '五', '六', '日']" :key="day">周{{ day }}</span></div>
|
||||
<div class="calendar-days">
|
||||
<div v-for="date in days" :key="date" class="work-day" :class="{ outside: mode === 'month' && !date.startsWith(anchor.slice(0, 7)), 'is-today': date === today, 'is-selected': date === selectedDate }" :data-date="date">
|
||||
<button class="day-heading" type="button" :disabled="loading || failed || !types.length" :aria-current="date === today ? 'date' : undefined" :aria-pressed="date === selectedDate" :aria-label="`${date} 工作明细`" @click="emit('day', date)"><span class="date-number">{{ Number(date.slice(8)) }}</span><small v-if="date === today" class="today-mark" title="浏览器当前日期">今天</small><span v-if="loading || failed || !types.length" class="day-total">—</span><span v-else-if="(aggregates.get(date)?.total ?? 0) > 0" class="day-total">{{ aggregates.get(date)?.total }} 项</span></button>
|
||||
<div class="type-counts"><template v-if="!loading && !failed && types.length"><button v-for="type in workTypes.filter(type => types.includes(type) && (aggregates.get(date)?.typeCounts[type] ?? 0) > 0)" :key="type" type="button" :data-type="type" :aria-pressed="date === selectedDate && type === selectedType" :aria-label="`${date} ${typeLabels[type]} ${aggregates.get(date)?.typeCounts[type]} 项`" @click="emit('day', date, type)"><span>{{ typeLabels[type] }}</span><b>{{ aggregates.get(date)?.typeCounts[type] }}</b></button></template></div>
|
||||
<button v-if="reference && referenceReady && arrivalCount(date)" type="button" class="arrival-reference" @click="emit('arrivals', date)">到港参考 {{ arrivalCount(date) }} 柜</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="muted">日历只展示有作业日期的事项,未定日期请从上方待办查看。已执行按实际日期展示;到港参考不计工作量。</footer>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.work-calendar { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface); }
|
||||
.calendar-toolbar, .period-controls, .work-layers, .calendar-counts { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.global-work { border-bottom: 1px solid var(--cc-border); }
|
||||
.calendar-toolbar { justify-content: flex-start; column-gap: var(--admin-space-6); padding: var(--admin-space-2) var(--admin-space-5); }
|
||||
.flow-controls { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-2); }
|
||||
.period-controls strong { min-width: 100px; text-align: center; }
|
||||
.work-layers, .calendar-counts { padding: var(--admin-space-2) var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.work-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-2); cursor: pointer; }
|
||||
.calendar-counts { min-height: 24px; font-size: var(--admin-font-caption); }
|
||||
.calendar-counts .is-zero { color: var(--el-text-color-secondary); }
|
||||
.weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; padding-block: var(--admin-space-3); background: var(--el-fill-color-light); }
|
||||
.weekdays span:nth-last-child(-n+2) { color: var(--el-text-color-secondary); }
|
||||
.work-day { height: 146px; min-height: 146px; max-height: 146px; box-sizing: border-box; border-top: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); padding: var(--admin-space-1); }
|
||||
.week-view .work-day { height: 270px; min-height: 270px; max-height: 270px; }
|
||||
.work-day:nth-child(7n) { border-right: 0; }
|
||||
.outside { background: var(--el-fill-color-lighter); }
|
||||
.work-calendar .day-heading { display: flex; align-items: center; gap: var(--admin-space-1); width: 100%; border: 0; padding: 0; min-height: 24px; background: transparent; margin-bottom: var(--admin-space-1); }
|
||||
.date-number { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; border-radius: 50%; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.is-today .date-number { color: var(--el-color-white); background: var(--el-color-primary); font-weight: 600; }
|
||||
.today-mark { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
|
||||
.day-total { margin-left: auto; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.work-day.is-selected { box-shadow: inset 0 0 0 2px var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.outside .day-heading { color: var(--el-text-color-secondary); }
|
||||
.type-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: minmax(24px, auto); gap: var(--admin-space-1); }
|
||||
.work-calendar .type-counts button { display: flex; align-items: center; justify-content: space-between; gap: 2px; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 0 var(--admin-space-1); font-size: var(--admin-font-caption); background: color-mix(in srgb, currentColor 7%, var(--admin-surface)); min-width: 0; min-height: 24px; white-space: nowrap; }
|
||||
.work-calendar .type-counts button:hover:not(:disabled) { border-color: currentColor; background: color-mix(in srgb, currentColor 12%, var(--admin-surface)); }
|
||||
.work-calendar .type-counts button[aria-pressed=true] { border-color: currentColor; background: color-mix(in srgb, currentColor 14%, var(--admin-surface)); box-shadow: inset 0 0 0 1px currentColor; }
|
||||
.type-counts b { font-variant-numeric: tabular-nums; }
|
||||
[data-type=PICKUP] { color: var(--el-color-primary); } [data-type=WAREHOUSE_RECEIPT] { color: #8356ad; } [data-type=DELIVERY] { color: #27815c; } [data-type=TRANSPORT] { color: #b26a12; } [data-type=EMPTY_RETURN] { color: #a64f69; }
|
||||
.work-calendar .arrival-reference { display: block; border: 0; background: transparent; padding: 0 var(--admin-space-1); margin-top: var(--admin-space-1); min-height: 24px; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.work-calendar .arrival-reference:hover { color: var(--el-text-color-regular); background: var(--el-fill-color-light); }
|
||||
footer { padding: var(--admin-space-3) var(--admin-space-5); }
|
||||
button { font: inherit; cursor: pointer; border-radius: var(--admin-radius); min-height: var(--admin-control-height); color: inherit; border: 1px solid var(--cc-border); background: var(--admin-surface); padding: 0 var(--admin-space-3); }
|
||||
.view-switch { display: inline-flex; }
|
||||
.view-switch button { border-radius: 0; min-width: 36px; }
|
||||
.view-switch button:first-child { border-radius: var(--admin-radius) 0 0 var(--admin-radius); }
|
||||
.view-switch button:last-child { border-radius: 0 var(--admin-radius) var(--admin-radius) 0; margin-left: -1px; }
|
||||
.view-switch button[aria-pressed=true], .flow-controls button[aria-pressed=true] { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); color: var(--el-color-primary); position: relative; }
|
||||
button:hover:not(:disabled) { border-color: var(--el-color-primary); }
|
||||
button:disabled { cursor: default; opacity: .6; } button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.muted { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import { submitWorkCommand } from './workApi'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
|
||||
vi.mock('@/core/auth/platformAuthorization', () => ({ listCurrentPlatformPermissionCodes: vi.fn().mockResolvedValue(['platform:appointment:followup']) }))
|
||||
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('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), submitWorkCommand: vi.fn() }))
|
||||
|
||||
function token(userId: string, exp = Math.floor(Date.now() / 1000) + 1800) {
|
||||
// Keep the numeric JSON literal intact; constructing a JS number would lose precision before decoding.
|
||||
const payload = `{"user_id":${userId},"username":"test_operator","app_code":"PLATFORM","exp":${exp}}`
|
||||
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(submitWorkCommand).mockResolvedValue({ item: workItem(), affectedWorkItemKeys: [] })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it.each([
|
||||
['login', '2110664514823360515'], ['login', '"2110664514823360515"'],
|
||||
['restore', '2110664514823360515'], ['restore', '"2110664514823360515"'],
|
||||
])('preserves the %s session ID through the real follow-up form for JSON %s', async (mode, userId) => {
|
||||
const accessToken = token(userId)
|
||||
if (mode === 'restore') {
|
||||
localStorage.setItem('cc-admin-token', accessToken)
|
||||
localStorage.setItem('cc-admin-permission-codes', '[]')
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
if (mode === 'login') {
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { accessToken, refreshToken: 'test-refresh', expiresIn: 1800, refreshExpiresIn: 604800 } })
|
||||
await auth.login('test_operator', 'test-password')
|
||||
}
|
||||
expect(auth.user?.id).toBe('2110664514823360515')
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
const wrapper = mount(WorkCommandForm, {
|
||||
props: { detail: workDetail({ item: workItem({ owner: null }) }), action: followAction, refreshing: false },
|
||||
global: {
|
||||
provide: { 'appointment-work-leave-guard': () => () => undefined },
|
||||
stubs: { ElButton: { template: '<button><slot /></button>' }, LoadErrorState: true, ApiErrorMeta: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((wrapper.get('select').element as HTMLSelectElement).value).toBe('2110664514823360515')
|
||||
await wrapper.get('textarea').setValue('已联系负责人')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({
|
||||
command: 'SAVE_FOLLOWUP', payload: { ownerId: '2110664514823360515', followupDate: '2026-10-15', note: '已联系负责人' },
|
||||
}))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it.each([token('2110664514823360515', 1), 'header.%.signature'])('does not restore an expired or malformed token: %s', accessToken => {
|
||||
localStorage.setItem('cc-admin-token', accessToken)
|
||||
const auth = useAuthStore()
|
||||
expect(auth.user).toBeNull()
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import { submitWorkCommand, type WorkAction } from './workApi'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const session = vi.hoisted(() => ({ canWrite: true }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'user-1', name: '测试调度' } }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => session.canWrite }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), submitWorkCommand: vi.fn() }))
|
||||
let guard: LeaveGuard
|
||||
function render(action: WorkAction = followAction, detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) {
|
||||
return mount(WorkCommandForm, { props: { detail, action, refreshing: false }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType" :disabled="disabled"><slot /></button>' },
|
||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); session.canWrite = true; vi.mocked(submitWorkCommand).mockResolvedValue({ item: workItem(), affectedWorkItemKeys: [] }) })
|
||||
it('sends only follow-up ownership and feedback, retaining the undated work and the exact retry command', async () => {
|
||||
vi.mocked(submitWorkCommand).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = render()
|
||||
await wrapper.get('textarea').setValue('已联系,等待回复'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(submitWorkCommand).mock.calls[0]![1]
|
||||
expect(first).toMatchObject({ command: 'SAVE_FOLLOWUP', expectedVersion: 3, payload: { ownerId: 'user-1', note: '已联系,等待回复', followupDate: '2026-10-15' } })
|
||||
expect(first.payload).not.toHaveProperty('target'); expect(await guard()).toBe(false)
|
||||
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenNthCalledWith(2, 'work-delivery-1', first)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('preserves entered feedback after a conflict and uses the newly read version on an explicit resubmission', async () => {
|
||||
vi.mocked(submitWorkCommand).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||
const wrapper = render(); await wrapper.get('textarea').setValue('保留此反馈')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const first = vi.mocked(submitWorkCommand).mock.calls[0]![1]
|
||||
expect(wrapper.emitted('refresh')).toHaveLength(1)
|
||||
await wrapper.setProps({ detail: workDetail({ item: workItem({ version: 4 }) }) })
|
||||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('保留此反馈')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
const second = vi.mocked(submitWorkCommand).mock.calls[1]![1]
|
||||
expect(second.expectedVersion).toBe(4); expect(second.idempotencyKey).not.toBe(first.idempotencyKey)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses the distinct review version and never substitutes the item version or marks work complete', async () => {
|
||||
const action: WorkAction = { code: 'RESOLVE_REVIEW', label: '复核安排', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'RESOLVE_REVIEW' } }
|
||||
const wrapper = render(action, workDetail({ item: workItem({ allowedActions: [action], reviewRequired: true }), review: { version: 12, reasons: ['到港日期调整'] } }))
|
||||
await wrapper.get('textarea').setValue('已核对原安排'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ expectedVersion: 3, command: 'RESOLVE_REVIEW', payload: { reviewVersion: 12, note: '已核对原安排' } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('requires receiving instructions for a customer warehouse that needs no appointment and does not invent a time', async () => {
|
||||
const action: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记收货要求', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_REQUIREMENT' } }
|
||||
const wrapper = render(action)
|
||||
await wrapper.get('input[type=checkbox]').setValue(false)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('客户仓确认无需预约')
|
||||
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('textarea')[0]!.setValue('工作日9至16点到仓联系收货人')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ command: 'SAVE_REQUIREMENT', payload: { target: null, bookingRequired: false, receivingRequirements: '工作日9至16点到仓联系收货人', reason: '客户仓确认无需预约' } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps a return handoff separate from original carrier plans and records external acceptance evidence', async () => {
|
||||
const action: WorkAction = { code: 'ACCEPT_RETURN', label: '记录接手反馈', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'ACCEPT_RETURN' } }
|
||||
const wrapper = render(action, workDetail({ item: workItem({ type: 'EMPTY_RETURN', allowedActions: [action] }), returnHandoff: { version: 7, from: { kind: 'USER', id: 'user-1', name: '测试调度' }, to: { kind: 'EXTERNAL', id: null, name: '测试还空车队' }, status: 'PENDING_ACCEPTANCE', reason: '原车次调整' } }))
|
||||
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea').setValue('接手车队已回复确认'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ command: 'ACCEPT_RETURN', payload: { handoffVersion: 7, note: '接手车队已回复确认' } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('denies command submission without write permission even when a submit event is forced', async () => {
|
||||
session.canWrite = false; const wrapper = render()
|
||||
await wrapper.get('form').trigger('submit'); expect(submitWorkCommand).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('当前仅可查看'); wrapper.unmount()
|
||||
})
|
||||
|
||||
const requirementAction: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记收货要求', targetWorkItemKey: null, target: { kind: 'WORK_COMMAND', command: 'SAVE_REQUIREMENT' } }
|
||||
it('preserves an explicit cross-day local interval in the requirement request', async () => {
|
||||
const wrapper = render(requirementAction)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('跨日收货窗口')
|
||||
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
|
||||
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue('23:00')
|
||||
await dates[1]!.setValue('2026-10-16'); await times[1]!.setValue('01:00')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: '23:00', endDate: '2026-10-16', endTime: '01:00', timezone: 'America/Los_Angeles' } }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([
|
||||
['2026-10-16', '', '23:00'], ['', '01:00', '23:00'],
|
||||
['2026-10-15', '22:00', '23:00'], ['2026-10-15', '23:00', '23:00'],
|
||||
['2026-10-16', '01:00', ''],
|
||||
])('rejects incomplete or non-increasing interval %s %s %s without inferring a next day', async (endDate, endTime, startTime) => {
|
||||
const wrapper = render(requirementAction)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('核对时间')
|
||||
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
|
||||
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue(startTime)
|
||||
await dates[1]!.setValue(endDate); await times[1]!.setValue(endTime)
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('saves a date-only requirement with all time fields null', async () => {
|
||||
const wrapper = render(requirementAction)
|
||||
await wrapper.findAll('textarea')[1]!.setValue('具体时段待回复')
|
||||
await wrapper.findAll('input[data-picker-kind=date]')[0]!.setValue('2026-10-15')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import './appointmentNativeForms.css'
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { submitWorkCommand, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandPayload, type WorkCommandResult } from './workApi'
|
||||
import { actionAllowed, commandName } from './workPresentation'
|
||||
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||
const props = defineProps<{ detail: WorkDetail; action: WorkAction; refreshing: boolean }>()
|
||||
const emit = defineEmits<{ saved: [result: WorkCommandResult, command: WorkCommand['command']]; refresh: [] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore()
|
||||
const command = computed(() => commandName(props.action))
|
||||
const item = computed(() => props.detail.item)
|
||||
const form = reactive({ flow: item.value.flow === 'DIRECT' ? 'DIRECT' : 'TRANSFER', ownerId: item.value.owner?.id || auth.user?.id || '', followupDate: item.value.followupDate || '', note: '', reason: '', date: item.value.target?.date || '', startTime: item.value.target?.startTime || '', endDate: item.value.target?.endDate || '', endTime: item.value.target?.endTime || '', bookingRequired: props.detail.requirement?.bookingRequired ?? true, receivingRequirements: props.detail.requirement?.receivingRequirements || '', recipientKind: 'EXTERNAL', recipientId: '', recipientName: '' })
|
||||
const baseline = ref(JSON.stringify(form)), pending = ref<WorkCommand | null>(null), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const supplierKeyword = ref(''), suppliers = ref<Supplier[]>([]), supplierTotal = ref(0), supplierLoading = ref(false), supplierError = ref<ApiErrorDetails | null>(null)
|
||||
let supplierRequest = 0
|
||||
async function searchSuppliers() {
|
||||
const request = ++supplierRequest; supplierError.value = null; suppliers.value = []; form.recipientId = ''
|
||||
if (!access.can('platform:supplier:list')) { supplierError.value = { message: '当前账号没有供应商读取权限,请联系有权限的负责人选择车队。' }; return }
|
||||
supplierLoading.value = true
|
||||
try { const result = await listSuppliers({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: supplierKeyword.value }); if (request === supplierRequest) { suppliers.value = result.records; supplierTotal.value = result.total } }
|
||||
catch (cause) { if (request === supplierRequest) supplierError.value = getApiErrorDetails(cause, '还空车队读取失败') }
|
||||
finally { if (request === supplierRequest) supplierLoading.value = false }
|
||||
}
|
||||
function changeRecipient() { form.recipientId = ''; form.recipientName = ''; if (form.recipientKind === 'SUPPLIER') void searchSuppliers() }
|
||||
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||
const owners = computed(() => {
|
||||
const people = [item.value.owner, auth.user ? { id: auth.user.id, name: auth.user.name } : null, ...props.detail.relatedItems.map(row => row.owner)]
|
||||
return [...new Map(people.filter((person): person is { id: string; name: string } => !!person?.id).map(person => [person.id, person])).values()]
|
||||
})
|
||||
const permission = computed(() => command.value === 'SAVE_FOLLOWUP' || command.value === 'RESOLVE_REVIEW' ? 'platform:appointment:followup' : 'platform:appointment:manage-work')
|
||||
const allowed = computed(() => !!command.value && actionAllowed(item.value, props.action) && access.can(permission.value))
|
||||
const missingContext = computed(() => {
|
||||
if (command.value === 'RESOLVE_REVIEW' && props.detail.review == null) return '当前没有可复核的影响版本,请刷新事项。'
|
||||
if (command.value === 'ACCEPT_RETURN' && props.detail.returnHandoff == null) return '当前没有有效的还空交接,请刷新事项。'
|
||||
if (command.value === 'SAVE_REQUIREMENT' && form.date && !item.value.timezone) return '作业地点时区未核实,暂不能保存带日期的要求。'
|
||||
return ''
|
||||
})
|
||||
function payload(): WorkCommandPayload | null {
|
||||
const note = form.note.trim(), reason = form.reason.trim()
|
||||
switch (command.value) {
|
||||
case 'CONFIRM_ROUTE': return { command: 'CONFIRM_ROUTE', payload: { flow: form.flow === 'DIRECT' ? 'DIRECT' : 'TRANSFER', reason: reason || null } }
|
||||
case 'SAVE_FOLLOWUP':
|
||||
if (!form.ownerId || !note) { ElMessage.warning('请选择跟进负责人并填写本次回复'); return null }
|
||||
return { command: 'SAVE_FOLLOWUP', payload: { ownerId: form.ownerId, followupDate: form.followupDate || null, note } }
|
||||
case 'RESOLVE_REVIEW':
|
||||
if (!props.detail.review || !note) { ElMessage.warning('请核对当前影响并填写处理结果'); return null }
|
||||
return { command: 'RESOLVE_REVIEW', payload: { reviewVersion: props.detail.review.version, note } }
|
||||
case 'SAVE_REQUIREMENT':
|
||||
if (!reason || (!form.bookingRequired && !form.receivingRequirements.trim())) { ElMessage.warning('请填写处理说明;无需预约时必须填写真实收货要求'); return null }
|
||||
if ((form.startTime || form.endDate || form.endTime) && !form.date) { ElMessage.warning('填写时段前需明确目标日期'); return null }
|
||||
if (!!form.endDate !== !!form.endTime) { ElMessage.warning('结束日期和结束时间须同时填写或同时留空'); return null }
|
||||
if (form.endDate && (!form.startTime || form.endDate + 'T' + form.endTime <= form.date + 'T' + form.startTime)) { ElMessage.warning('完整的当地结束时间须晚于开始时间'); return null }
|
||||
return { command: 'SAVE_REQUIREMENT', payload: { target: form.date && item.value.timezone ? { date: form.date, startTime: form.startTime || null, endDate: form.endDate || null, endTime: form.endTime || null, timezone: item.value.timezone } : null, bookingRequired: form.bookingRequired, receivingRequirements: form.receivingRequirements.trim(), reason } }
|
||||
case 'HANDOFF_RETURN': {
|
||||
const person = owners.value.find(owner => owner.id === form.recipientId)
|
||||
const supplier = suppliers.value.find(candidate => candidate.id === form.recipientId)
|
||||
const recipient = form.recipientKind === 'USER' && person ? { kind: 'USER' as const, id: person.id, name: person.name } : form.recipientKind === 'SUPPLIER' && supplier && !supplierLoading.value && !supplierError.value ? { kind: 'SUPPLIER' as const, id: supplier.id, name: supplier.name } : form.recipientKind === 'EXTERNAL' ? { kind: 'EXTERNAL' as const, id: null, name: form.recipientName.trim() } : null
|
||||
if (!recipient?.name || !reason) { ElMessage.warning('请明确接手方并填写转交原因'); return null }
|
||||
return { command: 'HANDOFF_RETURN', payload: { recipient, reason } }
|
||||
}
|
||||
case 'ACCEPT_RETURN':
|
||||
if (!props.detail.returnHandoff) return null
|
||||
if (props.detail.returnHandoff.to.kind !== 'USER' && !note) { ElMessage.warning('请记录外部接手方已确认的反馈依据'); return null }
|
||||
return { command: 'ACCEPT_RETURN', payload: { handoffVersion: props.detail.returnHandoff.version, note: note || null } }
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!allowed.value || props.refreshing || saving.value || missingContext.value) return
|
||||
if (!pending.value) { const request = payload(); if (!request) return; pending.value = { ...request, expectedVersion: item.value.version, idempotencyKey: crypto.randomUUID() } }
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
const request = pending.value, result = await submitWorkCommand(item.value.workItemKey, request)
|
||||
pending.value = null; baseline.value = JSON.stringify(form); ElMessage.success('已保存,工作待办已更新'); emit('saved', result, request.command)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '本次保存结果待确认,请重试同一次保存')
|
||||
if (rejectedCommand(cause)) { pending.value = null; emit('refresh') }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<form class="density-form work-form appointment-native-form" @submit.prevent="save">
|
||||
<h3>{{ action.label }}<InlineHelp v-if="command === 'SAVE_FOLLOWUP'" title="跟进日期说明"><p>跟进日期不代替作业日期;未完成事项仍保留在待办中。</p></InlineHelp><InlineHelp v-if="command === 'RESOLVE_REVIEW'" title="核对结果说明"><p>原安排需要改期时,请先处理原安排,再记录核对结果。</p></InlineHelp></h3>
|
||||
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
|
||||
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
|
||||
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
|
||||
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow" class="workbench-input"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required class="workbench-input"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" class="workbench-input" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
|
||||
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" class="workbench-input" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient" class="workbench-input"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200" class="workbench-input"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers" class="workbench-input"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError" class="workbench-input"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required class="workbench-input"><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label></template>
|
||||
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" class="workbench-input" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
|
||||
</fieldset>
|
||||
<LoadErrorState v-if="error" title="工作操作未保存" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button></div>
|
||||
</form>
|
||||
</template>
|
||||
<style scoped>.time-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }</style>
|
||||