Compare commits

...
65 Commits
Author SHA1 Message Date
ouyangxq a2ec169eb3 Sync optional CDN base configuration from GitHub dev [skip ci] 2026-10-14 21:06:09 +08:00
ouyangxq 46364d3f89 feat(selfop): show customer names and filter sales and turnover 2026-10-14 16:15:40 +08:00
ouyangxq 2a9c23ebc6 feat(self-operated): add transit column and streamline dashboard ranking explanations 2026-10-14 14:09:24 +08:00
ouyangxq adfb1a5cb3 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-14 14:01:01 +08:00
ouyangxq ff9ca74ba4 fix(self-operated): clarify turnover chart values and coverage threshold 2026-10-14 14:00:35 +08:00
liuyihe cb38114db9 优化 2026-10-14 13:53:15 +08:00
ouyangxq e7411eb03f feat(self-operated): unify completed-day ranges and analytics sorting 2026-10-14 12:39:16 +08:00
liuyihe 6b30e35d07 优化 2026-10-14 12:14:29 +08:00
liuyihe 0830d7a3a3 优化 2026-10-14 11:43:32 +08:00
ouyangxq 6a905419fc fix(self-operated): render dashboard KPIs with omitted unknown values 2026-10-14 11:22:59 +08:00
ouyangxq d8a4160bc7 fix(self-operated): clarify today's unavailable sales and tooltip precision 2026-10-14 11:18:47 +08:00
ouyangxq e0f54edcc9 优化 2026-10-14 11:02:56 +08:00
ouyangxq 3f728cd0b2 fix(selfop): render inventory turnover cells and data status 2026-10-14 09:23:29 +08:00
ouyangxq 3291d34fb8 fix(selfop): label order processing independently of detail requests 2026-10-14 08:57:21 +08:00
ouyangxq 02a9d72ef8 fix: preserve turnover deep-link snapshots and clarify data status 2026-10-14 01:52:20 +08:00
ouyangxq 2cf9129606 优化 2026-10-14 00:07:05 +08:00
ouyangxq 4c4a3db1eb feat(self-operated): add charts and continuous collection status 2026-10-13 20:44:31 +08:00
ouyangxq 698ca34529 fix(self-operated): follow selected dates in hot rankings 2026-10-13 18:47:56 +08:00
ouyangxq 133654327d refactor(self-operated): simplify collection account monitor 2026-10-13 17:29:45 +08:00
liuyihe 260409c071 优化样式 2026-10-13 17:14:56 +08:00
ouyangxq c7d9af0165 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-13 16:54:14 +08:00
ouyangxq bda0743520 feat: focus collection page on account activity and progress 2026-10-13 16:52:09 +08:00
liuyihe 50df363f86 优化 2026-10-13 16:49:08 +08:00
liuyihe c61ae525e1 优化 2026-10-13 14:47:06 +08:00
liuyihe 8bf7b20b89 优化 2026-10-13 13:46:56 +08:00
ouyangxq 1de73b5f1c fix: keep analytics date ranges together and apply atomically 2026-10-13 13:43:16 +08:00
ouyangxq 6bdaff3fdf fix(selfop): distinguish preparation progress from warehouse tasks 2026-10-13 12:50:29 +08:00
ouyangxq 9dcd991ddb feat(selfop): select inventory sales window and cutoff date 2026-10-13 12:36:45 +08:00
ouyangxq 58df0ed9e3 fix(selfop): preserve warehouse navigation and refine overview presentation 2026-10-13 11:06:20 +08:00
ouyangxq b73dc0d410 feat(selfop): add operating dashboard and preserve drilldown context 2026-10-13 00:45:41 +08:00
ouyangxq cd3a32f372 fix(selfop): show inventory observation time range 2026-10-13 00:10:54 +08:00
ouyangxq 600fdcb4b4 fix(selfop): unify collection layout and show details in dialogs 2026-10-12 22:06:45 +08:00
ouyangxq 57c50e6876 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-12 21:37:13 +08:00
ouyangxq 449d0c4965 refactor(self-operated): focus analytics pages on SKU metrics 2026-10-12 21:36:24 +08:00
liuyihe 2186f1f3a6 优化 2026-10-12 21:19:07 +08:00
ouyangxq 533273769d feat: add platform self-operated sales inventory and collection views 2026-10-12 20:33:39 +08:00
liuyihe cc4856ceac 优化 2026-10-12 18:01:41 +08:00
liuyihe 3f6d74dee1 优化 2026-10-12 13:43:21 +08:00
ouyangxq 92bdd32f86 fix(appointments): unify calendar layout and cabinet numbers 2026-10-12 11:45:00 +08:00
ouyangxq 20b4d1425b fix(appointments): ignore stale impact history responses 2026-10-11 21:09:30 +08:00
ouyangxq 43611ecc55 fix: avoid duplicate inquiry fetch on capacity refresh 2026-10-11 19:33:25 +08:00
ouyangxq f97cdad353 fix: show inquiry return text on dispatch detail 2026-10-11 16:11:58 +08:00
ouyangxq 315cb8e663 fix: align appointment viewing and fleet follow-up navigation 2026-10-11 15:33:09 +08:00
ouyangxq ee174f8265 fix: close appointment cancellation bypass and clarify verified cargo 2026-10-11 14:14:49 +08:00
ouyangxq 240aa5d6d0 fix: require appointment cancellation reason and expose history 2026-10-11 13:27:39 +08:00
ouyangxq d77abbef7c fix: align fleet controls and protect appointment retries 2026-10-11 13:07:52 +08:00
ouyangxq 1edb65df77 fix(fleet): fit receipt pallet space controls within dialog 2026-10-11 12:33:02 +08:00
ouyangxq baa69e66a1 fix(fleet): keep capacity fields readable in publish dialog 2026-10-11 11:17:08 +08:00
ouyangxq d7f2d09b88 fix(procurement): align correction actions with shipment state 2026-10-10 19:52:33 +08:00
ouyangxq e124fbcf68 fix(fleet): reflect bound booking in receipt progress 2026-10-10 19:04:09 +08:00
ouyangxq ebff6f8167 fix(appointments): show authoritative timezone and business source 2026-10-10 18:38:08 +08:00
ouyangxq 7f39adf9f5 fix(fleet): lock destination in readonly inquiry dialog 2026-10-10 18:15:39 +08:00
ouyangxq 67bd3e6e53 fix(appointments): show full candidate business identifiers 2026-10-10 17:36:36 +08:00
liuyihe c0155b8293 优化 2026-10-10 16:58:21 +08:00
liuyihe 175d8936b9 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-10 16:12:43 +08:00
liuyihe 081f79a9a1 优化 2026-10-10 16:12:38 +08:00
ouyangxq e404fe1049 fix(appointments): prioritize calendar and collapse data notices 2026-10-10 16:08:25 +08:00
liuyihe c69115f1b7 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-10 15:45:22 +08:00
liuyihe a564850e23 优化 2026-10-10 15:45:20 +08:00
ouyangxq 453549ece1 style(appointments): simplify calendar toolbar and remove redundant heading 2026-10-10 15:38:44 +08:00
ouyangxq be2a754342 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-10 15:34:07 +08:00
ouyangxq 0cb8a5c2b2 fix: align appointment procurement and carpool workflows with agreed views 2026-10-10 15:33:10 +08:00
liuyihe 40cccee266 优化优化 2026-10-10 14:58:15 +08:00
liuyihe 1abc831df1 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-10 14:42:43 +08:00
liuyihe b20c3ac104 优化 2026-10-10 14:42:41 +08:00
171 changed files with 7671 additions and 476 deletions
+1
View File
@@ -43,6 +43,7 @@
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 工作台与指标 | `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/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | | 预约管理、预约列表、跨客户同仓预约 | `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 存在代替页面接入完成 | | 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 存在代替页面接入完成 |
+2
View File
@@ -16,10 +16,12 @@
"@lucide/vue": "^1.27.0", "@lucide/vue": "^1.27.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"echarts": "^6.1.0",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.5.40", "vue": "^3.5.40",
"vue-echarts": "^8.3.2",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
+37
View File
@@ -20,6 +20,9 @@ importers:
dayjs: dayjs:
specifier: ^1.11.21 specifier: ^1.11.21
version: 1.11.21 version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
element-plus: element-plus:
specifier: ^2.14.3 specifier: ^2.14.3
version: 2.14.3(vue@3.5.40(typescript@5.9.3)) version: 2.14.3(vue@3.5.40(typescript@5.9.3))
@@ -32,6 +35,9 @@ importers:
vue: vue:
specifier: ^3.5.40 specifier: ^3.5.40
version: 3.5.40(typescript@5.9.3) 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: vue-router:
specifier: ^5.2.0 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)) 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: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
editorconfig@1.0.7: editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1150,6 +1159,9 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'} engines: {node: '>=20'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1325,6 +1337,12 @@ packages:
vue-component-type-helpers@3.3.8: vue-component-type-helpers@3.3.8:
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} 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: vue-router@5.2.0:
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
peerDependencies: peerDependencies:
@@ -1406,6 +1424,9 @@ packages:
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
snapshots: snapshots:
'@asamuzakjp/css-color@5.1.11': '@asamuzakjp/css-color@5.1.11':
@@ -1960,6 +1981,11 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
editorconfig@1.0.7: editorconfig@1.0.7:
dependencies: dependencies:
'@one-ini/wasm': 0.1.1 '@one-ini/wasm': 0.1.1
@@ -2436,6 +2462,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
tslib@2.3.0: {}
tslib@2.8.1: tslib@2.8.1:
optional: true optional: true
@@ -2563,6 +2591,11 @@ snapshots:
vue-component-type-helpers@3.3.8: {} 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)): 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: dependencies:
'@babel/generator': 8.0.0 '@babel/generator': 8.0.0
@@ -2658,3 +2691,7 @@ snapshots:
xmlchars@2.2.0: {} xmlchars@2.2.0: {}
yaml@2.9.0: {} yaml@2.9.0: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
+2
View File
@@ -34,6 +34,7 @@ import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes' import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes' import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
import { selfOperatedRoutes } from '@/modules/self-operated/routes'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -81,6 +82,7 @@ const router = createRouter({
...pdaReleaseRoutes, ...pdaReleaseRoutes,
...sailingCalendarRoutes, ...sailingCalendarRoutes,
...appointmentCalendarRoutes, ...appointmentCalendarRoutes,
...selfOperatedRoutes,
{ {
path: '403', path: '403',
name: 'forbidden', name: 'forbidden',
+12 -3
View File
@@ -8,6 +8,14 @@ import {
} from './adminNavigation' } from './adminNavigation'
describe('adminNavigation', () => { describe('adminNavigation', () => {
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',
])
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', () => { it('offers the common container menu to overseas-only operators', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list'])) const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic']) expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
@@ -27,11 +35,12 @@ describe('adminNavigation', () => {
{ 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(12) expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52) expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
}) })
@@ -73,7 +82,7 @@ describe('adminNavigation', () => {
expect(paths).toContain('/system/permissions') expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length) expect(new Set(paths).size).toBe(paths.length)
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index) 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) expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
}) })
+11
View File
@@ -181,6 +181,17 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' }, { 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', key: 'general',
title: '通用', title: '通用',
@@ -3,6 +3,19 @@ import { describe, expect, it } from 'vitest'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
describe('appointment calendar interactions', () => { describe('appointment calendar interactions', () => {
it('keeps creation beside the title and record actions beside data notices', () => {
const wrapper = mount(AppointmentCalendarPanel, {
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
expect(actions.text()).toContain('未定时安排')
expect(actions.text()).toContain('取消记录')
expect(actions.get('summary').text()).toContain('数据提示')
wrapper.unmount()
})
const render = () => mount(AppointmentCalendarPanel, { const render = () => mount(AppointmentCalendarPanel, {
props: { initialDate: '2026-09-24', canCreate: true, events: [ props: { initialDate: '2026-09-24', canCreate: true, events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true }, { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
@@ -10,11 +23,37 @@ describe('appointment calendar interactions', () => {
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } }, global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
}) })
it('opens a readable appointment without exposing write actions', async () => {
const wrapper = render()
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
await wrapper.setProps({events:[event]})
const actions=wrapper.get('.detail-event footer')
expect(actions.text()).toBe('查看预约')
await actions.get('button').trigger('click')
expect(wrapper.emitted('view')?.[0]).toEqual([event])
expect(wrapper.emitted('edit')).toBeUndefined()
wrapper.unmount()
})
it('hides optional references by default and counts only appointments after enabling them', async () => {
const wrapper = render()
await wrapper.setProps({ events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00', title: '收货预约', fields: [] },
{ key: 'b', type: 'arrival', date: '2026-09-24', time: '08:00', title: '到港参考', fields: [] },
{ key: 'c', type: 'trip', date: '2026-09-24', time: '07:00', title: '运输计划', fields: [] },
] })
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
const switches = wrapper.get('[aria-label="参考图层"]').findAll('input')
await switches[0]!.setValue(true); await switches[1]!.setValue(true)
expect(wrapper.findAll('.detail-event')).toHaveLength(3)
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('1 项预约')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('2 条参考计划')
expect(wrapper.emitted('range')).toBeUndefined()
})
it('selects an already loaded date without requesting data again', async () => { it('selects an already loaded date without requesting data again', async () => {
const wrapper = render() const wrapper = render()
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click') await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
expect(wrapper.emitted('range')).toBeUndefined() expect(wrapper.emitted('range')).toBeUndefined()
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无到港或送仓预约') expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
}) })
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => { it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
@@ -44,6 +83,7 @@ describe('appointment calendar interactions', () => {
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00', key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
title: `船舶 ${index}`, fields: [], title: `船舶 ${index}`, fields: [],
})) }) })) })
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
for (const view of ['month', 'week']) { for (const view of ['month', 'week']) {
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click') if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]') const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
@@ -15,30 +15,37 @@ export interface CalendarEvent {
fields: Array<{ label: string; value?: string | null }> fields: Array<{ label: string; value?: string | null }>
inboundCodes?: Array<string | null | undefined> inboundCodes?: Array<string | null | undefined>
canEdit?: boolean canEdit?: boolean
canView?: boolean
canDelete?: boolean canDelete?: boolean
sourcePath?: string sourcePath?: string
} }
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
notices?: string[]
events: CalendarEvent[] events: CalendarEvent[]
loading?: boolean loading?: boolean
incomplete?: boolean incomplete?: boolean
canCreate?: boolean canCreate?: boolean
initialDate?: string initialDate?: string
}>(), { loading: false, incomplete: false, canCreate: false, initialDate: '' }) }>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
const emit = defineEmits<{ const emit = defineEmits<{
layers: [layers: { arrivals: boolean; trips: boolean }]
range: [range: { from: string; to: string }] range: [range: { from: string; to: string }]
create: [date: string] create: [date: string]
edit: [event: CalendarEvent] edit: [event: CalendarEvent]
view: [event: CalendarEvent]
delete: [event: CalendarEvent] delete: [event: CalendarEvent]
open: [event: CalendarEvent] open: [event: CalendarEvent]
}>() }>()
const showArrivals = ref(false), showTrips = ref(false)
function updateLayers() { emit('layers', { arrivals: showArrivals.value, trips: showTrips.value }) }
const visibleEvents = computed(() => props.events.filter(event => (event.type !== 'arrival' || showArrivals.value) && (event.type !== 'trip' || showTrips.value)))
const selectedDate = ref(props.initialDate || dateKey(new Date())) const selectedDate = ref(props.initialDate || dateKey(new Date()))
const mode = ref<CalendarMode>('month') const mode = ref<CalendarMode>('month')
const dates = computed(() => calendarDates(selectedDate.value, mode.value)) const dates = computed(() => calendarDates(selectedDate.value, mode.value))
const grouped = computed(() => { const grouped = computed(() => {
const result = new Map<string, CalendarEvent[]>() const result = new Map<string, CalendarEvent[]>()
for (const event of props.events) { for (const event of visibleEvents.value) {
for (const date of dates.value) { for (const date of dates.value) {
if (!occursOnDate(event.date, event.endAt, date)) continue if (!occursOnDate(event.date, event.endAt, date)) continue
const group = result.get(date) || [] const group = result.get(date) || []
@@ -50,6 +57,7 @@ const grouped = computed(() => {
return result return result
}) })
const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || []) const selectedEvents = computed(() => grouped.value.get(selectedDate.value) || [])
const appointmentCount = computed(() => selectedEvents.value.filter(event => event.type === 'delivery' || event.type === 'pickup').length)
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' } const eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
const heading = computed(() => mode.value === 'month' const heading = computed(() => mode.value === 'month'
@@ -76,10 +84,9 @@ defineExpose({ selectedDate, mode })
<div class="calendar-label"> <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> <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> <strong>预约日历</strong>
<span class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span class="legend trip">用车计划</span> <el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
</div> </div>
<div class="calendar-controls"> <div class="calendar-controls">
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
<div class="view-toggle" role="group" aria-label="预约视图"> <div class="view-toggle" role="group" aria-label="预约视图">
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button> <button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button> <button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
@@ -89,15 +96,23 @@ defineExpose({ selectedDate, mode })
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button> <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> <el-button @click="select(dateKey(new Date()))">今天</el-button>
</div> </div>
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
<slot name="toolbar-actions" />
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
</div>
</header> </header>
<div class="calendar-options">
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
<div class="calendar-layers" role="group" aria-label="参考图层"><label><input v-model="showArrivals" type="checkbox" @change="updateLayers">显示到港参考</label><label><input v-model="showTrips" type="checkbox" @change="updateLayers">显示用车计划</label></div>
</div>
<div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div> <div class="weekdays"><span v-for="day in weekdays" :key="day">{{ day }}</span></div>
<div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }"> <div v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
<button v-for="date in dates" :key="date" type="button" class="calendar-day" <button v-for="date in dates" :key="date" type="button" class="calendar-day"
:class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }" :class="{ selected: selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
:title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(部分来源未加载)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(部分来源未加载)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)"> :title="`${date},${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-label="`${date},${(grouped.get(date) || []).length} 项安排${incomplete ? '(当前范围不完整)' : ''}`" :aria-pressed="selectedDate === date" @click="select(date)">
<span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span> <span class="day-number">{{ mode === 'week' ? `${Number(date.slice(5, 7))}月` : '' }}{{ Number(date.slice(8)) }}</span>
<span class="day-events"> <span 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.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(部分来源未加载)' : ''}`"> <span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="`${event.time} ${event.title} · 当天${incomplete ? '已加载' : '共'} ${(grouped.get(date) || []).length} 条安排${incomplete ? '(当前范围不完整)' : ''}`">
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small> {{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
</span> </span>
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span> <span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
@@ -106,15 +121,16 @@ defineExpose({ selectedDate, mode })
</div> </div>
</div> </div>
<aside class="calendar-detail" aria-label="当天安排"> <aside class="calendar-detail" aria-label="当天安排">
<header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分来源未加载)</span></p></header> <header><strong>{{ selectedDate }}</strong><p>{{ incomplete ? '当天已加载' : '当天共有' }} <b>{{ appointmentCount }}</b> 项预约<span v-if="selectedEvents.length > appointmentCount">,{{ selectedEvents.length - appointmentCount }} 条参考计划</span><span v-if="incomplete">(当前范围不完整)</span></p></header>
<div class="detail-events" aria-live="polite"> <div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '部分来源未加载,请重试后确认当天安排' : '当天暂无到港或送仓预约' }}</p> <p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event"> <article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div> <div class="event-heading"><span class="event-type" :class="event.type">{{ eventTypeLabels[event.type] }}</span><span>{{ event.time || '—' }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p> <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> <dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" /> <AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
<footer v-if="event.canEdit || event.canDelete || event.sourcePath" class="density-actions"> <footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button> <el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button> <el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button> <el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
@@ -126,12 +142,24 @@ defineExpose({ selectedDate, mode })
</template> </template>
<style scoped> <style scoped>
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 2.5fr) minmax(320px, 1fr); gap: 16px; color: #344054; } .calendar-data-notices { position: relative; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-data-notices summary { cursor: pointer; padding: var(--admin-space-2) var(--admin-space-3); }
.calendar-notice-content { position: absolute; top: 100%; right: 0; z-index: 5; width: 320px; padding: var(--admin-space-5); border: 1px solid var(--admin-table-border); border-radius: var(--admin-radius); background: var(--admin-surface); box-shadow: var(--el-box-shadow-light); }
.calendar-notice-content p { margin: 0; line-height: 1.6; }
.calendar-notice-content p + p { margin-top: var(--admin-space-3); }
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: #344054; }
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; } .calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; padding: 16px; border-bottom: 1px solid #edf0f6; } .calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; } .calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
.calendar-label > svg { color: #617ce9; } .calendar-label > svg { color: #617ce9; }
.calendar-label > strong { font-size: 16px; } .calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
.calendar-legend, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.calendar-layers label { display: inline-flex; align-items: center; gap: var(--admin-space-1); cursor: pointer; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.calendar-layers input { margin: 0; accent-color: var(--el-color-primary); }
.legend { font-size: 12px; white-space: nowrap; } .legend { font-size: 12px; white-space: nowrap; }
.legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; } .legend::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
.legend.delivery::before { background: #27a87a; } .legend.delivery::before { background: #27a87a; }
@@ -143,16 +171,16 @@ defineExpose({ selectedDate, mode })
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; } .nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
.calendar-period { font-size: 14px; white-space: nowrap; } .calendar-period { font-size: 14px; white-space: nowrap; }
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } .weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: 12px 14px; font-size: 13px; } .weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
.calendar-grid { gap: 7px; padding: 12px; background: #fbfcff; } .calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
.calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 128px; min-width: 0; padding: 8px; border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; } .calendar-day { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; height: 112px; min-width: 0; padding: var(--admin-space-2); border: 1px solid #e5eaf2; border-radius: 8px; background: #fff; text-align: left; cursor: pointer; color: inherit; font: inherit; overflow: hidden; }
.calendar-day.outside { background: #f7f8fa; color: #98a2b3; } .calendar-day.outside { background: #f7f8fa; color: #98a2b3; }
.calendar-day:hover { border-color: #bac8ed; background: #f8faff; } .calendar-day:hover { border-color: #bac8ed; background: #f8faff; }
.calendar-day.selected { border-color: #617ce9; background: #f5f7ff; } .calendar-day.selected { border-color: #617ce9; background: #f5f7ff; }
.calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; } .calendar-day:focus-visible, .nav-button:focus-visible, .view-toggle button:focus-visible { outline: 2px solid #4167d8; outline-offset: 2px; }
.day-number { display: block; font-size: 12px; font-weight: 600; } .day-number { display: block; font-size: 12px; font-weight: 600; }
.day-events { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; } .day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
.event-chip { display: block; padding: 3px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } .event-chip { display: block; padding: 1px 6px; line-height: 16px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; } .arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; } .delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; } .pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
@@ -163,17 +191,18 @@ defineExpose({ selectedDate, mode })
.event-more, .empty-day { color: #667085; font-size: 12px; } .event-more, .empty-day { color: #667085; font-size: 12px; }
.week-grid .calendar-day { height: 220px; } .week-grid .calendar-day { height: 220px; }
.calendar-detail { align-self: start; } .calendar-detail { align-self: start; }
.calendar-detail > header { padding: 18px; border-bottom: 1px solid #edf0f6; } .calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-detail > header > strong { font-size: 17px; } .calendar-detail > header > strong { font-size: var(--admin-font-title); }
.calendar-detail header p { margin: 10px 0 0; font-size: 13px; color: #8491a5; } .calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
.calendar-detail header b { color: #617ce9; font-size: 17px; } .calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
.detail-events { padding: 14px; max-height: 650px; overflow-y: auto; } .detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: 16px; padding-top: 16px; } .detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; } .event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; } .event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
.detail-event h3 { font-size: 15px; margin: 14px 0 8px; overflow-wrap: anywhere; } .detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; } .event-subtitle { color: #667085; font-size: 13px; overflow-wrap: anywhere; }
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 16px 0; } .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 .wide-field { grid-column: 1 / -1; }
.detail-event dt { font-size: 12px; color: #8491a5; } .detail-event dt { font-size: 12px; color: #8491a5; }
.detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; } .detail-event dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; } .detail-event footer { display: flex; justify-content: flex-end; border-top: 1px solid #edf0f6; padding-top: 10px; }
@@ -1,21 +1,23 @@
import { inject, onBeforeUnmount } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCalendarView from './AppointmentCalendarView.vue' import AppointmentCalendarView from './AppointmentCalendarView.vue'
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse' import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
import { listArrivalCalendar, listPickupCalendar } from './api' import { listArrivalCalendar, listPickupCalendar } from './api'
import { listTripCalendar } from './tripCalendar' import { listTripCalendar } from './tripCalendar'
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()})) vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi' import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true })) const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true, canWrite: true, containerPending: false }))
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() })) vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
import { listPlatformWarehouses } from '@/modules/warehouses' import { listPlatformWarehouses } from '@/modules/warehouses'
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() })) vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() })) vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'write' || routing.canWrite) && (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()), vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
@@ -27,11 +29,19 @@ const candidates = [
{ lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' }, { lineId: '9007199254740993', customerId: 'A', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '1', planNo: 'IN-A', orderId: '10' },
{ lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' }, { lineId: '9007199254740994', customerId: 'B', overseasWarehouseId: '100', fbaWarehouseCode: 'ONT8', planId: '2', planNo: 'IN-B', orderId: '20' },
] ]
function render() { function render(openCalendar = true) {
return mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: { const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
AppointmentCancellationDialog: {name:'AppointmentCancellationDialog',template:'<div>取消弹窗</div>'},
AppointmentCancellationHistory: {name:'AppointmentCancellationHistory',template:'<div>取消历史详情</div>'},
AppointmentCancellationRecords: {name:'AppointmentCancellationRecords',template:'<button data-history-view @click="$emit(\'view\',\'AP-CANCEL\')">查看取消记录</button>'},
AdminPage: { template: '<main><slot /></main>' }, AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'incomplete'], template: '<button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button>' }, AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() {
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' }, const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')!
const unregister = register(() => !routing.containerPending)
onBeforeUnmount(unregister)
}, template: '<div>整柜跟进工作区</div>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /><slot name="toolbar-actions" /></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '<div v-if="modelValue"><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot /></form>' }, ElForm: { template: '<form><slot /></form>' },
ElFormItem: { template: '<label><slot /></label>' }, ElFormItem: { template: '<label><slot /></label>' },
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' }, ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
@@ -39,15 +49,17 @@ function render() {
ElButton: { template: '<button><slot /></button>' }, ElButton: { template: '<button><slot /></button>' },
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' }, ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' }, ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' }, DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><slot v-for="row in data" name="actions" :row="row"/><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' }, LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
} } }) } } })
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
return wrapper
} }
describe('appointment calendar data integration', () => { describe('appointment calendar data integration', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks() vi.resetAllMocks()
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true; routing.canWrite = true; routing.containerPending = false
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 }) vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([]) vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0}) vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
@@ -58,25 +70,148 @@ describe('appointment calendar data integration', () => {
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
}) })
it('displays the existing D cabinet number in both arrival and pickup events', async () => {
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0007', arrivalAt: '2026-10-09T08:00:00', arrivalType: 'ESTIMATED' }])
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [{ containerBookingId: '1', cabinetNo: 'D0007', containerNo: null, destinationPortName: null, destinationWarehouseCode: null, fleetSupplierName: null, pickupAppointmentAt: '2026-10-09T09:00:00', actualPickupAt: null }], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
const wrapper = render(); await flushPromises()
const events = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')
expect(events).toHaveLength(2)
for (const event of events) {
expect(event.fields).toContainEqual({ label: '柜次号', value: 'D0007' })
expect(event.title).toContain('D0007')
}
wrapper.unmount()
})
it('opens a dated calendar appointment read-only for list-only users', async () => {
routing.canWrite=false
const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]}
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([record])
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render();await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual(expect.arrayContaining([expect.objectContaining({key:'AP-DATED',canView:true,canEdit:false})]))
panel.vm.$emit('view',{key:'AP-DATED'});await flushPromises()
expect(getFbaAppointment).toHaveBeenCalledWith('AP-DATED')
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
expect(wrapper.find('[data-save]').exists()).toBe(false)
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => {
const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render(); await flushPromises()
await wrapper.get('[data-unscheduled]').trigger('click')
await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED')
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads cancellation records only after opening the dialog and opens cancelled appointments read-only', async () => {
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render();await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
const records=wrapper.get('[data-cancellations]')
await records.trigger('click')
await wrapper.get('[data-history-view]').trigger('click');await flushPromises()
expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL')
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(false)
const history=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消历史')!
;(history.element as HTMLDetailsElement).open=true;await history.trigger('toggle')
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(true)
wrapper.findAllComponents({name:'AppDialog'}).at(-1)!.vm.$emit('confirm');await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('refreshes an expanded cancellation list after a successful cancellation', async () => {
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}])
const wrapper=render();await flushPromises()
const records=wrapper.get('[data-cancellations]')
await records.trigger('click')
const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm
wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises()
wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises()
expect(wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm).not.toBe(previous)
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).exists()).toBe(false)
wrapper.unmount()
})
it('keeps the container view mounted while a child has an unresolved command', async () => {
const wrapper = render(); await flushPromises()
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
await tabs[1]!.trigger('click'); await flushPromises()
routing.containerPending = true
await tabs[0]!.trigger('click')
expect(tabs[1]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(true)
routing.containerPending = false
await tabs[0]!.trigger('click')
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(false)
wrapper.unmount()
})
it('opens an unscheduled appointment read-only without allowing a save', async () => {
routing.canWrite = false
const record = { version: 0, id: '1', appointmentNo: 'AP-READ', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', items: [] }
vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper = render(); await flushPromises()
await wrapper.get('[data-unscheduled]').trigger('click')
await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('[data-save]').exists()).toBe(false)
const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!
dialog.vm.$emit('confirm'); await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
wrapper.unmount()
})
it('puts calendar first and opens it by default without reloading when switching back', async () => {
const wrapper = render(false); await flushPromises()
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
expect(tabs.map(button => button.text())).toEqual(['预约日历', '提柜与到仓', '送仓与排车'])
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(wrapper.text()).not.toContain('美国预约总览')
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
await tabs[1]!.trigger('click'); await flushPromises()
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
await tabs[0]!.trigger('click'); await flushPromises()
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
})
it('distinguishes missing country data from truncation instead of suggesting a narrower date range', async () => {
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [], truncated: false, unclassifiedCount: 74, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
const wrapper = render(); await flushPromises()
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('74 条提柜记录')
expect(wrapper.text()).not.toContain('缩小日期范围')
})
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => { it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'}) vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',cabinetNo: 'D0001',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
const wrapper=render(); await flushPromises() const wrapper=render(); await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})]) expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})])
expect(panel.props('incomplete')).toBe(true) expect(panel.props('incomplete')).toBe(true)
expect(wrapper.text()).toContain('目的仓国家待核实') expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('目的仓国家待核实')
expect(listPickupCalendar).toHaveBeenCalledTimes(1) expect(listPickupCalendar).toHaveBeenCalledTimes(1)
}) })
it('does not request pickup records without pickup list permission and discloses the omitted source', async () => { it('does not request pickup records without pickup list permission and discloses the omitted source', async () => {
routing.canPickup=false routing.canPickup=false
const wrapper=render(); await flushPromises() const wrapper=render(); await flushPromises()
expect(listPickupCalendar).not.toHaveBeenCalled() expect(listPickupCalendar).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('无提柜查看权限') expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无提柜查看权限')
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true) expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true)
}) })
it('keeps successful arrival data visible when the pickup source fails', async () => { it('keeps successful arrival data visible when the pickup source fails', async () => {
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable')) vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}]) vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',cabinetNo:'D0001',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
const wrapper=render(); await flushPromises() const wrapper=render(); await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})]) expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
@@ -86,13 +221,16 @@ describe('appointment calendar data integration', () => {
routing.canTrips=false routing.canTrips=false
const wrapper=render();await flushPromises() const wrapper=render();await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1) expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('无车次查看权限') expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无车次查看权限')
}) })
it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => { it('keeps unresolved trip origins outside daily slots and clearly labels their UTC source time', async () => {
vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]}) vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]})
const wrapper=render();await flushPromises() const wrapper=render();await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
expect(wrapper.text()).toContain('待核实地点的车次(1)');expect(wrapper.text()).toContain('原计划时间保留 UTC') expect(wrapper.findAll('.appointment-extra summary').map(node => node.text())).not.toContain('待核实地点的车次(1)')
expect(wrapper.text()).not.toContain('原计划时间保留 UTC')
await wrapper.findAll('button').find(button => button.text() === '查看待核实车次(1)')!.trigger('click')
expect(wrapper.text()).toContain('原计划时间保留 UTC')
expect(listTripCalendar).toHaveBeenCalledTimes(1) expect(listTripCalendar).toHaveBeenCalledTimes(1)
}) })
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => { it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
@@ -119,12 +257,13 @@ describe('appointment calendar data integration', () => {
}) })
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => { it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
const wrapper = render() const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click') await wrapper.get('[data-create]').trigger('click')
await flushPromises() await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 })) expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false) expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('查询可预约货物') expect(wrapper.text()).not.toContain('查询可预约货物')
expect(wrapper.find('details').attributes('open')).toBeUndefined() expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises() await flushPromises()
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' }) expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
@@ -137,6 +276,17 @@ describe('appointment calendar data integration', () => {
await wrapper.get('[data-select]').trigger('click') await wrapper.get('[data-select]').trigger('click')
await wrapper.get('[data-save]').trigger('click'); await flushPromises() await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0] const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
await wrapper.get('[data-close]').trigger('click')
expect(wrapper.find('[data-save]').exists()).toBe(true)
await wrapper.get('[data-create]').trigger('click')
expect((wrapper.get('select[aria-label="预约仓库"]').element as HTMLSelectElement).value).toBe('ONT8')
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() await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first) expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
expect(first.details.appointmentAt).toBeNull() expect(first.details.appointmentAt).toBeNull()
@@ -148,11 +298,14 @@ describe('appointment calendar data integration', () => {
const wrapper=render(); await flushPromises() const wrapper=render(); await flushPromises()
expect(wrapper.text()).toContain('未定时安排(1)') expect(wrapper.text()).toContain('未定时安排(1)')
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
expect(wrapper.findComponent({name:'DataTable'}).exists()).toBe(false)
await wrapper.get('[data-unscheduled]').trigger('click')
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt') expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
}) })
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => { it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
const wrapper = render() const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click') await wrapper.get('[data-create]').trigger('click')
await flushPromises() await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
@@ -170,6 +323,7 @@ describe('appointment calendar data integration', () => {
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const wrapper = render() const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click') await wrapper.get('[data-create]').trigger('click')
await flushPromises() await flushPromises()
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
@@ -183,6 +337,7 @@ describe('appointment calendar data integration', () => {
it('searches by keyword and exposes warehouse failures without accepting free text', async () => { it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
const wrapper = render() const wrapper = render()
await flushPromises()
await wrapper.get('[data-create]').trigger('click') await wrapper.get('[data-create]').trigger('click')
await flushPromises() await flushPromises()
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))! const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
@@ -197,7 +352,7 @@ describe('appointment calendar data integration', () => {
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => { it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用')) vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }]) vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0001', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
const wrapper = render() const wrapper = render()
await flushPromises() await flushPromises()
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' }) const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi' import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -21,12 +21,19 @@ import {
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue' import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue' import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue' import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue' import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import { calendarRange, dateKey } from './calendar' import { calendarRange, dateKey } from './calendar'
import { deleteAppointment, listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api' import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar' import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar'
const cancellationTarget = ref<FbaAppointmentDto | null>(null)
const unscheduledOpen = ref(false)
const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false)
const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment))
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -35,9 +42,22 @@ const selectedSource = ref<PlanningSource | null>(null)
const linkError = ref<ApiErrorDetails | null>(null) const linkError = ref<ApiErrorDetails | null>(null)
const linkLoading = ref(false) const linkLoading = ref(false)
let linkRequest = 0 let linkRequest = 0
const childLeaveGuards = new Set<() => boolean>()
provide('appointment-register-leave-guard', (guard: () => boolean) => {
childLeaveGuards.add(guard)
return () => { childLeaveGuards.delete(guard) }
})
function switchWorkView(next: 'calendar' | 'cargo' | 'containers') {
if (canLeave()) workView.value = next
}
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar') const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
const calendarVisited = ref(true)
const showTripLayer = ref(false)
const cargoVisited = ref(false) const cargoVisited = ref(false)
watch(workView, value => { if (value === 'cargo') cargoVisited.value = true }) watch(workView, value => {
if (value === 'cargo') cargoVisited.value = true
if (value === 'calendar' && !calendarVisited.value) { calendarVisited.value = true; void load(); void loadUnscheduled() }
})
const appointments = ref<FbaAppointmentDto[]>([]) const appointments = ref<FbaAppointmentDto[]>([])
const arrivals = ref<ArrivalCalendarEntry[]>([]) const arrivals = ref<ArrivalCalendarEntry[]>([])
const pickups = ref<PickupCalendarEntry[]>([]) const pickups = ref<PickupCalendarEntry[]>([])
@@ -45,6 +65,7 @@ const pickupNotice = ref('')
const tripEvents = ref<CalendarEvent[]>([]) const tripEvents = ref<CalendarEvent[]>([])
const unresolvedTrips = ref<TripCalendarEntry[]>([]) const unresolvedTrips = ref<TripCalendarEntry[]>([])
const tripNotice = ref('') const tripNotice = ref('')
const unresolvedDialogOpen = ref(false)
const unresolvedPage = ref(1), unresolvedSize = ref(10) const unresolvedPage = ref(1), unresolvedSize = ref(10)
const unresolvedRows = computed(() => unresolvedTrips.value.slice((unresolvedPage.value - 1) * unresolvedSize.value, unresolvedPage.value * unresolvedSize.value)) const unresolvedRows = computed(() => unresolvedTrips.value.slice((unresolvedPage.value - 1) * unresolvedSize.value, unresolvedPage.value * unresolvedSize.value))
const unresolvedColumns: DataTableColumn[] = [ const unresolvedColumns: DataTableColumn[] = [
@@ -99,33 +120,33 @@ async function loadUnscheduled() {
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' } const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) }, { key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 }, { prop: 'inboundCode', label: '入仓码', minWidth: 220, singleLine: false, showOverflowTooltip: false },
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 150 }, { prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 180, singleLine: false, showOverflowTooltip: false },
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 }, { prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 }, { prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150, singleLine: false, showOverflowTooltip: false },
{ prop: 'packageCount', label: '箱数', width: 80 }, { prop: 'packageCount', label: '箱数', width: 80 },
{ prop: 'palletCount', label: '托数', width: 80 }, { prop: 'palletCount', label: '托数', width: 80 },
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 }, { prop: 'appointmentNo', label: '已有预约号', minWidth: 160, singleLine: false, showOverflowTooltip: false },
] ]
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const, key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16), date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`, title: `${arrival.voyageNo || arrival.cabinetNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
fields: [ fields: [
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') }, { label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
{ label: '目的港', value: arrival.destinationPortName }, { label: '目的港', value: arrival.destinationPortName },
{ label: '已装货柜号', value: arrival.containerNo }, { label: '已装货柜号', value: arrival.containerNo },
{ label: '柜次号', value: arrival.bookingNo }, { label: '柜次号', value: arrival.cabinetNo },
{ label: '货物去向', value: arrival.destinationWarehouseCode }, { label: '货物去向', value: arrival.destinationWarehouseCode },
], ],
})), ...pickups.value.map(pickup => ({ })), ...pickups.value.map(pickup => ({
key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const, key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const,
date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16), date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16),
title: `${pickup.containerNo || pickup.bookingNo || '未关联柜号'} 预约提柜`, title: `${pickup.containerNo || pickup.cabinetNo || '未关联柜号'} 预约提柜`,
sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`, sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`,
subtitle: '美国目的仓相关提柜安排', subtitle: '美国目的仓相关提柜安排',
fields: [ fields: [
{ label: '柜次号', value: pickup.bookingNo }, { label: '目的港', value: pickup.destinationPortName }, { label: '柜次号', value: pickup.cabinetNo }, { label: '目的港', value: pickup.destinationPortName },
{ label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName }, { label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName },
{ label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' }, { label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' },
{ label: '时间口径', value: '原提柜业务时间,港口时区待核实' }, { label: '时间口径', value: '原提柜业务时间,港口时区待核实' },
@@ -146,10 +167,12 @@ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival =>
].filter(field => field.value != null && field.value !== ''), ].filter(field => field.value != null && field.value !== ''),
inboundCodes: appointment.items.map(item => item.inboundCode), inboundCodes: appointment.items.map(item => item.inboundCode),
canEdit: access.can(permission.inboundOrderAppointment), canEdit: access.can(permission.inboundOrderAppointment),
canView: access.can('platform:appointment:list'),
canDelete: access.can('platform:appointment:delete'), canDelete: access.can('platform:appointment:delete'),
})), ...tripEvents.value]) })), ...tripEvents.value])
async function load(nextRange = range.value) { async function load(nextRange = range.value) {
unresolvedDialogOpen.value = false
range.value = nextRange range.value = nextRange
const request = ++calendarRequest const request = ++calendarRequest
calendarController?.abort() calendarController?.abort()
@@ -180,7 +203,7 @@ async function load(nextRange = range.value) {
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') }) else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
if (pickupResult.status === 'fulfilled' && pickupResult.value) { if (pickupResult.status === 'fulfilled' && pickupResult.value) {
pickups.value = pickupResult.value.records pickups.value = pickupResult.value.records
if (pickupResult.value.truncated || Number(pickupResult.value.unclassifiedCount) > 0) pickupNotice.value = '提柜来源有超出数量上限或目的仓国家待核实的记录,当前统计不完整,请缩小日期范围或完善仓库资料。' pickupNotice.value = [pickupResult.value.truncated ? '提柜安排超出读取上限,请缩小日期范围。' : '', Number(pickupResult.value.unclassifiedCount) > 0 ? `有 ${Number(pickupResult.value.unclassifiedCount)} 条提柜记录的目的仓国家待核实,请完善仓库资料后重新核对;这些记录未计入当前范围。` : ''].filter(Boolean).join(' ')
} else if (pickupResult.status === 'rejected') sourceErrors.value.push({ title: '提柜预约加载失败', details: getApiErrorDetails(pickupResult.reason, '提柜预约加载失败') }) } else if (pickupResult.status === 'rejected') sourceErrors.value.push({ title: '提柜预约加载失败', details: getApiErrorDetails(pickupResult.reason, '提柜预约加载失败') })
if (tripResult.status === 'fulfilled' && tripResult.value) { if (tripResult.status === 'fulfilled' && tripResult.value) {
const projection = projectTripCalendar(tripResult.value, nextRange) const projection = projectTripCalendar(tripResult.value, nextRange)
@@ -196,7 +219,17 @@ function openSource(event: CalendarEvent) {
if (event.sourcePath) void router.push(event.sourcePath) if (event.sourcePath) void router.push(event.sourcePath)
} }
function canLeave() {
if (saving.value || pendingSave.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
return [...childLeaveGuards].every(guard => guard())
}
function beforeCloseAppointment(done: () => void) { if (canLeave()) done() }
function closeAppointment() { if (canLeave()) dialogOpen.value = false }
onBeforeRouteLeave(canLeave)
onBeforeRouteUpdate(canLeave)
function openCreate(date: string) { function openCreate(date: string) {
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
forceReadOnly.value = false; cancellationHistoryOpen.value = false
++candidateRequest ++candidateRequest
++detailRequest ++detailRequest
detailLoading.value = false detailLoading.value = false
@@ -217,12 +250,13 @@ function openCreate(date: string) {
void searchWarehouses('') void searchWarehouses('')
} }
async function openEdit(event: Pick<CalendarEvent, 'key'>) { async function openEdit(event: Pick<CalendarEvent, 'key'>, readOnly = false) {
if (!canLeave()) return
forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false
const request = ++detailRequest const request = ++detailRequest
dialogError.value = null dialogError.value = null
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
pendingSave.value = null pendingSave.value = null
if (!editing.value) return
dialogOpen.value = true dialogOpen.value = true
detailLoading.value = true detailLoading.value = true
detailReady.value = false detailReady.value = false
@@ -287,7 +321,7 @@ async function loadCandidates() {
} }
async function save() { async function save() {
if (saving.value || detailLoading.value || !detailReady.value) return if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return
const validation = appointmentFormError(form) const validation = appointmentFormError(form)
if (!pendingSave.value && validation) { ElMessage.warning(validation); return } if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
if (!editing.value) { if (!editing.value) {
@@ -318,23 +352,21 @@ function selectCandidates(rows: object[]) {
selected.value = rows as InboundOrderSummaryDto[] selected.value = rows as InboundOrderSummaryDto[]
} }
async function remove(event: CalendarEvent) { function remove(event: CalendarEvent) {
const appointment = appointments.value.find(item => item.appointmentNo === event.key) if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
if (!appointment || loading.value) return cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
try {
await ElMessageBox.confirm(`确认取消预约 ${appointment.appointmentNo}?将释放全部 ${appointment.selectedCount} 票货物的占用,历史仍保留。`, '取消预约',
{ type: 'warning', confirmButtonText: '确认取消预约', cancelButtonText: '返回', customClass: 'admin-density-confirm' })
} catch { return }
loading.value = true
try {
await deleteAppointment(appointment.appointmentNo, appointment.version)
ElMessage.success('预约已取消,历史仍保留')
await load()
} catch (error) {
errorTitle.value = '预约取消失败'
loadError.value = getApiErrorDetails(error, '预约取消失败')
} finally { loading.value = false }
} }
function cancelEditing() {
if (!editing.value || editing.value.appointmentStatus === 'CANCELLED' || !access.can('platform:appointment:delete') || !canLeave()) return
cancellationTarget.value = editing.value
dialogOpen.value = false
}
async function cancelled() {
cancellationTarget.value = null; ++cancellationsRevision.value
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
await Promise.all([load(),loadUnscheduled()])
}
watch(dialogOpen, open => { watch(dialogOpen, open => {
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false } if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
@@ -369,35 +401,48 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
</script> </script>
<template> <template>
<AdminPage class="admin-density"> <AdminPage class="admin-density" spacing="compact">
<p v-if="linkLoading">正在核对预约货源…</p> <p v-if="linkLoading">正在核对预约货源…</p>
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" /> <LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" /> <AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" />
<div class="appointment-heading">
<div class="density-status-tabs" role="group" aria-label="预约工作视图"> <div class="density-status-tabs" role="group" aria-label="预约工作视图">
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button> <button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="switchWorkView('calendar')">预约日历</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">整柜提柜与接仓</button> <button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="switchWorkView('containers')">提柜与到仓</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">货物与预约合批</button> <button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="switchWorkView('cargo')">送仓与排车</button>
</div>
</div> </div>
<div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div> <div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
<AppointmentContainerPanel v-if="workView === 'containers'" /> <AppointmentContainerPanel v-if="workView === 'containers'" />
<div v-show="workView === 'calendar'"> <div v-if="calendarVisited" v-show="workView === 'calendar'">
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState> <LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<AppointmentCalendarPanel :notices="[pickupNotice, showTripLayer ? tripNotice : ''].filter(Boolean)" :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @view="openEdit($event, true)" @edit="openEdit" @delete="remove" @open="openSource">
<template #toolbar-actions>
<el-button aria-haspopup="dialog" data-unscheduled @click="unscheduledOpen = true">未定时安排({{ unscheduledError ? '加载失败' : unscheduledTotal }})</el-button>
<el-button aria-haspopup="dialog" data-cancellations @click="cancellationsOpen = true">取消记录</el-button>
</template>
<template #notice-actions><el-button v-if="showTripLayer && unresolvedTrips.length" link type="primary" @click="unresolvedDialogOpen = true">查看待核实车次({{ unresolvedTrips.length }})</el-button></template>
</AppointmentCalendarPanel>
</div>
<AppDialog v-if="unscheduledOpen" v-model="unscheduledOpen" title="未定时安排" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" /> <LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details> <DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" :results-visible="!unscheduledError" row-key="appointmentNo" empty-text="暂无未定时安排" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()">
<p v-if="pickupNotice" role="status">{{ pickupNotice }}</p> <template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template>
<p v-if="tripNotice" role="status">{{ tripNotice }}</p> </DataTable>
<details v-if="unresolvedTrips.length" class="appointment-extra"> </AppDialog>
<summary>待核实地点的车次({{ unresolvedTrips.length }})</summary> <AppDialog v-if="cancellationsOpen" v-model="cancellationsOpen" title="取消记录" width="min(1160px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
<p>检索窗口内的车次,尚无法确定当地日期。原计划时间保留 UTC,不用浏览器时区换算。</p> <AppointmentCancellationRecords :key="cancellationsRevision" @view="openEdit({key: $event}, true)" />
</AppDialog>
<AppDialog v-if="unresolvedDialogOpen" v-model="unresolvedDialogOpen" :title="`待核实车次(${unresolvedTrips.length})`" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog">
<p>这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。</p>
<DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1"> <DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1">
<template #actions="{ row }"><el-button class="density-action" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">查看来源</el-button></template> <template #actions="{ row }"><el-button class="density-action" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">查看来源</el-button></template>
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template> <template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
</DataTable> </DataTable>
</details> </AppDialog>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || !!tripNotice" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" @open="openSource" /> <AppointmentCancellationDialog v-if="cancellationTarget" :appointment-no="cancellationTarget.appointmentNo" :version="cancellationTarget.version" :selected-count="cancellationTarget.selectedCount" @close="cancellationTarget = null" @cancelled="cancelled" />
</div> <AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="!appointmentReadOnly" class="admin-density-dialog" :title="appointmentReadOnly ? '查看预约' : editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState> <LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<div v-loading="detailLoading"> <div v-loading="detailLoading">
<el-form label-position="top" class="calendar-form density-form"> <el-form label-position="top" class="calendar-form density-form">
@@ -410,7 +455,8 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
</el-form> </el-form>
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" /> <LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p> <p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="saving || !!pendingSave" /> <FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
<el-button v-if="editing && editing.appointmentStatus !== 'CANCELLED' && access.can('platform:appointment:delete')" data-cancel-editing type="danger" plain :disabled="saving || !!pendingSave || !detailReady" @click="cancelEditing">取消预约</el-button>
<template v-if="!editing && warehouse"> <template v-if="!editing && warehouse">
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()"> <DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template> <template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
@@ -422,6 +468,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<div v-else class="appointment-cargo"> <div v-else class="appointment-cargo">
<span>{{ editing.selectedCount }} 票货物</span> <span>{{ editing.selectedCount }} 票货物</span>
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" /> <AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
<details :key="`cancellations-${editing.appointmentNo}`" @toggle="cancellationHistoryOpen = ($event.target as HTMLDetailsElement).open"><summary>取消历史</summary><AppointmentCancellationHistory v-if="cancellationHistoryOpen" :key="editing.appointmentNo" :appointment-no="editing.appointmentNo" /></details>
</div> </div>
</div> </div>
</AppDialog> </AppDialog>
@@ -429,10 +476,10 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
</template> </template>
<style scoped> <style scoped>
.appointment-heading { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.appointment-heading .density-status-tabs { flex: 1; margin: 0; }
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); } .calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; } .calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; } .appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
.appointment-extra { margin-bottom: var(--admin-space-5); }
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; } .appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
</style> </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 placeholder="请填写实际取消原因" /></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>
@@ -4,18 +4,41 @@ import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { listPlanningCargo } from './planningApi' import { listPlanningCargo } from './planningApi'
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() })) vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 } const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
function render() { function render(attach = false) {
return mount(AppointmentCargoPanel, { global: { stubs: { 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>' }, 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>' }, QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, 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', () => { describe('appointment planning cargo query', () => {
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) }) 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 () => { it('uses one server-page request per entry, page change and reset', async () => {
const wrapper = render(); await flushPromises() const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(1) expect(listPlanningCargo).toHaveBeenCalledTimes(1)
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue' import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse' import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -13,6 +13,7 @@ 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 { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue' import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter() const router = useRouter()
@@ -61,7 +62,7 @@ function toggle(row: PlanningCargo, checked: unknown) {
} }
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row } function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
async function previewBatch() { async function previewBatch() {
if (!selectedRows.value.length || batchLoading.value) return if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
const current = ++batchRequest const current = ++batchRequest
batchLoading.value = true; batchError.value = null batchLoading.value = true; batchError.value = null
try { try {
@@ -105,7 +106,7 @@ const columns: DataTableColumn[] = [
{ prop: 'packages', label: '预报箱数', width: 100 }, { prop: 'packages', label: '预报箱数', width: 100 },
{ prop: 'receivedPackages', label: '实收箱数', width: 100 }, { prop: 'receivedPackages', label: '实收箱数', width: 100 },
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 }, { prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 170 }, { prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 }, { key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
] ]
@@ -133,16 +134,24 @@ async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId'
if (request === followupRequest) following.value = result if (request === followupRequest) following.value = result
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') } } 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()) onMounted(() => load())
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() }) onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
</script> </script>
<template> <template>
<div role="group" aria-label="预约货源类型">
<el-button :type="candidateMode === 'firstleg' ? 'primary' : 'default'" @click="candidateMode = 'firstleg'">头程货物</el-button>
<el-button :type="candidateMode === 'external' ? 'primary' : 'default'" @click="candidateMode = 'external'">外接货物</el-button>
</div>
<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)"> <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> <template #toolbar-actions>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</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> <el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
@@ -162,7 +171,12 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</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> <el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template> </template>
<template #filters><QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" /></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> <template #notices>
<div v-if="externalSource" class="cargo-note"> <div v-if="externalSource" class="cargo-note">
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }} 待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
@@ -179,6 +193,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</template> </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 #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 #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 }"> <template #progress="{ row }">
<span v-if="row.detained">暂扣,待处理</span> <span v-if="row.detained">暂扣,待处理</span>
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span> <span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
@@ -187,13 +202,13 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</template> </template>
</DataTable> </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" /> <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="batchPreview = 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.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p> <p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p> <p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p> <p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState> <LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
<FbaAppointmentFields :model="arrangement" :disabled="creating || !!pendingCreate" /> <FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
</AppDialog> </AppDialog>
</template> </template>
@@ -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>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -8,7 +8,11 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi' import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' 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 access = useAccessPreviewStore()
const query = ref<QueryModel>({ keyword: '' }) const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }] const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
@@ -16,26 +20,44 @@ const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total
const loading = ref(false), opening = ref(false) const loading = ref(false), opening = ref(false)
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null) const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const context = ref<ContainerFollowupContext | 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 let request = 0, contextRequest = 0, controller: AbortController | undefined
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 130, fixed: 'left' }, { key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 }, { prop: 'containerNo', label: '集装箱号', minWidth: 170 },
{ prop: 'orderCount', label: '美国范围票数', width: 120 }, { prop: 'orderCount', label: '美国范围票数', width: 120 },
{ prop: 'customerCount', label: '客户数', width: 90 }, { prop: 'customerCount', label: '客户数', width: 90 },
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 }, { key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 }, { key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false }, { 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 }, { key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
] ]
async function load(reset = false) { async function load(reset = false) {
if (reset) page.value = 1 if (reset) page.value = 1
const current = ++request const current = ++request
controller?.abort(); controller = new AbortController() controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
try { try {
const result = await listPlanningContainers({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal) 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 (current !== request) return
rows.value = result.records; total.value = result.total 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 } } } catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
finally { if (current === request) loading.value = false } finally { if (current === request) loading.value = false }
} }
@@ -54,22 +76,33 @@ onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
</script> </script>
<template> <template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)"> <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> <template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
<template #notices> <template #actions>
<LoadErrorState v-if="error" title="整柜货源加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
<LoadErrorState v-if="contextError" title="整柜范围核对失败" :message="contextError.message" :show-retry="false"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState> <el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
<p>同柜多客户只记录一份提柜跟进;接仓按实际仓库分别跟进。预计到港为来源快照,不是对方确认时间。</p>
</template> </template>
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</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>
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></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 #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 #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 #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> <template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
</DataTable> </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" /> <AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
</template> </template>
<style scoped> <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); } .receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
</style> </style>
@@ -44,7 +44,12 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
<template> <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)"> <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 #toolbar-actions><slot name="toolbar-actions" /></template>
<template #filters><QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" /></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> <template #notices>
<slot name="notices" /> <slot name="notices" />
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -1,15 +1,17 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi' 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('./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() } })) 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 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 } 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) { function render(canWrite = true, container?: ContainerFollowupContext) {
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: { return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
AppDialog: { props: ['confirmDisabled'], template: '<div><slot/><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' }, 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>' }, 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)" />' }, ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
ElSelect: true, ElOption: true, ElDatePicker: true, ElSelect: true, ElOption: true, ElDatePicker: true,
@@ -33,6 +35,12 @@ describe('appointment followup recovery', () => {
expect(saveFollowup).not.toHaveBeenCalled() expect(saveFollowup).not.toHaveBeenCalled()
wrapper.unmount() 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 () => { it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
const wrapper = render(); await flushPromises() const wrapper = render(); await flushPromises()
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复') await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
@@ -48,6 +56,15 @@ describe('appointment followup recovery', () => {
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放') await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
await wrapper.get('[data-save]').trigger('click'); await flushPromises() await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const first = vi.mocked(saveFollowup).mock.calls[0]![2] 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() await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first) expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
expect(first.idempotencyKey).toBeTruthy() expect(first.idempotencyKey).toBeTruthy()
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -73,15 +74,28 @@ async function save() {
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() } if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
} finally { saving.value = false } } 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) onMounted(load)
onBeforeUnmount(() => { ++generation }) onBeforeUnmount(() => { ++generation })
</script> </script>
<template> <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="emit('close')"> <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> <p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving" @click="emit('arrange')">加入预约合批</el-button> <el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
<p v-else>同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p> <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="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> <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-for="reason in blockReasons" :key="reason">{{ reason }}</p>
@@ -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" placeholder="填写后续处理事项" /></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>
@@ -1,31 +1,44 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import ElementPlus from 'element-plus' 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 { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue' import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue' import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningSource } from './planningApi' 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('./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', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) 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 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: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false } 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 } const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }
describe('appointment tables use actual shared table slots', () => { describe('appointment tables use actual shared table slots', () => {
beforeEach(() => vi.resetAllMocks()) 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 () => { 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 }) 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 } 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(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', sources: [source, external], missingFields: [] }) 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>' } } } }) const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises() await flushPromises()
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled() 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 wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises() await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1) 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') expect(wrapper.text()).toContain('B-1')
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('合批核对') && b.isVisible())!.trigger('click'); await flushPromises() await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
@@ -36,9 +49,9 @@ describe('appointment tables use actual shared table slots', () => {
it('combines an original external booking with first-leg cargo and retries the identical save command', async () => { 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} 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(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',sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']}) 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:[]}) 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:{template:'<section><slot /><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}}) 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 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()==='选择本页可合批货物')!.trigger('click') await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
@@ -46,6 +59,15 @@ describe('appointment tables use actual shared table slots', () => {
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]) 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() await wrapper.get('[data-save]').trigger('click');await flushPromises()
const first=vi.mocked(createSourceAppointment).mock.calls[0]![0] 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).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1') expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
await wrapper.get('[data-save]').trigger('click');await flushPromises() await wrapper.get('[data-save]').trigger('click');await flushPromises()
@@ -64,7 +86,7 @@ describe('appointment tables use actual shared table slots', () => {
}) })
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => { 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(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', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] }) 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>' } } } }) const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises() await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click') await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click')
@@ -74,14 +96,34 @@ describe('appointment tables use actual shared table slots', () => {
expect(resolvePlanningSource).not.toHaveBeenCalled() expect(resolvePlanningSource).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱') expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱')
expect(wrapper.text()).toContain('保存前尚未占用货物') 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() wrapper.unmount()
}) })
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => { it('renders one shared container and resolves each receiving warehouse without per-row requests', 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: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] }) 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: [] }) 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 } } }) const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
await flushPromises() await flushPromises()
expect(listPlanningContainers).toHaveBeenCalledTimes(1) expect(listPlanningContainers).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('本页待处理 1')
expect(wrapper.text()).toContain('处理船期变化')
expect(wrapper.text()).toContain('运营甲')
expect(resolvePlanningContainer).not.toHaveBeenCalled() expect(resolvePlanningContainer).not.toHaveBeenCalled()
const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row') const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row')
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
@@ -112,7 +154,7 @@ describe('appointment tables use actual shared table slots', () => {
}) })
it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => { 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}}) 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:{template:'<section><slot /></section>'}}}}) 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') await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
expect(wrapper.emitted('arrange')).toHaveLength(1) 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:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
@@ -6,6 +6,9 @@
| 页面/操作 | 原型承载与布局 | 实现约束 | | 页面/操作 | 原型承载与布局 | 实现约束 |
|---|---|---| |---|---|---|
| 补充计划时段 | 用户2026-10-12新增预约截图:开始、结束时段输入框各占整行过长 | 共用 FbaAppointmentFields 的补充区域沿用三列网格,计划开始、结束时段同排各占一列,与主字段对齐;夏令时说明整行、两个时差输入同排。日历新增/编辑、合批安排、入仓单预约同步生效;字段语义及保存不变,待人工截图确认 |
| 预约补充记录入口 | 用户2026-10-12截图1:原顶部未定时安排、取消记录两行默认折叠文字要求优化 | 用户2026-10-12后续确认覆盖页内展开方案:入口并入日历工具栏,与数据提示同组;未定时安排1000px、取消记录1160px居中弹框,保留独立分页及错误重试,取消记录仅打开时加载。新增预约紧邻日历标题,月/周及日期操作连续排列 |
| 送仓与排车货源页签 | 用户2026-10-12截图2、3:参考尾程订单下划线页签 | 头程货物、外接货物通过同一局部页签组件复用 density-status-tabs,两种模式均放入表格 filters 的 integrated-query-group 内并紧邻搜索;点击及方向键/Home/End切换,保留跨货源选择;新增、编辑、取消预约沿用原承载方式。待人工1920×1080、100%截图确认 |
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 | | 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
| 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 | | 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 | | 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
@@ -23,9 +26,20 @@
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 | | 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 | | 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 | | 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
| 船期变化影响与处理 | 统一方案第十节场景16,旧/新预测、下游影响、负责人及结果闭环 | 并入提柜与到仓的货柜列表,不设第四工作视图;行内处理船期变化,本页待办计数明确标识范围;显式本页批量核对,不逐柜轮询。1200px居中详情展示直接货物/预约、关联车次与同车待复核承诺;各明细220px内滚动,历史服务端分页。认领及结果表单复用现有控件;未知提交保留原命令并禁止关闭,已确认预约不因核对动作改变。待1920×1080真实验收 |
结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。 结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。
本轮视觉验收:待人工1920×1080截图确认。 本轮视觉验收:待人工1920×1080截图确认。
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。 视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
按用户确认收敛:主视图仅提柜与到仓、送仓与排车、预约日历,默认有权限的列表视图;日历首次进入才加载。到港参考、用车计划默认隐藏,可独立开关,不计入预约数量;缺国家资料与查询超限分开提示。送仓与排车目前仍以选货合批为主,目的仓货量汇总及7/14/30天窗口尚未完成,不因标题调整宣称完成。
用户截图密度修正:业务标题复用density-detail-title并与三视图同行,不独占大标题区;船期操作并入查询栏,不单独占空白工具栏。常驻业务解释改为默认折叠“说明”;错误保留重试,诊断信息默认折叠。基础货柜与船期状态独立读取,后者失败不阻断货柜列表、不展示假的零待办。
用户最新截图确认:移除“美国预约总览”文字,仅保留三视图导航;日历图例和图层开关归入有内边距的辅助栏,长范围提示默认折叠,月格112px仍容纳3条摘要及剩余数;右侧固定360px,系统单号/完整时段/备注跨整行。该项以当前用户要求覆盖原标题建议,待1920×1080实际页面确认。
用户最新确认覆盖默认列表建议:预约日历排第一并默认打开,之后为提柜与到仓、送仓与排车;范围资料提示移入日历工具栏“数据提示”,点击展开浮层,不在日历上方占行。真实接口错误仍保留错误与重试入口。
用户确认待核实车次不占日历顶部:入口并入“数据提示”,显式点击后打开1000px居中分页明细;保留原UTC、缺项及查看来源操作,未打开不渲染明细,不增加查询;日期范围变化关闭旧明细。
+26 -4
View File
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface ArrivalCalendarEntry { export interface ArrivalCalendarEntry {
containerBookingId: string containerBookingId: string
bookingNo: string cabinetNo: string
containerNo?: string | null containerNo?: string | null
vesselName?: string | null vesselName?: string | null
voyageNo?: string | null voyageNo?: string | null
@@ -14,7 +14,7 @@ export interface ArrivalCalendarEntry {
export interface PickupCalendarEntry { export interface PickupCalendarEntry {
containerBookingId: string containerBookingId: string
bookingNo: string cabinetNo: string
containerNo: string | null containerNo: string | null
destinationPortName: string | null destinationPortName: string | null
destinationWarehouseCode: string | null destinationWarehouseCode: string | null
@@ -42,10 +42,32 @@ export async function listArrivalCalendar(query: { from: string; to: string }, s
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function deleteAppointment(appointmentNo: string, version: number) { export async function deleteAppointment(appointmentNo: string, version: number, cancelReason: string) {
const response = await http.delete<ApiEnvelope<void>, ApiEnvelope<void>>( const response = await http.delete<ApiEnvelope<void>, ApiEnvelope<void>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`, `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
{ params: { version } }, { data: { version, cancelReason } },
) )
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export interface AppointmentCancellation {
appointmentNo: string; version: number; cancelReason: string | null; cancelledAt: string
snapshot: { fbaWarehouseCode: string; appointmentStatus: string; appointmentRemark: string | null;
plannedDate: string | null; plannedAt: string | null; plannedEndAt: string | null;
confirmationNo: string | null; appointmentAt: string | null; appointmentEndAt: string | null;
timezone: string | null; selectedCount: number }
}
export async function listAppointmentCancellations(appointmentNo: string, page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: AppointmentCancellation[]; total: number}>, ApiEnvelope<{records: AppointmentCancellation[]; total: number}>>(
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}/cancellations`, {params: {page, size}},
))
return {...result, total: Number(result.total)}
}
export type AppointmentCancellationSummary = Omit<AppointmentCancellation, 'snapshot'> & {fbaWarehouseCode: string; selectedCount: number}
export async function listAppointmentCancellationRecords(page: number, size: number) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>, ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>>(
'/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations', {params: {page, size}},
))
return {...result, total: Number(result.total)}
}
@@ -0,0 +1,31 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { PlanningContainer } from './planningApi'
export interface ForecastFact { estimatedAt: string | null; actualAt: string | null; observedAt: string | null; providerCode: string | null; sourceReference: string | null }
export interface Impact { containerBookingId: string; impactNo: string; version: number; status: string; reason: string | null; ownerName: string | null; ownerId: string | null; baseline: ForecastFact | null; latest: ForecastFact | null; forecastToken: string; updatedAt: string }
export interface ImpactRow { container: PlanningContainer; impact: Impact | null }
export interface ImpactDetail {
impact: Impact
cargo: Array<{ inboundCode: string | null; customerName: string | null; packages: number | null; destination: string | null }>
appointments: Array<{ appointmentNo: string; status: string; destination: string; confirmedAt: string | null; timezone: string | null }>
transport: { trips: Array<{ tripNo: string; status: string; plannedDepartureAt: string | null }>; sameTripBookings: Array<{ tripNo: string; bookingNo: string; customerName: string | null; destinationLabel: string | null; status: string }> } | null
history: { records: Array<{ version: number; action: string; operatorName: string | null; result: string | null; nextAction: string | null; followupDate: string | null; createdAt: string }>; total: number }
}
export interface ImpactCommand { expectedVersion: number; forecastToken: string; idempotencyKey: string; action: 'CLAIM' | 'RECORD' | 'RESOLVE'; result: string | null; nextAction: string | null; followupDate: string | null }
const base = '/platform/v1/last-leg/appointment-planning/impacts'
export async function listImpacts(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ImpactRow[]; total: number }>, ApiEnvelope<{ records: ImpactRow[]; total: number }>>(base, { params: query, signal }))
return { ...result, total: Number(result.total) }
}
export async function refreshImpacts(query: { page: number; size: number; keyword?: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<Impact[]>, ApiEnvelope<Impact[]>>(`${base}/refresh`, query))
}
export async function getImpact(id: string, page = 1) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<ImpactDetail>, ApiEnvelope<ImpactDetail>>(`${base}/${encodeURIComponent(id)}`, { params: { page, size: 20 } }))
return { ...result, impact: { ...result.impact, version: Number(result.impact.version) }, history: { ...result.history, total: Number(result.history.total) } }
}
export async function handleImpact(id: string, command: ImpactCommand) {
return unwrapApiResponse(await http.post<ApiEnvelope<Impact>, ApiEnvelope<Impact>>(`${base}/${encodeURIComponent(id)}/commands`, command))
}
export const impactStatus: Record<string, string> = { OPEN: '待处理', RESOLVED: '已核对处理', MONITORING: '暂无变化' }
export const impactReason: Record<string, string> = { FORECAST_MISSING: '到港预测待核实', FORECAST_CHANGED: '到港预测变化', ACTUAL_ARRIVAL_REVIEW: '实际到港已更新', SOURCE_CHANGED: '预测来源变化', UNCHANGED: '预测未变', BASELINE_ESTABLISHED: '已建立核对基准' }
@@ -1,6 +1,6 @@
import { expect, it, vi } from 'vitest' import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { listPlanningCargo } from './planningApi' import { listPlanningCargo, listPlanningContainers } from './planningApi'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data })) vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: <T>(value: { data: T }) => value.data }))
it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => { it('uses the gateway planning route and preserves stable source IDs and null predictions', async () => {
@@ -12,3 +12,11 @@ it('uses the gateway planning route and preserves stable source IDs and null pre
expect(result.records[0]!.estimatedArrivalAt).toBeNull() expect(result.records[0]!.estimatedArrivalAt).toBeNull()
expect(result.total).toBe(21) expect(result.total).toBe(21)
}) })
it('keeps basic container listing on its own endpoint rather than requiring impact persistence', async () => {
vi.mocked(http.get).mockClear()
vi.mocked(http.get).mockResolvedValue({ data: { records: [], total: '0' } })
const result = await listPlanningContainers({ page: 1, size: 20 })
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/appointment-planning/containers', { params: { page: 1, size: 20 }, signal: undefined })
expect(result.total).toBe(0)
})
@@ -76,6 +76,7 @@ export interface PlanningBatchPreview {
group: { originWarehouseId: string; destinationKey: string; packages: number | null; sourceCount: number; customerCount: number; readinessIncomplete: boolean; readyFrom: string | null; readyTo: string | null; unknownQuantitySources: number } group: { originWarehouseId: string; destinationKey: string; packages: number | null; sourceCount: number; customerCount: number; readinessIncomplete: boolean; readyFrom: string | null; readyTo: string | null; unknownQuantitySources: number }
originWarehouseName: string originWarehouseName: string
destinationWarehouseCode: string destinationWarehouseCode: string
destinationTimezone: string | null
sources: PlanningSource[] sources: PlanningSource[]
missingFields: string[] missingFields: string[]
} }
@@ -100,6 +101,7 @@ export async function resolvePlanningSource(query: { sourceKey: string; customer
} }
export interface PlanningContainer { export interface PlanningContainer {
impact?: import('./impactApi').Impact | null
containerBookingId: string; containerNo: string | null; orderCount: number; customerCount: number; containerBookingId: string; containerNo: string | null; orderCount: number; customerCount: number;
packages: number | null; unknownPackageOrders: number; earliestArrivalAt: string | null; latestArrivalAt: string | null; packages: number | null; unknownPackageOrders: number; earliestArrivalAt: string | null; latestArrivalAt: string | null;
standardNode: string; sourceVersion: string | null; incompleteScope: boolean; standardNode: string; sourceVersion: string | null; incompleteScope: boolean;
@@ -118,6 +120,7 @@ export async function listPlanningContainers(query: { page: number; size: number
warehouses: row.warehouses.map(warehouse => ({ ...warehouse, orderCount: Number(warehouse.orderCount), customerCount: Number(warehouse.customerCount) })), warehouses: row.warehouses.map(warehouse => ({ ...warehouse, orderCount: Number(warehouse.orderCount), customerCount: Number(warehouse.customerCount) })),
})) } })) }
} }
export async function resolvePlanningContainer(containerId: string, warehouseId?: string) { export async function resolvePlanningContainer(containerId: string, warehouseId?: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<ContainerFollowupContext>, ApiEnvelope<ContainerFollowupContext>>(`${containerBase}/${encodeURIComponent(containerId)}`, { params: { warehouseId } })) return unwrapApiResponse(await http.get<ApiEnvelope<ContainerFollowupContext>, ApiEnvelope<ContainerFollowupContext>>(`${containerBase}/${encodeURIComponent(containerId)}`, { params: { warehouseId } }))
} }
@@ -182,7 +182,7 @@ describe('platform container loading API', () => {
it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => { it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }], records: [{ bookingId: 9007199254740993n, cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
page: '1', size: '200', total: '1', pages: '1', page: '1', size: '200', total: '1', pages: '1',
} }) } })
@@ -8,7 +8,7 @@ import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() })) vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = { const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 }, container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9', loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] }, pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }], documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
@@ -10,7 +10,7 @@ vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() })) vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
const container = { const container = {
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING', id: '9001', cabinetNo: 'CAB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null,
oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null,
@@ -75,9 +75,9 @@ describe('ContainersView', () => {
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 }) vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
}) })
it('loads the real paged contract and renders core container fields', async () => { it('loads the real paged contract and renders core container fields', async () => {
@@ -296,7 +296,7 @@ describe('ContainersView', () => {
it('uses stable view labels when cabinetNo is missing', async () => { it('uses stable view labels when cabinetNo is missing', async () => {
const base = { const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0, id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
@@ -305,9 +305,9 @@ describe('ContainersView', () => {
} }
vi.mocked(listContainers).mockResolvedValueOnce({ vi.mocked(listContainers).mockResolvedValueOnce({
records: [ records: [
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' }, { ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2' },
{ ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' }, { ...base, id: '9003', cabinetNo: '', containerNo: null },
{ ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' }, { ...base, id: '9004', cabinetNo: null, containerNo: null },
], ],
page: 1, size: 20, total: 3, pages: 1, page: 1, size: 20, total: 3, pages: 1,
} as never) } as never)
@@ -319,13 +319,13 @@ describe('ContainersView', () => {
const labels = wrapper.findAll('.el-table__body button') const labels = wrapper.findAll('.el-table__body button')
.map((button) => button.attributes('aria-label')) .map((button) => button.attributes('aria-label'))
.filter((label): label is string => Boolean(label)) .filter((label): label is string => Boolean(label))
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情'])) expect(labels).toEqual(['查看 MSCU-2', '查看货柜详情', '查看货柜详情'])
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true) expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
}) })
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => { it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = { const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0, id: '9001', planId: '9101', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
@@ -632,7 +632,7 @@ describe('ContainersView', () => {
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => { it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{ vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null, id: '215', planId: null, cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING', channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7, capacityCbm: 86, maxWeightKg: 29000, version: 7,
@@ -679,7 +679,7 @@ describe('ContainersView', () => {
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => { it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{ vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null, id: '315', planId: null, cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING', channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7, capacityCbm: 86, maxWeightKg: 29000, version: 7,
@@ -313,7 +313,7 @@ function localDateTimeValue() {
return now.toISOString().slice(0, 19) return now.toISOString().slice(0, 19)
} }
function containerViewLabel(row: ContainerBookingResource) { function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo] const reference = [row.cabinetNo, row.containerNo]
.find((value) => typeof value === 'string' && value.trim()) .find((value) => typeof value === 'string' && value.trim())
return reference ? `查看 ${reference}` : '查看货柜详情' return reference ? `查看 ${reference}` : '查看货柜详情'
} }
@@ -64,7 +64,7 @@ vi.mock('@/modules/warehouses', () => ({
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) })) vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) }))
const container = { const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船', billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船',
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
@@ -101,7 +101,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records.
function containerPage(records: ContainerBookingResource[] = [container]) { function containerPage(records: ContainerBookingResource[] = [container]) {
return { return {
records: records.map((item) => ({ records: records.map((item) => ({
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo, bookingId: item.id, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelGroupCode: item.channelGroupCode, warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelGroupCode: item.channelGroupCode,
channelCode: item.channelCode, channelName: item.channelName, channelCode: item.channelCode, channelName: item.channelName,
containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg, containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
@@ -18,7 +18,7 @@ vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups:
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const container = { const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
+1 -3
View File
@@ -4,7 +4,6 @@ export interface ContainerBookingResource {
id: string id: string
planId: string | null planId: string | null
planNo?: string | null planNo?: string | null
bookingNo: string
containerNo: string | null containerNo: string | null
cabinetNo: string cabinetNo: string
sourceType?: 'SIMULATION' | 'OCEAN_BOOKING' sourceType?: 'SIMULATION' | 'OCEAN_BOOKING'
@@ -175,7 +174,7 @@ export interface ContainerTimelineEntry {
} }
export interface ContainerOverview { export interface ContainerOverview {
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string } container: Pick<ContainerBookingResource, 'id' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
loadingPlanId: string | null loadingPlanId: string | null
pda: ContainerPdaSummary | null pda: ContainerPdaSummary | null
documents: ContainerOverviewDocument[] documents: ContainerOverviewDocument[]
@@ -373,7 +372,6 @@ export interface SmartLoadingCandidatePage {
export interface SmartLoadingWorkbenchContainer { export interface SmartLoadingWorkbenchContainer {
bookingId: string bookingId: string
bookingNo: string
cabinetNo: string cabinetNo: string
containerNo: string | null containerNo: string | null
warehouseId: string warehouseId: string
@@ -21,7 +21,7 @@ vi.mock('../api/platformOutboundDispatch', () => ({ listOutboundDispatches: vi.f
const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户', legalName: '示例客户有限公司', status: 'ACTIVE' } as PlatformTenantResource const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户', legalName: '示例客户有限公司', status: 'ACTIVE' } as PlatformTenantResource
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
const container = { id: '40001', bookingNo: 'BK-001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource const container = { id: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
@@ -41,7 +41,7 @@ let disposed = false
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } }, { key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '入仓码', width: 280, placeholder: filters.value.customerId ? '请选择该客户的入仓码' : '请选择入仓码', options: orders.value.map((item) => ({ label: item.inboundCode || '入仓码暂缺', value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } }, { key: 'orderId', type: 'select', label: '入仓码', width: 280, placeholder: filters.value.customerId ? '请选择该客户的入仓码' : '请选择入仓码', options: orders.value.map((item) => ({ label: item.inboundCode || '入仓码暂缺', value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } }, { key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || '—'}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
]) ])
const columns = [ const columns = [
@@ -41,6 +41,39 @@ beforeEach(() => {
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }) vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
}) })
describe('procurement purchase node operations', () => { describe('procurement purchase node operations', () => {
it('disables terminal physical changes and keeps the management evidence form open', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02', active: true }]
const wrapper = render(value); const dialog = await open(wrapper)
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' })
expect(selector.props('modelValue')).toBe('control-notes')
const options = selector.findAllComponents({ name: 'ElOption' })
expect(options.find(option => option.props('value') === 'receipts/correct')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('订单已结束') })
expect(options.find(option => option.props('value') === 'control-notes')!.props('disabled')).toBe(false)
expect(options.find(option => option.props('value') === 'actual-warehouse/correct')!.props('disabled')).toBe(false)
selector.vm.$emit('update:modelValue', 'receipts/correct'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
await confirm(wrapper)
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(storageKey)).toBeNull()
})
it('disables overseas receipt candidates after international shipment without blocking domestic correction', async () => {
const value = fixture('MANAGE'); value.customerView.order.status = 'DOMESTIC_RECEIVING'
value.purchaseFacts.overseas_shipped_at = '2020-01-02'
const receipt = { id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS' as const, sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-01', active: true }
value.physical.receipts = [receipt, { ...receipt, id: '12', receiptNo: 'PR-12', leg: 'DOMESTIC', sourceCartonId: '31' }]
const wrapper = render(value); const dialog = await open(wrapper)
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'receipts/correct')
await flushPromises()
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '待更正实收')!.findComponent({ name: 'ElSelect' })
const options = selector.findAllComponents({ name: 'ElOption' })
expect(options.find(option => option.props('value') === '11')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('本段已实际交寄') })
expect(options.find(option => option.props('value') === '12')!.props('disabled')).toBe(false)
selector.vm.$emit('update:modelValue', '12'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
expect(submitProcurementCommand).not.toHaveBeenCalled()
})
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => { it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
const value = fixture('FINANCE_ADJUST', false) const value = fixture('FINANCE_ADJUST', false)
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }] value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
@@ -181,7 +214,7 @@ describe('procurement purchase node operations', () => {
expect(submitProcurementCommand).toHaveBeenCalledTimes(1) expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
}) })
it('uses exception evidence and source quantities for damaged goods disposition', async () => { it('uses exception evidence and source quantities for damaged goods disposition', async () => {
const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2 const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2; value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 1, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }] value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 1, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }]
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '12', fileId: '102', category: 'EXCEPTION', fileName: 'damage.png', contentType: 'image/png', sizeBytes: 3, createdAt: '' }) vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '12', fileId: '102', category: 'EXCEPTION', fileName: 'damage.png', contentType: 'image/png', sizeBytes: 3, createdAt: '' })
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '破损样品已经处理,客户已确认') const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '破损样品已经处理,客户已确认')
@@ -240,9 +273,26 @@ describe('procurement purchase node operations', () => {
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper) const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(document.body.textContent).toContain('订单版本冲突'); expect(wrapper.emitted('changed')).toBeUndefined() expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(document.body.textContent).toContain('订单版本冲突'); expect(wrapper.emitted('changed')).toBeUndefined()
}) })
it('hides actions without permission and disables actions blocked by the server', async () => { it('hides unauthorized actions and preserves blocked node reasons without a submit button', async () => {
let wrapper = render(fixture(), []); expect(wrapper.find('.purchase-actions button').exists()).toBe(false); wrapper.unmount() let wrapper = render(fixture(), []); expect(wrapper.find('.purchase-actions button').exists()).toBe(false); wrapper.unmount()
wrapper = render(fixture('REVIEW', false)); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined() wrapper = render(fixture('REVIEW', false))
expect(wrapper.find('.purchase-actions button').exists()).toBe(false)
expect(wrapper.find('details').text()).toContain('审核采购')
expect(wrapper.find('details').text()).toContain('业务条件不满足')
expect(submitProcurementCommand).not.toHaveBeenCalled()
})
it('keeps executable workflow actions in front and financial exceptions in a closed group', () => {
const detail = fixture('PURCHASE')
detail.actions.push(...fixture('FINANCE_ADJUST').actions, ...fixture('PACK', false).actions)
const wrapper = render(detail)
const primary = wrapper.get('[data-testid="current-procurement-actions"]')
expect(primary.text()).toContain('登记购买完成')
expect(primary.text()).not.toContain('费用更正与退款')
expect(primary.text()).not.toContain('保存装箱草稿')
const groups = wrapper.findAll('details')
expect(groups[0].text()).toContain('费用更正与退款')
expect(groups[0].attributes('open')).toBeUndefined()
expect(groups[1].text()).toContain('业务条件不满足')
}) })
it('prevents a second submit while the first command is pending', async () => { it('prevents a second submit while the first command is pending', async () => {
let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve })) let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve }))
@@ -21,13 +21,17 @@ import { financeCommands, financeForm, buildFinance, feeRetryPermission } from '
import ProcurementTransportFields from './ProcurementTransportFields.vue' import ProcurementTransportFields from './ProcurementTransportFields.vue'
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm' import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue' import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
import { physicalCommands, physicalForm, buildPhysical } from '../procurementPhysicalForm' import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>() const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
const emit = defineEmits<{ changed: [] }>() const emit = defineEmits<{ changed: [] }>()
const auth = useAuthStore(); const access = useAccessPreviewStore() const auth = useAuthStore(); const access = useAccessPreviewStore()
const routes: Record<string, ProcurementCommand> = { REVIEW: 'review', PURCHASE: 'purchase', CONFIRM_PURCHASE: 'purchase/confirm', AUTHORIZE_BUDGET: 'budget-authorizations', PLAN_WAREHOUSE: 'planned-warehouse', CANCEL: 'cancel', RECEIVE_OVERSEAS: 'overseas-receipts', RECEIVE_DOMESTIC: 'domestic-receipts', FINISH_OVERSEAS: 'overseas-receipts/complete', FINISH_DOMESTIC: 'domestic-receipts/complete', PACK: 'packing', CONFIRM_PACKING: 'packing/confirm', MANAGE: 'dispositions', LABELS: 'labels', ESTIMATE: 'international-estimate', APPLY_LABELS: 'labels/apply', SHIP_OVERSEAS: 'overseas-shipment', SHIP_CUSTOMER: 'customer-shipment', REPACK: 'domestic-repack', CONFIRM_CHARGES: 'charges/confirm', FINANCE_ADJUST: 'fees/correct-target', RETRY_FEE: 'fees/retry', EXCEPTION_CLOSE: 'exception-close' } const routes: Record<string, ProcurementCommand> = { REVIEW: 'review', PURCHASE: 'purchase', CONFIRM_PURCHASE: 'purchase/confirm', AUTHORIZE_BUDGET: 'budget-authorizations', PLAN_WAREHOUSE: 'planned-warehouse', CANCEL: 'cancel', RECEIVE_OVERSEAS: 'overseas-receipts', RECEIVE_DOMESTIC: 'domestic-receipts', FINISH_OVERSEAS: 'overseas-receipts/complete', FINISH_DOMESTIC: 'domestic-receipts/complete', PACK: 'packing', CONFIRM_PACKING: 'packing/confirm', MANAGE: 'dispositions', LABELS: 'labels', ESTIMATE: 'international-estimate', APPLY_LABELS: 'labels/apply', SHIP_OVERSEAS: 'overseas-shipment', SHIP_CUSTOMER: 'customer-shipment', REPACK: 'domestic-repack', CONFIRM_CHARGES: 'charges/confirm', FINANCE_ADJUST: 'fees/correct-target', RETRY_FEE: 'fees/retry', EXCEPTION_CLOSE: 'exception-close' }
const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && fee.walletStatus.endsWith('_FAILED') && access.can(feeRetryPermission(fee)))) const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && fee.walletStatus.endsWith('_FAILED') && access.can(feeRetryPermission(fee))))
const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])]) const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])])
const secondaryKeys = new Set(['AUTHORIZE_BUDGET', 'PLAN_WAREHOUSE', 'MANAGE', 'FINANCE_ADJUST', 'CANCEL', 'EXCEPTION_CLOSE'])
const currentActions = computed(() => actions.value.filter(action => action.allowed && !secondaryKeys.has(action.key)))
const secondaryActions = computed(() => actions.value.filter(action => secondaryKeys.has(action.key)))
const unavailableActions = computed(() => actions.value.filter(action => !action.allowed && !secondaryKeys.has(action.key)))
const opened = ref(false); const active = ref<ProcurementAction | null>(null); const saving = ref(false); const uploading = ref(false) const opened = ref(false); const active = ref<ProcurementAction | null>(null); const saving = ref(false); const uploading = ref(false)
const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); const pending = ref<PendingProcurement | null>(null) const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); const pending = ref<PendingProcurement | null>(null)
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('') const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
@@ -45,6 +49,17 @@ const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : ''
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose))) const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' } const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined)) const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
const managementBlock = computed(() => {
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
const reason = managementReason(command.value, props.detail)
if (reason) return reason
if (command.value === 'dispositions') return dispositionReason(props.detail, physical.value.leg)
const row = command.value === 'receipts/correct'
? props.detail.physical.receipts.find(item => item.id === physical.value.receiptId)
: command.value === 'dispositions/revoke' ? props.detail.physical.dispositions.find(item => item.id === physical.value.dispositionId) : null
return row ? receiptCorrectionReason(props.detail, row.leg) : null
})
const isControl = computed(() => !!command.value && controlCommands.has(command.value)) const isControl = computed(() => !!command.value && controlCommands.has(command.value))
const isFinance = computed(() => !!command.value && financeCommands.has(command.value)) const isFinance = computed(() => !!command.value && financeCommands.has(command.value))
const isTransport = computed(() => !!command.value && transportCommands.has(command.value)) const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
@@ -73,7 +88,7 @@ async function loadWarehouses() {
} }
function open(action: ProcurementAction) { function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return if (!action.allowed || pending.value || storageError.value || saving.value) return
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail) editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; physical.value = physicalForm(props.detail)
control.value = controlForm(props.detail) control.value = controlForm(props.detail)
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target' finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS') transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
@@ -146,6 +161,7 @@ async function submit() {
if (!pending.value) { if (!pending.value) {
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
try { try {
if (managementBlock.value) throw new Error(managementBlock.value)
const request = { command: command.value, payload: build() } const request = { command: command.value, payload: build() }
const serialized = JSON.stringify(request); readPendingProcurement(serialized) const serialized = JSON.stringify(request); readPendingProcurement(serialized)
sessionStorage.setItem(scope, serialized); pending.value = request sessionStorage.setItem(scope, serialized); pending.value = request
@@ -183,14 +199,25 @@ async function upload(event: Event) {
</script> </script>
<template> <template>
<section class="admin-density" aria-label="采购履约节点操作"> <section class="admin-density" aria-label="采购履约节点操作">
<div class="density-actions purchase-actions"><el-tooltip v-for="action in actions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div> <div class="density-actions purchase-actions" data-testid="current-procurement-actions"><el-tooltip v-for="action in currentActions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div>
<el-button v-if="canDraft" :disabled="saving || uploading" @click="openCorrectionDraft">{{ correctionDraft ? '恢复费用更正草稿' : '准备费用更正草稿' }}</el-button><el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" /> <details v-if="secondaryActions.length || canDraft" class="purchase-secondary-actions">
<summary>更正、财务及异常处理</summary>
<div class="density-actions purchase-actions"><el-tooltip v-for="action in secondaryActions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip>
<el-button v-if="canDraft" :disabled="saving || uploading" @click="openCorrectionDraft">{{ correctionDraft ? '恢复费用更正草稿' : '准备费用更正草稿' }}</el-button>
</div>
</details>
<details v-if="unavailableActions.length" class="purchase-secondary-actions">
<summary>其他履约节点及不可操作原因</summary>
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
</details>
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert> <el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
<ApiErrorMeta v-if="error && !opened" :details="error" /> <ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit"> <AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" /> <template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)"> <el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option value="dispositions" label="记录差异处置" /><el-option value="receipts/correct" label="更正原实收数量" /><el-option value="dispositions/revoke" label="撤销原差异处置" /><el-option value="packing/withdraw" label="撤回未交寄的确认清单" /><el-option value="control-notes" label="追加事实说明或阻断" /><el-option value="control-notes/resolve" label="解除已处理的阻断" /><el-option value="actual-warehouse/correct" label="实际仓受控更正" /></el-select></el-form-item> <el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item> <el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
<ProcurementControlFields v-if="isControl && command" v-model="control" :command="command" :detail="detail" :files="allFiles" /> <ProcurementControlFields v-if="isControl && command" v-model="control" :command="command" :detail="detail" :files="allFiles" />
<template v-if="editingDraft"><el-form-item label="原费用"><el-select v-model="finance.feeId" placeholder="选择本单原实际费用"><el-option v-for="fee in detail.fees.filter(row => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(row.purpose))" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${fee.feeItemName}`" /></el-select></el-form-item><el-form-item label="正确原币总额"><el-input v-model="finance.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item></template> <template v-if="editingDraft"><el-form-item label="原费用"><el-select v-model="finance.feeId" placeholder="选择本单原实际费用"><el-option v-for="fee in detail.fees.filter(row => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(row.purpose))" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${fee.feeItemName}`" /></el-select></el-form-item><el-form-item label="正确原币总额"><el-input v-model="finance.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item></template>
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import { computed } from 'vue' import { computed } from 'vue'
import type { ProcurementCommand } from '../api/procurementCommands' import type { ProcurementCommand } from '../api/procurementCommands'
import type { PlatformProcurement } from '../procurementTypes' import type { PlatformProcurement } from '../procurementTypes'
import { newCarton, shippedCartons, type PhysicalForm } from '../procurementPhysicalForm' import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>() const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
const form = defineModel<PhysicalForm>({ required: true }) const form = defineModel<PhysicalForm>({ required: true })
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null))))) const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
@@ -22,12 +22,12 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" /> <el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" /> <el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" /> <el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS">海外收货</el-radio><el-radio value="DOMESTIC">国内收货</el-radio></el-radio-group></el-form-item> <el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item> <el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item> <el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次合格件数" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次破损件数" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次多到件数" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template> <template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次合格件数" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次破损件数" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次多到件数" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template> <template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}`" /></el-select></el-form-item> <el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
<template v-if="command === 'packing'"> <template v-if="command === 'packing'">
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" /> <el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box"> <section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
@@ -206,6 +206,19 @@ describe('overseas business mounted pages', () => {
}) })
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') }) it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(PurchaseOrdersView)
await flushPromises()
const cells = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')[0]!.findAll('td')
expect(cells[0]!.classes()).toContain('el-table-fixed-column--left')
for (const [index, value] of [record.orderNo, record.customerName, record.purchaseWarehouseName, record.purchaseCategory, record.productName, record.productSpecification].entries()) {
expect(cells[index + 1]!.text()).toBe(value)
expect(cells[index + 1]!.classes()).not.toContain('data-table__single-line-cell')
expect(cells[index + 1]!.find('.cell').classes()).not.toContain('el-tooltip')
}
})
it('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) }) it('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) }) it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) }) it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
@@ -268,4 +281,17 @@ describe('overseas business mounted pages', () => {
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() }) it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
return (async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '承运安排')!
expect(dialog.props('modelValue')).toBe(matching); expect(dialog.props('showConfirm')).toBe(false)
await wrapper.findAll('button').find(item => item.text() === '返回原拼车登记')!.trigger('click')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: matching ? arrangement.bookingNo : 'FB-OTHER' } })
expect(updateUsTruckOrder).not.toHaveBeenCalled()
})()
})
}) })
@@ -30,12 +30,12 @@ const fields = computed<QueryField[]>(() => [
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' }, { key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' }, { key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 }, { key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130, singleLine: false },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 }, { key: 'productName', prop: 'productName', label: '商品名称', minWidth: 220, singleLine: false },
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' }, { key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) }, { key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) },
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' }, { key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
{ key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) }, { key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data' import { inquiryReturnTarget, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Plus } from '@lucide/vue' import { Plus } from '@lucide/vue'
@@ -76,6 +76,9 @@ const columns = computed(() => {
}) })
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' } function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function load() { async function load() {
const sourceBooking = route.query.fleetBookingNo
const sourceOrder = route.query.orderNo
arrangementOpen.value = false; arrangement.value = null
rows.value = []; total.value = 0; loadError.value = null; loading.value = true rows.value = []; total.value = 0; loadError.value = null; loading.value = true
try { try {
if (isLooseboard.value) { if (isLooseboard.value) {
@@ -85,6 +88,10 @@ async function load() {
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery) const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
rows.value = result.records; total.value = result.total rows.value = result.records; total.value = result.total
} }
if (inquiryReturnTarget(route.query) && route.query.fleetBookingNo === sourceBooking && route.query.orderNo === sourceOrder) {
const target = rows.value.find(row => row.orderNo === sourceOrder && row.fleetArrangement?.bookingNo === sourceBooking)
if (target) showArrangement(target)
}
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false } } catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false }
} }
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 } function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
@@ -99,7 +106,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<template #toolbar-start><h1>{{ domainLabel }}</h1></template> <template #toolbar-start><el-button v-if="inquiryReturnTarget(route.query)" link @click="router.push(inquiryReturnTarget(route.query)!)">返回原拼车登记</el-button><h1>{{ domainLabel }}</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template> <template #filters><QueryToolbar v-model="filters" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template> <template #notices><template v-if="loadError"><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { buildPacking, buildPhysical, physicalForm, shippedCartons } from './procurementPhysicalForm' import { buildPacking, buildPhysical, physicalForm, shippedCartons, managementReason, receiptCorrectionReason } from './procurementPhysicalForm'
import { readPendingProcurement } from './procurementPending' import { readPendingProcurement } from './procurementPending'
import type { PlatformProcurement } from './procurementTypes' import type { PlatformProcurement } from './procurementTypes'
const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' } const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' }
@@ -65,11 +65,52 @@ describe('procurement physical request validation', () => {
expect(payload).toMatchObject({ kind: 'SHORTAGE', decision: 'ACCEPT_SHORTAGE', quantity: 3 }); expect(payload).not.toHaveProperty('receiptId') expect(payload).toMatchObject({ kind: 'SHORTAGE', decision: 'ACCEPT_SHORTAGE', quantity: 3 }); expect(payload).not.toHaveProperty('receiptId')
}) })
it('corrects an original receipt without counting it twice or moving its origin', () => { it('corrects an original receipt without counting it twice or moving its origin', () => {
const value = detail(); const form = physicalForm(); form.receiptId = '11'; form.leg = 'DOMESTIC'; form.sourceCartonId = '31'; form.goodQuantity = 5; form.damagedQuantity = 1; form.receivedAt = '2020-01-02T10:00:00' const value = detail(); value.purchaseFacts.overseas_shipped_at = null; const form = physicalForm(); form.receiptId = '11'; form.leg = 'DOMESTIC'; form.sourceCartonId = '31'; form.goodQuantity = 5; form.damagedQuantity = 1; form.receivedAt = '2020-01-02T10:00:00'
const payload = buildPhysical('receipts/correct', form, value, base, '20', '901') const payload = buildPhysical('receipts/correct', form, value, base, '20', '901')
expect(payload).toMatchObject({ receiptId: '11', leg: 'OVERSEAS', goodQuantity: 5 }); expect(payload).not.toHaveProperty('sourceCartonId') expect(payload).toMatchObject({ receiptId: '11', leg: 'OVERSEAS', goodQuantity: 5 }); expect(payload).not.toHaveProperty('sourceCartonId')
expect(readPendingProcurement(JSON.stringify({ command: 'receipts/correct', payload }))).not.toBeNull() expect(readPendingProcurement(JSON.stringify({ command: 'receipts/correct', payload }))).not.toBeNull()
}) })
it('blocks shipped receipt corrections while preserving domestic corrections before customer shipment', () => {
const value = detail()
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('本段已实际交寄')
expect(receiptCorrectionReason(value, 'DOMESTIC')).toBeNull()
value.purchaseFacts.customer_shipped_at = '2020-01-05T10:00:00'
expect(receiptCorrectionReason(value, 'DOMESTIC')).toContain('本段已实际交寄')
value.purchaseFacts.overseas_shipped_at = null
value.physical.packingStatus = 'CONFIRMED'
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('先撤回')
value.physical.packingStatus = 'DRAFT'
expect(receiptCorrectionReason(value, 'OVERSEAS')).toBeNull()
})
it.each(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])('blocks physical changes for %s but leaves evidence and warehouse correction available', status => {
const value = detail(); value.customerView.order.status = status
expect(managementReason('receipts/correct', value)).toContain('订单已结束')
expect(managementReason('dispositions', value)).toContain('订单已结束')
expect(managementReason('packing/withdraw', value)).toContain('订单已结束')
expect(managementReason('control-notes', value)).toBeNull()
expect(managementReason('actual-warehouse/correct', value)).toBeNull()
const form = physicalForm(); form.receiptId = '11'
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('订单已结束')
})
it('requires the correct physical source and unfinished receipt before recording differences', () => {
const value = detail(); value.purchaseFacts = {}
expect(managementReason('dispositions', value)).toContain('交寄来源')
value.purchaseFacts.purchased_at = '2020-01-01'
expect(managementReason('dispositions', value)).toBeNull()
value.purchaseFacts.overseas_received_at = '2020-01-02'
expect(managementReason('dispositions', value)).not.toBeNull()
value.purchaseFacts.overseas_shipped_at = '2020-01-03'
expect(managementReason('dispositions', value)).toBeNull()
value.purchaseFacts.domestic_received_at = '2020-01-04'
expect(managementReason('dispositions', value)).toContain('本段收货已完成')
})
it('rejects a shipped receipt even when another leg still offers a correctable receipt', () => {
const value = detail()
value.physical.receipts.push({ ...value.physical.receipts[0]!, id: '13', leg: 'DOMESTIC', sourceCartonId: '31' })
expect(managementReason('receipts/correct', value)).toBeNull()
const form = physicalForm(); form.receiptId = '11'
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('本段已实际交寄')
})
it('confirms the exact draft version and rejects malformed persisted packing before replay', () => { it('confirms the exact draft version and rejects malformed persisted packing before replay', () => {
const value = detail(); const form = physicalForm(value) const value = detail(); const form = physicalForm(value)
expect(buildPhysical('packing/confirm', form, value, base, '', '')).toEqual({ ...base, packingVersion: 2 }) expect(buildPhysical('packing/confirm', form, value, base, '', '')).toEqual({ ...base, packingVersion: 2 })
@@ -1,6 +1,43 @@
import type { PlatformProcurement } from './procurementTypes' import type { PlatformProcurement } from './procurementTypes'
import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands' import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands'
export type Leg = 'OVERSEAS' | 'DOMESTIC' export type Leg = 'OVERSEAS' | 'DOMESTIC'
const closedStatuses = new Set(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])
export function receiptCorrectionReason(detail: PlatformProcurement, leg: Leg): string | null {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,只能追加事实说明,不能更正实收数量'
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) return '本段已实际交寄,只能追加更正说明,不能改写数量'
if (leg === 'OVERSEAS' && detail.physical.packingStatus === 'CONFIRMED') return '须先撤回尚未交寄的确认清单,再更正海外实收'
return null
}
export function dispositionReason(detail: PlatformProcurement, leg: Leg): string | null {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能修改差异处置'
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_received_at' : 'domestic_received_at']) return '本段收货已完成,须先受控更正,不能直接改差异结果'
if (leg === 'OVERSEAS' && !detail.purchaseFacts.purchased_at) return '尚无本单真实购买来源'
if (leg === 'DOMESTIC' && !detail.purchaseFacts.overseas_shipped_at) return '尚无本单国际交寄来源'
return null
}
export const managementOptions: { value: ProcurementCommand; label: string }[] = [
{ value: 'dispositions', label: '记录差异处置' }, { value: 'receipts/correct', label: '更正原实收数量' },
{ value: 'dispositions/revoke', label: '撤销原差异处置' }, { value: 'packing/withdraw', label: '撤回未交寄的确认清单' },
{ value: 'control-notes', label: '追加事实说明或阻断' }, { value: 'control-notes/resolve', label: '解除已处理的阻断' },
{ value: 'actual-warehouse/correct', label: '实际仓受控更正' },
]
export function managementReason(command: ProcurementCommand, detail: PlatformProcurement): string | null {
if (command === 'receipts/correct' || command === 'dispositions/revoke') {
const records = command === 'receipts/correct' ? detail.physical.receipts : detail.physical.dispositions
const active = records.filter(row => row.active)
if (!active.length) return command === 'receipts/correct' ? '暂无有效实收记录' : '暂无有效差异处置'
return active.some(row => !receiptCorrectionReason(detail, row.leg)) ? null : receiptCorrectionReason(detail, active[0]!.leg)
}
if (command === 'dispositions') return dispositionReason(detail, 'OVERSEAS') && dispositionReason(detail, 'DOMESTIC')
if (command === 'packing/withdraw') {
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能撤回装箱事实'
if (detail.purchaseFacts.overseas_shipped_at) return '国际段已交寄,不能撤回装箱事实'
if (detail.physical.packingStatus !== 'CONFIRMED') return '暂无已确认清单可撤回'
}
if (command === 'control-notes/resolve' && !detail.controls.notes.some(row => row.blocking && !row.resolved_at)) return '暂无待处理阻断'
if (command === 'actual-warehouse/correct' && !detail.purchaseFacts.actual_warehouse_id) return '尚无实际收货仓,请修改计划仓'
return null
}
export const physicalCommands = new Set<ProcurementCommand>(['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke']) export const physicalCommands = new Set<ProcurementCommand>(['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke'])
export interface PackingDraft { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number | undefined }[] } export interface PackingDraft { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number | undefined }[] }
export interface PhysicalForm { leg: Leg; sourceCartonId: string; receiptId: string; dispositionId: string; goodQuantity: number | undefined; damagedQuantity: number | undefined; extraQuantity: number | undefined; receivedAt: string; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number | undefined; cartons: PackingDraft[] } export interface PhysicalForm { leg: Leg; sourceCartonId: string; receiptId: string; dispositionId: string; goodQuantity: number | undefined; damagedQuantity: number | undefined; extraQuantity: number | undefined; receivedAt: string; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number | undefined; cartons: PackingDraft[] }
@@ -28,6 +65,8 @@ export function buildPacking(cartons: PackingDraft[], detail: PlatformProcuremen
}) })
} }
export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody { export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
const blocked = managementReason(command, detail)
if (blocked) throw new Error(blocked)
const closed = ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(detail.customerView.order.status) const closed = ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(detail.customerView.order.status)
if (closed) throw new Error('订单已结束,不能改写实物记录') if (closed) throw new Error('订单已结束,不能改写实物记录')
if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) } if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) }
@@ -40,7 +79,10 @@ export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, d
return base return base
} }
if (command === 'dispositions/revoke') { if (command === 'dispositions/revoke') {
if (!detail.physical.dispositions.some(row => row.id === form.dispositionId && row.active)) throw new Error('请选择有效差异处置') const row = detail.physical.dispositions.find(row => row.id === form.dispositionId && row.active)
if (!row) throw new Error('请选择有效差异处置')
const reason = receiptCorrectionReason(detail, row.leg)
if (reason) throw new Error(reason)
return { ...base, dispositionId: form.dispositionId, voucherFileId } return { ...base, dispositionId: form.dispositionId, voucherFileId }
} }
if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' } if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' }
@@ -48,6 +90,8 @@ export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, d
const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined
if (correction && !old) throw new Error('请选择有效原实收记录') if (correction && !old) throw new Error('请选择有效原实收记录')
const leg: Leg = old?.leg ?? (command === 'overseas-receipts' ? 'OVERSEAS' : command === 'domestic-receipts' ? 'DOMESTIC' : form.leg) const leg: Leg = old?.leg ?? (command === 'overseas-receipts' ? 'OVERSEAS' : command === 'domestic-receipts' ? 'DOMESTIC' : form.leg)
const reason = correction ? receiptCorrectionReason(detail, leg) : command === 'dispositions' ? dispositionReason(detail, leg) : null
if (reason) throw new Error(reason)
const sourceCartonId = old?.sourceCartonId || form.sourceCartonId const sourceCartonId = old?.sourceCartonId || form.sourceCartonId
if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱') if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱')
if (command === 'dispositions') { if (command === 'dispositions') {
@@ -146,3 +146,5 @@
| 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 | | 车次准备 | 选择合格排车、新建或加入车次、连续发布 | 1080px居中,复用车次与发布表单 | 来源带入、版本和失败续办,未授权的预约事实不由此生成 |
本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。 本次无新增文件预览、删除或停用方式;未改变的操作沿既有矩阵。代码测试与真实页面验收分别报告。
2026-10-10 拼车简化收口:调度默认待我处理,销售默认可拼车次;登记仍为1080px居中弹框,默认只展开客户和卸货站,已知货量与确认前资料分区展开;只读跟进仅突出当前有权限的主要动作,其他操作与关联单据收起。沿原请求、权限与真实作业节点;本次视觉变更待1920×1080、100%缩放人工截图确认。
@@ -41,6 +41,7 @@ export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
export interface OfferCustomerOption { id: string; code: string; name: string } export interface OfferCustomerOption { id: string; code: string; name: string }
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' } export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' } export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
export const inquiryCargoLabel = (status?: CapacityInquiry['status']) => status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对' export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
@@ -59,3 +59,36 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
expect(listOfferQuotes).toHaveBeenCalledTimes(2) expect(listOfferQuotes).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.emitted('saved')).toHaveLength(1)
}) })
it('locks the destination in readonly follow-up even without offer detail, while allowing multi-stop editing', async () => {
permissions.values = new Set(['platform:fleet:capacity-offer:manage'])
const multi = { ...offer, destinations: [...offer.destinations, { destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2, remaining: null }] }
const row: CapacityInquiry = {
bookingNo: 'FB-READONLY', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1,
offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17',
customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'LOCAL_LOOSEBOARD', deliveryMode: 'SELF_DELIVERY',
estimatedArrivalAt: null, expectedPalletCount: null,
cargo: { destinationKey: 'FBA:ACY2', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null },
destinationLabel: 'ACY2', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null,
updatedAt: '2026-10-10T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null,
progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '待补资料', missingFields: [] },
}
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer: null, inquiry: row, readonly: true }, global: { plugins: [createPinia(), ElementPlus] } })
wrappers.push(wrapper); await flushPromises()
const destination = () => formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' })
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
await destination().find('.el-select__wrapper').trigger('click'); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('aria-expanded')).toBe('false')
expect(destination().props('modelValue')).toBe('FBA:ACY2')
await wrapper.setProps({ offer: multi, readonly: false }); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeUndefined()
await destination().find('.el-select__wrapper').trigger('click'); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('aria-expanded')).toBe('true')
await select(wrapper, '本车卸货站', 'FBA:ONT8')
expect(destination().props('modelValue')).toBe('FBA:ONT8')
await wrapper.setProps({ inquiry: null }); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeUndefined()
await wrapper.setProps({ offer }); await flushPromises()
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
expect(saveCapacityInquiry).not.toHaveBeenCalled()
})
@@ -8,14 +8,37 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers' import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates' import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes' import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes' import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
import type { TripBookingCargo } from '../bookingTypes' import type { TripBookingCargo } from '../bookingTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard' import { useFleetDraftGuard } from '../useFleetDraftGuard'
import { inquiryOrderTarget } from '../inquiryNavigation'
const open = defineModel<boolean>({ default: false }) const open = defineModel<boolean>({ default: false })
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>() const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>() const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null) const access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const followupActions = computed(() => {
const row = props.inquiry
if (!row) return []
const manage = access.can('platform:fleet:capacity-offer:manage')
return [
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && (manage || access.can('platform:fleet:capacity-offer:inquiry-edit')) },
{ key: 'confirm', label: '核对并确认', visible: manage && (row.actions.canEdit || row.actions.canConfirm) },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit') },
{ key: 'receipt', label: '核对实收与运费', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm') },
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch') },
{ key: 'fulfillment', label: '查看收货进度', visible: !!row.incomingNo },
{ key: 'dispatch', label: '查看排车详情', visible: !!row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list') },
{ key: 'trip', label: '前往车次审核', visible: !!row.dispatchNo && access.can('platform:fleet:trip:list') },
].filter(action => action.visible)
})
const primaryFollowup = computed(() => {
const key = props.inquiry?.progress.primaryAction
if (key === 'VIEW') return followupActions.value.find(action => action.key === (props.inquiry?.dispatchNo ? 'dispatch' : 'fulfillment'))
return followupActions.value.find(action => action.key.toUpperCase() === key)
})
const secondaryFollowups = computed(() => followupActions.value.filter(action => action !== primaryFollowup.value))
const canReadOrder = computed(() => props.inquiry && inquiryOrderTarget(props.inquiry, access.can))
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }) const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null }) const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false) const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
@@ -26,6 +49,8 @@ const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo |
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry)) const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
const canQuote = computed(() => access.can('platform:fleet:quote:list')) const canQuote = computed(() => access.can('platform:fleet:quote:list'))
const lock = computed(() => !writable.value || busy.value || !!pending.value) const lock = computed(() => !writable.value || busy.value || !!pending.value)
const cargoLabel = computed(() => inquiryCargoLabel(props.inquiry?.status))
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || '')) const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value)) const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
let generation = 0, customerSequence = 0, quoteSequence = 0 let generation = 0, customerSequence = 0, quoteSequence = 0
@@ -83,22 +108,38 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save"> <AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p> <p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
<section v-if="confirmMode" aria-label="本目的站可接余位">
<strong>本目的站可接余位</strong>
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
<p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p>
</section>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p> <p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p> <p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section> <section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-else>保存后调度可见,尚不保位。已知货量可先填,未知可留空,确认承运前再补齐。</p> <p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p> <p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p> <p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
<section v-if="inquiry && readonly" aria-label="继续处理本票货物">
<el-button v-if="primaryFollowup" type="primary" @click="emit('action', primaryFollowup.key, inquiry)">{{ primaryFollowup.label }}</el-button>
<details v-if="secondaryFollowups.length"><summary>其他操作与关联单据</summary><div class="density-actions">
<el-button v-for="action in secondaryFollowups" :key="action.key" @click="emit('action', action.key, inquiry)">{{ action.label }}</el-button>
</div></details>
</section>
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock"> <el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item> <el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item> <el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item> <details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item> <el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item> <el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item> <el-form-item :label="`${cargoLabel}标准托位`"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item> <el-form-item :label="`${cargoLabel}重量(kg)`"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item> <el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
</div></details>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form"> <details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item> <el-form-item label="货物名称"><el-input v-model="form.cargo.cargoName" placeholder="填写货物名称(确认承运前必填)" /></el-form-item>
<el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item> <el-form-item label="交货方式"><el-select v-model="form.deliveryMode"><el-option value="SELF_DELIVERY" label="客户自行送至我仓" /><el-option value="MANUAL_PICKUP_TO_WAREHOUSE" label="线下安排提货回我仓" /></el-select></el-form-item>
<el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item> <el-form-item label="预计到仓"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
@@ -118,23 +159,13 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div> <p v-if="quoteSearched && !quoteRows.length">暂无适用报价,可继续手工询价。</p><div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
</template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p> </template><p v-if="quoteError" role="alert">{{ quoteError.message }}<ApiErrorMeta :details="quoteError" /></p>
</section></details> </section></details>
<section v-if="inquiry && readonly" aria-label="继续处理本票货物" class="density-actions">
<el-button v-if="inquiry.actions.canEdit" @click="emit('action', 'edit', inquiry)">补充资料</el-button>
<el-button v-if="access.can('platform:fleet:capacity-offer:manage') && (inquiry.actions.canEdit || inquiry.actions.canConfirm)" @click="emit('action', 'confirm', inquiry)">核对并确认</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit')" @click="emit('action', 'incoming', inquiry)">安排来货</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-confirm')" @click="emit('action', 'receipt', inquiry)">核对实收与运费</el-button>
<el-button v-if="inquiry.progress.primaryAction === 'BIND' && access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch')" @click="emit('action', 'bind', inquiry)">确认排入本车</el-button>
<el-button v-if="inquiry.incomingNo" @click="emit('action', 'fulfillment', inquiry)">查看收货进度</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('action', 'dispatch', inquiry)">查看排车详情</el-button>
<el-button v-if="inquiry.dispatchNo && access.can('platform:fleet:trip:list')" @click="emit('action', 'trip', inquiry)">前往车次审核</el-button>
</section>
<section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖"> <section v-if="inquiry?.appointmentCoverage" aria-label="收货预约覆盖">
<h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p> <h3>收货预约覆盖</h3><el-button v-if="access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')" @click="emit('action', 'appointment', inquiry)">预约跟进</el-button><p>{{ appointmentCoverageLabels[inquiry.appointmentCoverage.status] || '预约结果待核对' }}。承运保位不代表收货方已接受本票货物。</p>
<p v-if="inquiry.appointmentCoverage.missingFields.length">待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}</p> <p v-if="inquiry.appointmentCoverage.missingFields.length">待处理:{{ inquiry.appointmentCoverage.missingFields.map(key => appointmentMissingLabels[key] || '请预约人员核对来源资料').join('、') }}</p>
<p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p> <p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p>
<p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p> <p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p>
</section> </section>
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p> <p v-if="inquiry">本土订单:<el-button v-if="canReadOrder" link @click="emit('action', 'business-order', inquiry)">{{ inquiry.businessOrderNo }}</el-button><span v-else>{{ inquiry.businessOrderNo || '尚未成交' }}</span> · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
</AppDialog> </AppDialog>
</template> </template>
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style> <style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
@@ -2,7 +2,7 @@
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes' import { inquiryCargoLabel, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false }) withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 }) const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>() const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
@@ -10,7 +10,7 @@ const access = useAccessPreviewStore()
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] { function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
const manage = access.can('platform:fleet:capacity-offer:manage') const manage = access.can('platform:fleet:capacity-offer:manage')
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`) const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
return [{ key: 'view', label: '查看进度', priority: 10 }, const items: RowActionItem[] = [{ key: 'view', label: '查看进度', priority: 10 },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy }, { key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy }, { key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy }, { key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
@@ -18,12 +18,15 @@ function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy }, { key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy }, { key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }] { key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
const primary = items.find(action => action.visible !== false && action.key.toUpperCase() === row.progress.primaryAction)
if (primary) { primary.priority = 20; primary.tone = 'primary' }
return items
} }
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false }, { key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 }, { key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' }, { key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '货量', minWidth: 270, slotName: 'cargo', singleLine: false },
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 }, { key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' }, { key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
] ]
@@ -32,9 +35,9 @@ const columns: DataTableColumn[] = [
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template> <template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
<template v-if="$slots.filters" #filters><slot name="filters" /></template> <template v-if="$slots.filters" #filters><slot name="filters" /></template>
<template v-if="$slots.notices" #notices><slot name="notices" /></template> <template v-if="$slots.notices" #notices><slot name="notices" /></template>
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template> <template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template> <template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template> <template #cargo="{ row }"><div>{{ inquiryCargoLabel(row.status) }}:{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</div><div>预计实托数:{{ row.expectedPalletCount ?? '—' }}</div></template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template> <template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template> <template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
</DataTable></template> </DataTable></template>
@@ -19,7 +19,7 @@ function mount(realPlanner = false) {
const wrapper = shallowMount(Dialog, { props: { modelValue: true, trip }, global: { plugins: [createPinia()], stubs: { const wrapper = shallowMount(Dialog, { props: { modelValue: true, trip }, global: { plugins: [createPinia()], stubs: {
AppDialog: { name: 'AppDialog', props: ['confirmText'], emits: ['confirm'], template: '<div><slot/><button @click="$emit(\'confirm\')">{{confirmText}}</button></div>' }, AppDialog: { name: 'AppDialog', props: ['confirmText'], emits: ['confirm'], template: '<div><slot/><button @click="$emit(\'confirm\')">{{confirmText}}</button></div>' },
TripLoadingPanel: realPlanner ? false : defineComponent({ name: 'TripLoadingPanel', setup(_, { expose }) { expose({ save: state.plan }); return {} }, template: '<div/>' }), TripLoadingPanel: realPlanner ? false : defineComponent({ name: 'TripLoadingPanel', setup(_, { expose }) { expose({ save: state.plan }); return {} }, template: '<div/>' }),
DataTable: { name: 'DataTable', props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' }, DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="palletNo" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' },
ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input/>' }, ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input/>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElButton: { template: '<button><slot/></button>' }, ElDatePicker: true, ElInput: true, LoadErrorState: true, ApiErrorMeta: true, ElButton: { template: '<button><slot/></button>' }, ElDatePicker: true, ElInput: true, LoadErrorState: true, ApiErrorMeta: true,
@@ -61,10 +61,14 @@ it('retries only the uncertain publish command with the original key', async ()
}) })
it('submits measurements from the embedded panel before saving and publishing', async () => { it('submits measurements from the embedded panel before saving and publishing', async () => {
const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-1', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] } const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-USLAX01-261010-G0RD3LYHGD8H', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
vi.mocked(trips.getTripCapacityPlan).mockResolvedValue(basis) vi.mocked(trips.getTripCapacityPlan).mockResolvedValue(basis)
vi.mocked(trips.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 6, planValid: true }) vi.mocked(trips.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 6, planValid: true })
const wrapper = mount(true); await flushPromises() const wrapper = mount(true); await flushPromises()
expect(wrapper.text()).toContain('PLT-USLAX01-261010-G0RD3LYHGD8H')
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string; singleLine?: boolean; width?: number; minWidth?: number }[]
expect(columns.filter(column => ['palletNo', 'spaces', 'weight', 'volume', 'stop'].includes(column.key)).every(column => column.singleLine === false)).toBe(true)
expect(columns.reduce((sum, column) => sum + (column.width || column.minWidth || 0), 0)).toBeLessThanOrEqual(1048)
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' }) const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 2); await flushPromises() fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 2); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
@@ -1,6 +1,10 @@
import { ElMessage } from 'element-plus'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue' import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings' import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes' import type { FleetTrip } from '../tripTypes'
@@ -17,9 +21,9 @@ function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' } const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: { const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' }, DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
LoadErrorState: true, ApiErrorMeta: true, ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper } } }); wrappers.push(wrapper); return wrapper
} }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) }) beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
@@ -27,10 +31,12 @@ afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => { it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false }) vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises() const wrapper = mount('receipt'); await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises() expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')).toContainEqual(expect.objectContaining({ key: 'spaces', singleLine: false }))
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[0]!.vm.$emit('update:modelValue', 2); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] })) expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }] }))
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled() expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
expect(ElMessage.success).toHaveBeenCalledWith('实收货量及约定运费已核实')
}) })
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => { it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } }) vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
@@ -56,3 +62,56 @@ it('keeps an uncertain receipt command retryable after closing and reopening wit
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original) expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
expect(api.getTripIncoming).toHaveBeenCalledOnce() expect(api.getTripIncoming).toHaveBeenCalledOnce()
}) })
it.each([
{ bookingStatus: 'BOUND' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,已排入本车' },
{ bookingStatus: 'RECEIVED' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,可安排排车' },
{ bookingStatus: 'BOUND' as const, incomingStatus: 'OUTBOUNDED', expected: '已出库' },
])('shows truthful receipt guidance for $bookingStatus / $incomingStatus', async ({ bookingStatus, incomingStatus, expected }) => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: incomingStatus } })
const wrapper = mount('view')
await wrapper.setProps({ booking: { ...booking, status: bookingStatus, dispatchNo: bookingStatus === 'BOUND' ? 'DS-1' : null } })
await flushPromises()
expect(wrapper.text()).toContain(expected)
if (bookingStatus === 'BOUND') expect(wrapper.text()).not.toContain('可安排排车')
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(preview.incoming!.pallets)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
})
it('anchors receipt control sizing inside the real DataTable slot and disables cell ellipsis', async () => {
const wrapper = mountFull(TripBookingFulfillmentDialog, {
props: { modelValue: true, trip, booking, mode: 'receipt' },
global: { plugins: [createPinia()], components: { ElTable, ElTableColumn, ElInputNumber, ElInput }, directives: { loading: () => undefined }, stubs: {
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElButton: { template: '<button><slot/></button>' },
AppDialog: { template: '<div><slot/></div>' }, LoadErrorState: true, ApiErrorMeta: true,
} },
})
wrappers.push(wrapper); await flushPromises()
const control = wrapper.get('.el-table__body .receipt-spaces-control')
const scope = control.element.getAttributeNames().find(name => name.startsWith('data-v-'))
expect(scope).toBeDefined()
expect(control.find('.el-input-number').exists()).toBe(true)
expect(control.element.closest('td')?.classList.contains('data-table__single-line-cell')).toBe(false)
expect(control.element.closest('.cell')?.classList.contains('el-tooltip')).toBe(false)
// DataTable forwards classes to its inner table, which does not carry this SFC's scope.
expect(wrapper.find(`.receipt-pallet-table[${scope}]`).exists()).toBe(false)
const input = control.get('input')
await input.setValue('2'); await input.trigger('change'); await flushPromises()
expect(wrapper.text()).toContain('2 托位')
})
it('compares real receipt with the forecast, preserves unknown values, and does not invent historical forecasts', async () => {
const wrapper = mount('receipt'); await flushPromises()
const rows = () => wrapper.get('[aria-label="预报与实收对照"]').findAll('tbody tr').map(row => row.findAll('td').map(cell => cell.text()))
expect(rows()).toEqual([
['箱数', '20', '22', '+2'], ['实托数', '—', '1', '—'], ['标准托位(待核对)', '2', '1', '-1'],
['重量(kg)', '100', '150', '+50'], ['体积(m³)', '1', '1.5', '+0.5'],
])
await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } })
expect(rows().every(row => row[1] === '—' && row[3] === '—')).toBe(true)
expect(wrapper.text()).toContain('无法还原原始预报')
await wrapper.setProps({ mode: 'view' }); await flushPromises()
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
})
@@ -27,9 +27,30 @@ const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMA
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0)) const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100) const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' } const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const receiptComparison = computed(() => {
const incoming = preview.value?.incoming
const pallets = incoming?.pallets || []
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
? pallets.reduce((total, row) => total + row[key]!, 0) : null
const values = [
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
]
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
})
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
const incomingStatusLabel = computed(() => {
const status = preview.value?.incoming?.status
if (status === 'READY_FOR_DISPATCH' && props.booking?.status === 'BOUND') return '已确认实收,已排入本车'
return statuses[status || ''] || '等待读取'
})
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 }, const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 }, { key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])]) ...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
async function load() { async function load() {
if (!props.booking) return if (!props.booking) return
const current = generation; loading.value = true; error.value = null const current = generation; loading.value = true; error.value = null
@@ -77,7 +98,7 @@ async function save() {
const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart) const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind) : mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept) : await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success('外接履约记录已更新') } if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success(value.booking.status === 'BOUND' ? '已排入本车,尚未装车或发车' : value.booking.status === 'RECEIVED' ? '实收货量及约定运费已核实' : value.booking.incomingNo ? '已安排来货,等待仓库实际收货' : '履约命令已完成,请查看最新进度') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } } } catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false } finally { busy.value = false }
} }
@@ -94,8 +115,15 @@ onBeforeUnmount(() => { generation++ })
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template> <template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form> </el-form>
<template v-if="mode === 'receipt' || mode === 'view'"> <template v-if="mode === 'receipt' || mode === 'view'">
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p> <el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable> <DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
</table>
</section>
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form> <el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p> <p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
</template> </template>
@@ -103,4 +131,4 @@ onBeforeUnmount(() => { generation++ })
</div> </div>
</AppDialog> </AppDialog>
</template> </template>
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style> <style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}.receipt-spaces-control{display:flex;width:100%;min-width:0}.receipt-spaces-control :deep(.el-input-number){width:100%;min-width:0;max-width:100%}</style>
@@ -1,3 +1,5 @@
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingPanel from './TripBookingPanel.vue' import TripBookingPanel from './TripBookingPanel.vue'
@@ -45,7 +47,6 @@ it('confirms using both current versions and updates the parent version without
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false) expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
}) })
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => { it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
const { ElInputNumber, ElInput } = await vi.importActual<typeof import('element-plus')>('element-plus')
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } } const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 } const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] }) vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
@@ -15,10 +15,14 @@ const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<Pa
let sequence = 0 let sequence = 0
const baseline = ref('') const baseline = ref('')
watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true }) watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true })
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 }, const columns = computed<DataTableColumn[]>(() => [
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 }, { key: 'palletNo', label: '实物托盘', slotName: 'palletNo', minWidth: 210, singleLine: false },
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 }, { key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 140, singleLine: false },
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 200 }]) { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 160, singleLine: false },
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 180, singleLine: false },
{ key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 180, singleLine: false },
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 160, singleLine: false },
])
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad)) const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || []) const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid) const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
@@ -91,7 +95,8 @@ onBeforeUnmount(() => { sequence++ })
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p> <p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p> <p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p> <p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
<DataTable :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘"> <DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
<template #palletNo="{ row }"><span class="trip-pallet-code">{{ row.palletNo }}</span></template>
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template> <template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template> <template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template> <template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
@@ -101,3 +106,8 @@ onBeforeUnmount(() => { sequence++ })
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table> <table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
</div> </div>
</template> </template>
<style scoped>
.trip-pallet-code{white-space:normal;overflow-wrap:anywhere}
.trip-loading-table :deep(.el-input-number),.trip-loading-table :deep(.el-select){width:100%;min-width:0;max-width:100%}
</style>
@@ -1,4 +1,5 @@
export { overseasMasterDataRoutes } from './routes' export { overseasMasterDataRoutes } from './routes'
export { inquiryReturnTarget } from './inquiryNavigation'
export { overseasMasterDataPermissions } from './permissions' export { overseasMasterDataPermissions } from './permissions'
export type * from './types' export type * from './types'
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue' export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
@@ -0,0 +1,16 @@
import { expect, it } from 'vitest'
import { inquiryReturnTarget, inquirySourceQuery, inquiryOrderTarget } from './inquiryNavigation'
import type { CapacityInquiry } from './capacityOfferTypes'
it('returns only to a fixed internal inquiry route and rejects malformed or repeated values', () => {
expect(inquiryReturnTarget(inquirySourceQuery({ offerNo: 'FO-1', bookingNo: 'FB-2' }))).toEqual({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } })
for (const value of ['https://example.com', '../FB-2', ['FB-2'], '', null]) expect(inquiryReturnTarget({ fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: value })).toBeNull()
expect(inquiryReturnTarget({ returnTo: 'https://example.com' })).toBeNull()
})
it.each(['US_TRUCK', 'LOCAL_LOOSEBOARD'] as const)('opens %s using only its list permission and the business number', businessType => {
const row = { businessType, businessOrderNo: 'ORDER-1', offerNo: 'FO-1', bookingNo: 'FB-2' } as CapacityInquiry
const domain = businessType === 'US_TRUCK' ? 'truck' : 'looseboard'
expect(inquiryOrderTarget(row, permission => permission === `platform:overseas-business:${domain}:list`)).toMatchObject({ query: { orderNo: 'ORDER-1', fleetBookingNo: 'FB-2' } })
expect(inquiryOrderTarget(row, permission => permission.endsWith(':edit'))).toBeNull()
expect(inquiryOrderTarget({ ...row, businessOrderNo: null }, () => true)).toBeNull()
})
@@ -0,0 +1,16 @@
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
import type { CapacityInquiry } from './capacityOfferTypes'
const businessNo = (value: unknown): value is string => typeof value === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(value)
export function inquirySourceQuery(row: Pick<CapacityInquiry, 'offerNo' | 'bookingNo'>) {
return { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo }
}
export function inquiryReturnTarget(query: LocationQuery = {}): RouteLocationRaw | null {
if (query.fleetSource !== 'inquiry' || !businessNo(query.fleetOfferNo) || !businessNo(query.fleetBookingNo)) return null
return { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: query.fleetOfferNo }, query: { inquiry: query.fleetBookingNo } }
}
export function inquiryOrderTarget(row: CapacityInquiry, can: (permission: string) => boolean): RouteLocationRaw | null {
const domain = row.businessType === 'US_TRUCK' ? 'truck' : 'looseboard'
if (!row.businessOrderNo || !can(`platform:overseas-business:${domain}:list`)) return null
return { name: domain === 'truck' ? 'overseas-business-trucks' : 'overseas-business-looseboards', query: { orderNo: row.businessOrderNo, ...inquirySourceQuery(row) } }
}
@@ -14,16 +14,30 @@ import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue' import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers' import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes' import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands' import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore() const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null) const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false) const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
const confirmMode = ref(false) const confirmMode = ref(false)
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view') const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
fulfillmentOpen.value = false
if (!row) return
const current = ++followupSequence
await load()
try {
const updated = await getCapacityInquiry(row.bookingNo)
if (current === followupSequence) await action('view', updated)
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
}
let followupSequence = 0
function openFulfillment(key: string, row: CapacityInquiry) { function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true } if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true } if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo }, query: inquirySourceQuery(row) }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true } if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); return true }
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
editorOpen.value = false editorOpen.value = false
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
@@ -38,7 +52,11 @@ async function load() {
try { try {
const value = await getCapacityOffer(String(route.params.offerNo)) const value = await getCapacityOffer(String(route.params.offerNo))
let checked: CapacityInquiry | null = null let checked: CapacityInquiry | null = null
if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo) if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
const returned = await getCapacityInquiry(route.query.inquiry)
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) } if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
} }
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } } catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
@@ -61,14 +79,14 @@ function accept(value: InquiryResult) {
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion } for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
} }
const commands = useCapacityInquiryCommands('capacity-offer-command', accept) const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
async function action(key: string, row: CapacityInquiry) { if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } } async function action(key: string, row: CapacityInquiry) { followupSequence++; if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true } function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => { watch(() => [route.name, route.params.offerNo, route.query?.inquiry], ([name, offerNo]) => {
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
detail.value = null; editorOpen.value = false; void load() followupSequence++; detail.value = null; editorOpen.value = false; void load()
}, { immediate: true }) }, { immediate: true })
watch([page, size], loadInquiries) watch([page, size], loadInquiries)
onBeforeUnmount(() => { sequence++; inquirySequence++ }) onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
</script> </script>
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情"> <template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
@@ -82,7 +100,7 @@ onBeforeUnmount(() => { sequence++; inquirySequence++ })
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button> <LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" /> <CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
</template></div> </template></div>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" /> <CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" /> <CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
</DetailPageLayout></AdminPage></template> </DetailPageLayout></AdminPage></template>
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style> <style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
@@ -1,3 +1,4 @@
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
@@ -10,7 +11,7 @@ import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import * as api from '../api/capacityOffers' import * as api from '../api/capacityOffers'
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes' import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes' import type { FleetTrip } from '../tripTypes'
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() })) const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { query: {} as Record<string, string>, name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) })) vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write) }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write) }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
@@ -21,7 +22,7 @@ const pageOf = <T,>(records: T[], total = records.length) => ({ records, total,
const wrappers: VueWrapper[] = [] const wrappers: VueWrapper[] = []
const layout = { template: '<div><slot name="header"/><slot/></div>' } const layout = { template: '<div><slot name="header"/><slot/></div>' }
const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<input />' } const field = { props: ['modelValue'], emits: ['update:modelValue', 'focus'], template: '<input />' }
function mount(component: object, props: Record<string, unknown> = {}) { function mount(component: object, props: Record<string, unknown> = {}): { wrapper: VueWrapper; pinia: ReturnType<typeof createPinia> } {
const pinia = createPinia() const pinia = createPinia()
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: { const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
@@ -35,8 +36,29 @@ function mount(component: object, props: Record<string, unknown> = {}) {
} } }); wrappers.push(wrapper); return { wrapper, pinia } } } }); wrappers.push(wrapper); return { wrapper, pinia }
} }
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)! const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) }) beforeEach(() => { state.route.query = {}; vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() }) afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labels %s cargo consistently in the list and detail without treating estimated pallets as actual', async status => {
const row: CapacityInquiry = { ...inquiry, status, expectedPalletCount: 7, cargo: { ...inquiry.cargo, packageCount: 12, palletSpaces: 3, weightKg: 80, volumeCbm: 2 } }
const label = status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
const list = shallowMount(CapacityInquiryTable, { props: { rows: [row], total: 1, loading: false }, global: { stubs: {
DataTable: { props: ['data', 'columns'], template: '<div><slot name="cargo" :row="data[0]"/></div>' },
} } })
wrappers.push(list)
expect(list.text()).toContain(`${label}:12 箱 · 3 托位 · 80 kg · 2 m³`)
expect(list.text()).toContain('预计实托数:7')
expect(list.text()).not.toContain('7 实托')
await list.setProps({ rows: [{ ...row, expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
expect(list.text()).toContain(`${label}:— 箱`)
expect(list.text()).toContain('预计实托数:—')
const { wrapper: detail } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
await flushPromises()
for (const suffix of ['箱数', '标准托位', '重量(kg)', '体积(m³)']) expect(detail.find(`[label="${label}${suffix}"]`).exists()).toBe(true)
expect(detail.findAll('summary').map(item => item.text())).toContain(label === '已核实' ? '已核实货量与预计实托数' : `${label}货量`)
expect(detail.find('[label="预计实托数"]').exists()).toBe(true)
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => { it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises() const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled() expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
@@ -69,7 +91,7 @@ it('reads detail once, permits drafting while capacity is unknown, and never pre
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => { it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true }) vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises() const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', inquiry.customerId); selects[2]!.vm.$emit('update:modelValue', inquiry.cargo.destinationKey); await flushPromises() const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', inquiry.customerId); selects[1]!.vm.$emit('update:modelValue', inquiry.cargo.destinationKey); await flushPromises()
const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises() const dialog = wrapper.findComponent({ name: 'AppDialog' }); dialog.vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2] const first = vi.mocked(api.saveCapacityInquiry).mock.calls[0]![2]
expect(first).toMatchObject({ expectedPalletCount: null, cargo: { packageCount: null, palletSpaces: null }, idempotencyKey: expect.any(String) }) expect(first).toMatchObject({ expectedPalletCount: null, cargo: { packageCount: null, palletSpaces: null }, idempotencyKey: expect.any(String) })
@@ -136,10 +158,14 @@ it('keeps the create button disabled with a reason after cutoff, while hiding it
const readonly = mount(CapacityOfferDetailView); await flushPromises() const readonly = mount(CapacityOfferDetailView); await flushPromises()
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false) expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
}) })
it('defaults administrators to available offers and requires an explicit history filter', async () => { it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => {
state.manage = true state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises() const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' })) expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'TODO' }))
expect(api.listCapacityOffers).not.toHaveBeenCalled()
expect(state.links.size).toBe(0)
await button(wrapper, '可拼车次').trigger('click'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
const query = wrapper.findComponent({ name: 'QueryToolbar' }) const query = wrapper.findComponent({ name: 'QueryToolbar' })
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises() query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' })) expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
@@ -169,9 +195,9 @@ it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip sep
const { wrapper } = mount(component); await flushPromises() const { wrapper } = mount(component); await flushPromises()
const dialog = wrapper.findComponent(CapacityInquiryDialog) const dialog = wrapper.findComponent(CapacityInquiryDialog)
dialog.vm.$emit('action', 'dispatch', row); await flushPromises() dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' } }) expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
dialog.vm.$emit('action', 'trip', row); await flushPromises() dialog.vm.$emit('action', 'trip', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }) expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
expect(api.commandCapacityInquiry).not.toHaveBeenCalled() expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
state.links.clear(); state.push.mockClear() state.links.clear(); state.push.mockClear()
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises() dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
@@ -191,3 +217,130 @@ it('shows follow-up destinations only with the matching read permission', async
expect(second.wrapper.emitted('action')).toEqual([['trip', row]]) expect(second.wrapper.emitted('action')).toEqual([['trip', row]])
expect(api.commandCapacityInquiry).not.toHaveBeenCalled() expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
}) })
it('keeps initial inputs compact, reveals confirmation fields, and keeps unknown amounts null', async () => {
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false })
await flushPromises()
const visibleInputs = wrapper.findAll('input').filter(input => !input.element.closest('details:not([open])'))
expect(visibleInputs).toHaveLength(2)
expect(wrapper.text()).toContain('业务类型:本土散板')
const cargo = wrapper.findAll('details').find(section => section.find('summary').text().startsWith('已知货量'))!
expect(cargo.attributes('open')).toBeUndefined()
await wrapper.setProps({ confirmMode: true }); await flushPromises()
expect(cargo.attributes('open')).toBeDefined()
expect(wrapper.findAll('details').find(section => section.find('summary').text().startsWith('确认承运前待补资料'))!.attributes('open')).toBeDefined()
})
it('highlights only the allowed current step and keeps other actions secondary', async () => {
state.manage = true
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry, readonly: true })
await flushPromises()
const section = wrapper.find('[aria-label="继续处理本票货物"]')
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['补充资料'])
expect(section.find('details').text()).toContain('核对并确认')
await button(wrapper, '补充资料').trigger('click')
expect(wrapper.emitted('action')?.[0]).toEqual(['edit', inquiry])
await wrapper.setProps({ inquiry: { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' }, actions: { ...inquiry.actions, canEdit: false, canConfirm: false } } })
expect(section.findAll('button').filter(item => !item.element.closest('details'))).toHaveLength(0)
state.links.add('platform:fleet:trip:booking-edit')
await wrapper.setProps({ inquiry: { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } } })
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['安排来货'])
})
it('shows the selected destination capacity when confirming and never treats unknown capacity as zero', async () => {
state.manage = true
const knownOffer = { ...offer, capacityKnown: true, destinations: [
{ ...offer.destinations[0]!, remaining: { palletSpaces: 0, weightKg: 12, volumeCbm: 3 } },
{ destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2, remaining: { palletSpaces: 7, weightKg: 800, volumeCbm: 20 } },
] }
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer: knownOffer, inquiry, readonly: false, confirmMode: true })
await flushPromises()
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('0 托位 · 12 kg · 3 m³')
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'FBA:ACY2'); await flushPromises()
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('7 托位 · 800 kg · 20 m³')
await wrapper.setProps({ offer: { ...knownOffer, capacityKnown: false } })
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('待调度核对')
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).not.toContain('800')
expect(api.saveAndConfirmInquiry).not.toHaveBeenCalled()
})
it('prioritizes the allowed current list action and keeps other actions in the overflow menu', async () => {
state.manage = true
const wrapper = shallowMount(CapacityInquiryTable, { props: { rows: [inquiry], total: 1, loading: false }, global: { directives: { loading: () => undefined }, stubs: {
DataTable: { props: ['data'], template: '<div><slot name="actions" :row="data[0]"/></div>' },
RowActions: { name: 'RowActions', props: ['actions', 'maxVisible'], template: '<div/>' },
} } })
wrappers.push(wrapper)
const actions = () => wrapper.findComponent({ name: 'RowActions' }).props('actions') as { key: string; visible?: boolean; priority?: number }[]
const primary = () => actions().filter(item => item.visible !== false).sort((a,b) => (b.priority || 0) - (a.priority || 0))[0]?.key
expect(wrapper.findComponent({ name: 'RowActions' }).props('maxVisible')).toBe(1)
expect(primary()).toBe('edit')
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' }, actions: { ...inquiry.actions, canEdit: false, canConfirm: false } }] })
expect(primary()).toBe('view')
state.links.add('platform:fleet:trip:booking-edit')
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } }] })
expect(primary()).toBe('incoming')
})
it('shows the business identity and exposes the order link only with its list permission', async () => {
const row = { ...inquiry, businessOrderNo: 'ORDER-1' }
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
await flushPromises()
const summary = wrapper.get('[aria-label="本票处理进度"]').text()
expect(summary).toContain(row.tripNo); expect(summary).toContain(row.bookingNo)
expect(summary).toContain(row.customerName); expect(summary).toContain(row.destinationLabel)
expect(button(wrapper, 'ORDER-1')).toBeUndefined()
state.links.add('platform:overseas-business:truck:list')
await wrapper.setProps({ inquiry: { ...row } }); await flushPromises()
await button(wrapper, 'ORDER-1').trigger('click')
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
})
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
wrapper.unmount()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('fetches an off-page returned inquiry once per refresh and retries without falling back to stale data', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer, inquiries: pageOf<CapacityInquiry>([], 30) })
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true)
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 7 })
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
expect(editor.props('inquiry').recordVersion).toBe(7)
vi.mocked(api.getCapacityInquiry).mockRejectedValueOnce(new Error('读取失败'))
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
const error = wrapper.findComponent({ name: 'LoadErrorState' })
expect(error.exists()).toBe(true)
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 8 })
error.vm.$emit('retry'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
expect(editor.props('inquiry').recordVersion).toBe(8)
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
@@ -15,25 +15,40 @@ import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue' import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers' import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes' import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands' import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore() const router = useRouter(), access = useAccessPreviewStore()
const tab = ref<'offers' | 'inquiries'>('offers'), filters = ref<QueryModel>({}), applied = ref<QueryModel>({}) const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const tab = ref<'offers' | 'inquiries'>(manage.value ? 'inquiries' : 'offers')
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null) const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([]) const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true) const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
const confirmMode = ref(false) const confirmMode = ref(false)
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view') const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
fulfillmentOpen.value = false
if (!row) return
const current = ++followupSequence
await load()
try {
const updated = await getCapacityInquiry(row.bookingNo)
if (current === followupSequence) await inquiryAction('view', updated)
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
}
let followupSequence = 0
function openFulfillment(key: string, row: CapacityInquiry) { function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true } if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true } if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo }, query: inquirySourceQuery(row) }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true } if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); return true }
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
editorOpen.value = false editorOpen.value = false
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
return true return true
} }
let sequence = 0, editorSequence = 0 let sequence = 0, editorSequence = 0
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [ const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 }, { key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 }, { key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
@@ -82,6 +97,7 @@ function accept(value: InquiryResult) {
} }
const commands = useCapacityInquiryCommands('capacity-pool-command', accept) const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
async function inquiryAction(key: string, row: CapacityInquiry) { async function inquiryAction(key: string, row: CapacityInquiry) {
followupSequence++
if (openFulfillment(key, row)) return if (openFulfillment(key, row)) return
if (key === 'cancel') { await commands.run(row, key); return } if (key === 'cancel') { await commands.run(row, key); return }
if (key === 'confirm' && !manage.value) return if (key === 'confirm' && !manage.value) return
@@ -102,7 +118,7 @@ async function recheckEditor() {
if (current === editorSequence) { selectedOffer.value = detail.offer; editing.value = checked } if (current === editorSequence) { selectedOffer.value = detail.offer; editing.value = checked }
} catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '最新意向核对失败') } } catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '最新意向核对失败') }
} }
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ }) watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货"> <template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'"> <DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
@@ -132,6 +148,6 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequenc
</template> </template>
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template> <template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
</CapacityInquiryTable> </CapacityInquiryTable>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" /> <CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" /> <CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
</ListPageLayout></AdminPage></template> </ListPageLayout></AdminPage></template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { inquiryReturnTarget } from '../inquiryNavigation'
import TripDispatchSelector from '../components/TripDispatchSelector.vue' import TripDispatchSelector from '../components/TripDispatchSelector.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -185,7 +186,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</script> </script>
<template> <template>
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情"> <AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState> <LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
<div v-loading="loading"><template v-if="detail"> <div v-loading="loading"><template v-if="detail">
@@ -0,0 +1,15 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, getCargoDiscard } from './cargoDiscards'
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
beforeEach(() => vi.mocked(http.get).mockReset())
it('reads submitted photos from detail and downloads a blob in the explicit customer scope', async () => {
const photos = [{ fileId: '101', palletNo: 'PALLET-A', fileName: 'discard.png', createdAt: '2026-10-14T10:00:00', submittedAt: '2026-10-14T10:01:00' }]
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { summary: { discardNo: 'CD/1', customerId: '7' }, photos, items: [], histories: [] } })
expect((await getCargoDiscard('CD/1', '7')).photos).toEqual(photos)
const blob = new Blob(['image'], { type: 'image/png' })
vi.mocked(http.get).mockResolvedValueOnce(blob)
expect(await downloadCargoDiscardPhoto('CD/1', '101', '7')).toBe(blob)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/cargo-discards/CD%2F1/photos/files/101/download', { params: { customerId: '7' }, responseType: 'blob' })
})
@@ -4,7 +4,8 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string } export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null } export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null } export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardDetail { summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] } export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] } export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
export async function listCargoDiscards(query: CargoDiscardQuery) { export async function listCargoDiscards(query: CargoDiscardQuery) {
@@ -24,3 +25,7 @@ export async function completeCargoDiscard(discardNo: string, customerId?: strin
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export function downloadCargoDiscardPhoto(discardNo: string, fileId: string, customerId: string) {
return http.get<Blob, Blob>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { reviewCargoDiscard } from './lastMileOrders' import { reviewCargoDiscard, updateLastMileOrderPalletMerge } from './lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders' import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
@@ -148,3 +148,12 @@ describe('last-mile order overseas API', () => {
) )
}) })
}) })
it('sets and cancels a merge group with string IDs and explicit customer scope', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
for (const required of [true, false]) {
await updateLastMileOrderPalletMerge(['2092873463568596994'], '2092618717861048321', required)
expect(http.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/pallet-merge-required/batch',
{ orderIds: ['2092873463568596994'], palletMergeRequired: required }, { params: { customerId: '2092618717861048321' } })
}
})
@@ -99,7 +99,7 @@ export interface LastMileOrderSummaryDto {
deliveryOrderNo?: string | null deliveryOrderNo?: string | null
outboundOrderNo?: string | null outboundOrderNo?: string | null
relabelOrderNo?: string | null relabelOrderNo?: string | null
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }> relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }>
relabelStatus?: string | null relabelStatus?: string | null
palletMergeFlag?: string | null palletMergeFlag?: string | null
/** Optional grouping facts returned by newer last-leg services. */ /** Optional grouping facts returned by newer last-leg services. */
@@ -356,9 +356,12 @@ export interface ChangeContainerReceivingWarehousePayload {
export interface LastMileOrderDetailDto { export interface LastMileOrderDetailDto {
returnReceipt?: { returnReceipt?: {
order: { returnNo: string; receivedQuantity: number; expectedQuantity: number } order: { returnNo: string; status?: string; receivedQuantity: number; expectedQuantity: number }
receipts: Array<{ receiptNo: string; unitType: string; unitCode?: string; palletCode?: string; receivedQuantity: number; boxCount: number; signedAt: string }> items?: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number; receivedQuantity: number }>
cartons?: Array<{ receiptNo: string; palletCode?: string; cartonMark: string; countStatus: string; countedAt?: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }> receipts: Array<{ receiptNo: string; unitType: string; unitCode?: string; palletCode?: string; receivedQuantity: number; boxCount: number; signedAt: string; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
cartons?: Array<{ receiptNo: string; palletCode?: string; cartonMark: string; countStatus: string; countedAt?: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }>; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
packingDetails?: import('./returnInventory').ReturnCartonMergeHistory[]
currentCartons?: Array<{ carton: import('./returnInventory').ReturnCartonDetail; ownership: Array<{ returnNo: string; skuCode: string; quantity: number }>; orderQuantity: number; labelStatus: string; canRelabel: boolean; blockReason?: string }>
} | null } | null
order: LastMileOrderDto order: LastMileOrderDto
manualNodes?: ManualNodesResponse['nodes'] manualNodes?: ManualNodesResponse['nodes']
@@ -426,6 +429,15 @@ export async function transferLastMileOrders(payload: TransferDestinationPayload
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function updateLastMileOrderPalletMerge(orderIds: string[], customerId: string, palletMergeRequired: boolean) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/orders/pallet-merge-required/batch',
{ orderIds, palletMergeRequired },
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> { export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> {
const response = await http.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>( const response = await http.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>(
'/platform/v1/last-leg/orders/container-summaries', '/platform/v1/last-leg/orders/container-summaries',
@@ -52,7 +52,7 @@ export async function listReturnOutboundCandidates(customerId: string, warehouse
const response = await http.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } }) const response = await http.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; skuCode?: string; quantity?: number; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) { export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) {
const response = await http.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } }) const response = await http.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } })
return unwrapApiResponse(response) return unwrapApiResponse(response)
} }
@@ -66,6 +66,8 @@ export interface RelabelSummaryDto {
totalPackageCount?: number | null totalPackageCount?: number | null
notifiedPackageCount?: number | null notifiedPackageCount?: number | null
skuCodes?: string | null skuCodes?: string | null
productNameZh?: string | null
labelRequirement?: string | null
totalFileId?: string | null totalFileId?: string | null
totalFileName?: string | null totalFileName?: string | null
totalFileUrl?: string | null totalFileUrl?: string | null
@@ -87,6 +89,8 @@ export interface RelabelSummaryDto {
export interface RelabelItemDto { export interface RelabelItemDto {
id: string id: string
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
skuCode?: string | null skuCode?: string | null
productNameZh?: string | null productNameZh?: string | null
totalPackageCount?: number | null totalPackageCount?: number | null
@@ -142,6 +146,7 @@ export interface ReturnPackingPlanPayload {
remark?: string remark?: string
idempotencyKey: string idempotencyKey: string
sourceCartonMarks?: string[] sourceCartonMarks?: string[]
sourceDeductions?: Array<{ cartonMark: string; skuCode: string; quantity: number }>
} }
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> { export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
@@ -155,6 +160,10 @@ export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 're
pushedAt?: string | null pushedAt?: string | null
completedAt?: string | null completedAt?: string | null
resultCartons?: import('./returnInventory').ReturnCartonDetail[] resultCartons?: import('./returnInventory').ReturnCartonDetail[]
sources?: Array<{ cartonMark: string; sourceReturnNo?: string; participation: 'FULL' | 'PARTIAL' | 'UNKNOWN'; items: ReturnPackingPlanBoxItem[]; originalItems: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }>
completionCartons?: import('./returnInventory').ReturnCartonLabel[]
transfers?: import('./returnInventory').ReturnCartonMergeHistory[]
completedBy?: string
} }
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) { export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
@@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string } export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string } export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string } export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] } export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string } export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
@@ -22,7 +22,8 @@ export function appointmentPayload(form: AppointmentForm): FbaAppointmentDetails
startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null } startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null }
} }
export function appointmentFormError(form: AppointmentForm): string | null { export function appointmentFormError(form: AppointmentForm): string | null {
if (form.appointmentStatus === 'CANCELLED') return '请在预约列表使用取消预约操作,并填写实际取消原因'
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间' if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
if (['FAILED', 'CANCELLED'].includes(form.appointmentStatus) && !form.appointmentRemark.trim()) return '请填写取消或未约成的原因' if (form.appointmentStatus === 'FAILED' && !form.appointmentRemark.trim()) return '请填写未约成的原因'
return null return null
} }
@@ -6,25 +6,29 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p> <p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled"> <el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item> <el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="appointmentNo" label="取消安排" value="CANCELLED" /></el-select></el-form-item> <el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'"> <template v-if="model.appointmentStatus === 'SUCCESS'">
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item> <el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item> <el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
<el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item> <el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
</template> </template>
<el-form-item :label="['FAILED','CANCELLED'].includes(model.appointmentStatus) ? '原因' : '备注'" :required="['FAILED','CANCELLED'].includes(model.appointmentStatus)"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item> <el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary> <details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
<div class="appointment-fields__extra-grid">
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item> <el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item> <el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p> <template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item> <el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item> <el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template> </template>
</div>
</details> </details>
</el-form> </el-form>
</template> </template>
<style scoped> <style scoped>
.appointment-fields { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); } .appointment-fields, .appointment-fields__extra-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); }
.appointment-fields :deep(.el-select), .appointment-fields :deep(.el-date-editor) { width:100%; } .appointment-fields :deep(.el-select), .appointment-fields :deep(.el-date-editor) { width:100%; }
.appointment-fields__extra { grid-column:1 / -1; } .appointment-fields__extra { grid-column:1 / -1; }
.appointment-fields__extra summary { cursor:pointer; margin-bottom:var(--admin-space-3); }
.appointment-fields__extra-grid > p { grid-column:1 / -1; }
</style> </style>
@@ -11,6 +11,7 @@ withDefaults(defineProps<{
referenceLabel: string referenceLabel: string
referenceValue: string referenceValue: string
backLabel: string backLabel: string
backText?: string
domain: string domain: string
pending?: boolean pending?: boolean
statusText?: string statusText?: string
@@ -23,13 +24,13 @@ defineEmits<{ back: [] }>()
<AdminPage as="div" class="overseas-detail-page" :class="{ 'admin-density': dense }" :aria-label="title"> <AdminPage as="div" class="overseas-detail-page" :class="{ 'admin-density': dense }" :aria-label="title">
<header v-if="dense" class="density-detail-header"> <header v-if="dense" class="density-detail-header">
<div class="overseas-detail-title density-detail-heading"> <div class="overseas-detail-title density-detail-heading">
<el-button link class="density-back" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回列表</el-button> <el-button link class="density-back" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />{{ backText || '返回列表' }}</el-button>
<h1 class="density-detail-title"><slot name="reference">{{ referenceValue }}</slot></h1> <h1 class="density-detail-title"><slot name="reference">{{ referenceValue }}</slot></h1>
<StatusBadge v-if="statusText" :status="statusText" /> <StatusBadge v-if="statusText" :status="statusText" />
</div> </div>
</header> </header>
<header v-else class="overseas-detail-head"> <header v-else class="overseas-detail-head">
<el-button :class="{ 'density-back': dense }" :link="dense" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button> <el-button :class="{ 'density-back': dense }" :link="dense" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />{{ backText || '返回' }}</el-button>
<div class="overseas-detail-title"> <div class="overseas-detail-title">
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p> <p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p>
@@ -110,6 +110,19 @@ it('does not submit a physical pallet fallback when its actual plan source ident
wrapper.unmount() wrapper.unmount()
}) })
it('submits relabel physical pallet identity without requiring an inbound plan pallet', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'relabel-draft', containers: [
{ id: 'container', capacity: 30, palletIds: ['101'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: null, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-1' })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
})
it('does not send a second batch while the original command is pending', async () => { it('does not send a second batch while the original command is pending', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups]) vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned]) vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
@@ -330,7 +330,8 @@ async function submit() {
ElMessage.warning('草稿中存在重复托盘或无效装车容器,请核对分组;原草稿已保留') ElMessage.warning('草稿中存在重复托盘或无效装车容器,请核对分组;原草稿已保留')
return return
} }
if (assigned.value.some(item => item.sourceType === 'RETURN_RECEIPT' if (assigned.value.some(item => item.sourceStage === 'RELABEL'
? !String(item.warehousePalletId || '').trim() : item.sourceType === 'RETURN_RECEIPT'
? !String(item.returnReceiptId || '').trim() : !String(item.planPalletId || '').trim())) { ? !String(item.returnReceiptId || '').trim() : !String(item.planPalletId || '').trim())) {
ElMessage.warning('所选托盘来源标识缺失,请重新加载核对;原草稿已保留') ElMessage.warning('所选托盘来源标识缺失,请重新加载核对;原草稿已保留')
return return
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -9,24 +9,42 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue' import StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails } from '@/shared/api/http'
import { listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards' import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' }) const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }] const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }] const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '客户已确认', REJECTED: '已驳回', CANCELLED: '已取消' } const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待仓库确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
let photoGeneration = 0
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
async function loadPhoto(fileId: string) {
const current = detail.value; const generation = photoGeneration
if (!current || photoLoading[fileId]) return
photoLoading[fileId] = true; photoErrors[fileId] = false
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === photoGeneration) photoErrors[fileId] = true }
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
}
watch(detailOpen, value => { if (!value) clearPhotos() })
onBeforeUnmount(clearPhotos)
function text(key: string) { return String(filters.value[key] || '').trim() || undefined } function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } } async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } } async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } }) onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
</script> </script>
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage> <AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog> <AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
</template> </template>
<style scoped>
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height:150px;border-radius:6px}.discard-photo-item small{color:var(--cc-text-secondary)}
</style>
@@ -5,13 +5,15 @@ import { getDispatch } from '../api/dispatches'
import DispatchDetailView from './DispatchDetailView.vue' import DispatchDetailView from './DispatchDetailView.vue'
import { DataTableStub } from './phase5TestStubs' import { DataTableStub } from './phase5TestStubs'
const sourceQuery = vi.hoisted(() => ({} as Record<string, string>))
const routerPush = vi.hoisted(() => vi.fn()) const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' } }), useRouter: () => ({ push: routerPush }) })) vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query: sourceQuery }), useRouter: () => ({ push: routerPush }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() })) vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
describe('DispatchDetailView', () => { describe('DispatchDetailView', () => {
beforeEach(() => { beforeEach(() => {
routerPush.mockReset() routerPush.mockReset()
Object.keys(sourceQuery).forEach(key => delete sourceQuery[key])
vi.mocked(getDispatch).mockReset().mockResolvedValue({ vi.mocked(getDispatch).mockReset().mockResolvedValue({
id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE', id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }], route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }],
@@ -40,4 +42,15 @@ describe('DispatchDetailView', () => {
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })]) expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
wrapper.unmount() wrapper.unmount()
}) })
it.each([false, true])('returns to the original inquiry only for a valid source: %s', async fromInquiry => {
if (fromInquiry) Object.assign(sourceQuery, { fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' })
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
const backButton = wrapper.get('button.density-back')
expect(backButton.text()).toBe(fromInquiry ? '返回原拼车登记' : '返回列表')
await backButton.trigger('click')
expect(routerPush).toHaveBeenCalledWith(fromInquiry ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } } : { name: 'overseas-dispatches' })
wrapper.unmount()
})
}) })
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { inquiryReturnTarget } from '@/modules/overseas-master-data'
import { physicalWeightKg } from '../dispatchPhysicalWeight' import { physicalWeightKg } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
@@ -128,13 +129,13 @@ async function load() {
catch (error) { loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') } catch (error) { loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
finally { loading.value = false } finally { loading.value = false }
} }
function back() { void router.push({ name: 'overseas-dispatches' }) } function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" back-label="返回排车列表" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back"> <OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading"> <div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<div class="summary-grid"> <div class="summary-grid">
@@ -0,0 +1,50 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import InboundOrdersView from './InboundOrdersView.vue'
import { getFbaAppointment, updateFbaAppointment } from '../api/inboundOrders'
vi.mock('vue-router', () => ({ useRouter: () => ({push: vi.fn()}), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
const access=vi.hoisted(()=>({canWrite:true}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({can: (permission:string) => !permission.endsWith(':fba-appointment') || access.canWrite}) }))
vi.mock('../api/inboundOrders', () => ({
listInboundOrders: vi.fn().mockResolvedValue({records:[{lineId:'1',planNo:'IP-1',customerId:'A',appointmentNo:'AP-1',fbaWarehouseCode:'ONT8'}],total:1}),
getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn(), createFbaAppointment: vi.fn(), updateInboundPlan: vi.fn(),
}))
vi.mock('element-plus', () => ({ ElMessage: {warning:vi.fn(),success:vi.fn()} }))
const render=()=>mount(InboundOrdersView,{global:{directives:{loading:{}},stubs:{
AdminPage:{template:'<main><slot/></main>'},ListPageLayout:{template:'<div><slot/></div>'},
DataTable:{props:['data'],template:'<div><slot v-for="row in data" name="actions" :row="row"/></div>'},
AppDialog:{name:'AppDialog',props:['modelValue','beforeClose','showConfirm'],template:'<section v-if="modelValue"><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></section>'},
ElButton:{template:'<button><slot/></button>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
}}})
beforeEach(()=>{vi.clearAllMocks();access.canWrite=true;vi.mocked(getFbaAppointment).mockResolvedValue({id:'1',appointmentNo:'AP-1',version:2,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})})
it('opens linked appointment details for list-only users and blocks saving',async()=>{
access.canWrite=false
const wrapper=render();await flushPromises()
expect(wrapper.findAll('button').some(b=>b.text()==='预约')).toBe(false)
await wrapper.findAll('button').find(b=>b.text()==='查看预约')!.trigger('click');await flushPromises()
expect(getFbaAppointment).toHaveBeenCalledWith('AP-1')
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
expect(wrapper.find('[data-save]').exists()).toBe(false)
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
})
it('keeps the inbound appointment retry across close, reopen and route attempts', async () => {
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({} as never)
const wrapper=render()
await flushPromises()
const open=()=>wrapper.findAll('button').find(b=>b.text()==='预约')!.trigger('click')
await open();await flushPromises();await wrapper.get('[data-save]').trigger('click');await flushPromises()
const original=vi.mocked(updateFbaAppointment).mock.calls[0]![1]
await wrapper.get('[data-close]').trigger('click')
const dialog=wrapper.findAllComponents({name:'AppDialog'})[0]!
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
expect(wrapper.find('[data-save]').exists()).toBe(true)
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
await wrapper.get('[data-save]').trigger('click');await flushPromises()
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
wrapper.unmount()
})
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { CalendarCheck2, Truck } from '@lucide/vue' import { CalendarCheck2, Truck } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -58,7 +58,9 @@ const appointmentVersion = ref(0)
const appointmentForm = reactive(createAppointmentForm()) const appointmentForm = reactive(createAppointmentForm())
const appointmentTimezone = ref<string | null>(null) const appointmentTimezone = ref<string | null>(null)
const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType<typeof appointmentPayload>; idempotencyKey: string} | null>(null) const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType<typeof appointmentPayload>; idempotencyKey: string} | null>(null)
const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约') const appointmentViewOnly = ref(false)
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
const appointmentTitle = computed(() => appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约')
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]) const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
function selectableRow(source: object) { function selectableRow(source: object) {
@@ -127,6 +129,8 @@ function selectionError() {
} }
function openAppointment() { function openAppointment() {
if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
appointmentViewOnly.value = false
const error = selectionError() const error = selectionError()
if (error) { ElMessage.warning(error); return } if (error) { ElMessage.warning(error); return }
appointmentMode.value = 'create'; editingAppointmentNo.value = '' appointmentMode.value = 'create'; editingAppointmentNo.value = ''
@@ -141,9 +145,11 @@ function openSmartDispatch() {
smartDispatchOpen.value = true smartDispatchOpen.value = true
} }
async function openAppointmentEdit(source: object) { async function openAppointmentEdit(source: object, readOnly = false) {
if (!canLeaveAppointment()) return
const row = source as InboundOrderListItem const row = source as InboundOrderListItem
if (!row.appointmentNo || row.appointmentNo === '—') return if (!row.appointmentNo || row.appointmentNo === '—') return
appointmentViewOnly.value = readOnly
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true
try { try {
@@ -158,7 +164,16 @@ async function openAppointmentEdit(source: object) {
} finally { appointmentLoading.value = false } } finally { appointmentLoading.value = false }
} }
function canLeaveAppointment() {
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
return true
}
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
onBeforeRouteLeave(canLeaveAppointment)
onBeforeRouteUpdate(canLeaveAppointment)
async function submitAppointment() { async function submitAppointment() {
if (appointmentReadOnly.value || saving.value || appointmentLoading.value) return
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } } if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
const validation = appointmentFormError(appointmentForm) const validation = appointmentFormError(appointmentForm)
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return } if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
@@ -209,14 +224,14 @@ onMounted(() => { void load() })
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template> <template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment"> <AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading"> <div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div> <div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="saving || !!pendingAppointment" /> <FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
</div> </div>
</AppDialog> </AppDialog>
@@ -13,6 +13,32 @@ vi.mock('../api/warehouseTransfers', () => ({ getWarehouseTransfer: vi.fn() }))
enableAutoUnmount(afterEach) enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => { describe('LastMileOrderDetailView domestic detail alignment', () => {
it.each(['PENDING_REVIEW', 'PARTIALLY_RECEIVED', 'COMPLETED'])('shows only customer SKU forecast regardless of receipt status (%s)', async status => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: status, packageCount: 30 },
cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', status, expectedQuantity: 30, receivedQuantity: 5 }, receipts: [],
items: [{ skuCode: 'SKU-FORECAST', productNameZh: '预报商品', expectedQuantity: 30, receivedQuantity: 5 }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('[aria-label="基本信息"]').text()).not.toContain('箱数')
await wrapper.findAll('[role="tab"]').find(tab => tab.text().includes('货物信息'))!.trigger('click'); await flushPromises()
const panel = wrapper.get('.table-panel')
expect(panel.text()).toContain('SKU-FORECAST')
expect(panel.text()).toContain('预报商品')
expect(panel.text()).toContain('预报件数')
expect(panel.text()).toContain('30')
expect(panel.text()).not.toContain('箱数')
})
it('keeps customer forecast after completion instead of using actual signed cargo', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'return-tail', customerId: 'C1', orderType: 'RETURN_RELABEL', orderNo: 'RT-1', currentStatus: 'INBOUND_COMPLETED' },
cargo: [{ id: 'actual', skuCode: 'SKU-ACTUAL', productNameZh: '实收商品', productQuantity: 25, packageCount: 2 }], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', status: 'COMPLETED', expectedQuantity: 30, receivedQuantity: 25 }, receipts: [], items: [{ skuCode: 'SKU-FORECAST', expectedQuantity: 30, receivedQuantity: 25 }] } })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.findAll('[role="tab"]').find(tab => tab.text().includes('货物信息'))!.trigger('click'); await flushPromises()
expect(wrapper.get('.table-panel').text()).not.toContain('SKU-ACTUAL')
expect(wrapper.get('.table-panel').text()).toContain('SKU-FORECAST')
expect(wrapper.get('.table-panel').text()).toContain('30')
expect(wrapper.get('.table-panel').text()).not.toContain('箱数')
})
it('shows signed box contents and actual quantities on return orders', async () => { it('shows signed box contents and actual quantities on return orders', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail-return', customerId: 'customer-1', orderNo: 'RETU-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [], vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail-return', customerId: 'customer-1', orderNo: 'RETU-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [],
returnReceipt: { order: { returnNo: 'RT-1', receivedQuantity: 10, expectedQuantity: 10 }, receipts: [{ receiptNo: 'RR-1', unitType: 'PALLET', unitCode: 'PLT-1', receivedQuantity: 10, boxCount: 2, signedAt: '' }], returnReceipt: { order: { returnNo: 'RT-1', receivedQuantity: 10, expectedQuantity: 10 }, receipts: [{ receiptNo: 'RR-1', unitType: 'PALLET', unitCode: 'PLT-1', receivedQuantity: 10, boxCount: 2, signedAt: '' }],
@@ -56,6 +56,20 @@ function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核',
function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' } function isSingleLineBusinessField(field: DetailField) { return field.label === '入仓码' || field.label === '集装箱号' }
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充')) const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱')
const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => {
const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL')
return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight }
}))
function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) {
if (box.length == null || box.width == null || box.height == null) return '—'
const volume = box.length * box.width * box.height / 1_000_000
return Number.isFinite(volume) && volume >= 0 ? volume.toFixed(6).replace(/\.?0+$/, '') : '—'
}
const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.returnReceipt?.order.status
? detail.value.returnReceipt.order.status !== 'COMPLETED'
: ['PENDING_REVIEW', 'PENDING_RECEIPT', 'PARTIALLY_RECEIVED', 'REJECTED', 'CANCELLED'].includes(detail.value?.order.currentStatus || '')))
const orderFields = computed<DetailField[]>(() => { const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
const fields: DetailField[] = [ const fields: DetailField[] = [
@@ -93,7 +107,7 @@ const containerFields = computed<DetailField[]>(() => {
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) }, { label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
{ label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) }, { label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) }, { label: '货柜节点', value: containerNodeLabel(order?.containerNode) },
] ].filter(field => !isReturnOrder.value || field.label !== '箱数')
}) })
const commercialLink = computed(() => businessOrderLink(detail.value?.order.businessSource?.businessType, detail.value?.order.businessSource?.businessOrderNo, access.can)) const commercialLink = computed(() => businessOrderLink(detail.value?.order.businessSource?.businessType, detail.value?.order.businessSource?.businessOrderNo, access.can))
const basicFields = computed(() => [...orderFields.value, ...containerFields.value]) const basicFields = computed(() => [...orderFields.value, ...containerFields.value])
@@ -104,7 +118,8 @@ const overviewFields = computed(() => {
return [ return [
{ label: '客户', value: text(order?.customerName) }, { label: '客户', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) }, { label: '海外仓', value: text(order?.overseasWarehouseName) },
{ label: warehouseTransfer ? (hasReceivedPackages ? '实收总箱数' : '客户预报总箱数') : '总箱数', value: (hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount) == null ? '—' : `${hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount} 箱` }, ...(isReturnOrder.value ? [{ label: showReturnForecast.value ? '预报总件数' : '实收总件数', value: `${showReturnForecast.value ? detail.value?.returnReceipt?.order.expectedQuantity ?? order?.productQuantity ?? 0 : detail.value?.returnReceipt?.order.receivedQuantity ?? order?.productQuantity ?? 0} 件` }]
: [{ label: warehouseTransfer ? (hasReceivedPackages ? '实收总箱数' : '客户预报总箱数') : '总箱数', value: (hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount) == null ? '—' : `${hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount} 箱` }]),
{ label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' }, { label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' },
{ label: '货物毛重', value: order?.grossWeightKg == null ? '—' : order.grossWeightKg + ' kg' }, { label: '货物毛重', value: order?.grossWeightKg == null ? '—' : order.grossWeightKg + ' kg' },
{ label: '货物净重', value: order?.netWeightKg == null ? '—' : order.netWeightKg + ' kg' }, { label: '货物净重', value: order?.netWeightKg == null ? '—' : order.netWeightKg + ' kg' },
@@ -130,7 +145,10 @@ const documents = computed<DocumentRow[]>(() => {
.filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' } as DocumentRow)) .filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' } as DocumentRow))
.concat((order.relabelApplications || []).map(application => ({ type: '换标单', number: application.relabelNo, createdAt: formatOverseasDateTime(application.createdAt), quote: '—', status: relabelApplicationStatusLabel(application.status), quantity: application.quantity }))) .concat((order.relabelApplications || []).map(application => ({ type: '换标单', number: application.relabelNo, createdAt: formatOverseasDateTime(application.createdAt), quote: '—', status: relabelApplicationStatusLabel(application.status), quantity: application.quantity })))
}) })
const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item) => ({ const cargo = computed<CargoRow[]>(() => isReturnOrder.value
? (detail.value?.returnReceipt?.items || []).map(item => ({ id: item.skuCode, sku: item.skuCode, productName: text(item.productNameZh),
quantity: item.expectedQuantity, pallets: 0, cartons: 0, weight: 0, volume: 0 }))
: (detail.value?.cargo || []).map((item) => ({
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0), id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0), cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
}))) })))
@@ -149,14 +167,18 @@ const logs = computed<LogRow[]>(() => {
return [...statusRows, ...operationRows].sort((left, right) => right.sortAt.localeCompare(left.sortAt)) return [...statusRows, ...operationRows].sort((left, right) => right.sortAt.localeCompare(left.sortAt))
}) })
const cargoColumns: DataTableColumn[] = [ const cargoColumns = computed<DataTableColumn[]>(() => isReturnOrder.value ? [
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 },
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'quantity', prop: 'quantity', label: '预报件数', width: 120, align: 'right' },
] : [
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
{ key: 'pallets', prop: 'pallets', label: '托数', width: 100, align: 'right' }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 100, align: 'right' }, { key: 'pallets', prop: 'pallets', label: '托数', width: 100, align: 'right' }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 100, align: 'right' },
{ key: 'quantity', prop: 'quantity', label: '件数', width: 100, align: 'right' }, { key: 'weight', prop: 'weight', label: '重量(kg)', width: 120, align: 'right' }, { key: 'quantity', prop: 'quantity', label: '件数', width: 100, align: 'right' }, { key: 'weight', prop: 'weight', label: '重量(kg)', width: 120, align: 'right' },
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' }, { key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
] ])
const documentColumns: DataTableColumn[] = [ const documentColumns: DataTableColumn[] = [
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(件)', minWidth: 130 }, { key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(箱/件)', minWidth: 150, formatter: (row: { quantity?: number; quantityUnit?: string }) => `${row.quantity ?? '—'}${row.quantityUnit || '件'}` },
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 }, { key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 }, { key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
] ]
@@ -226,7 +248,7 @@ onMounted(load)
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div> <div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
</dl> </dl>
</el-tab-pane> </el-tab-pane>
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"></DataTable></section></el-tab-pane> <el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
<el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template> <el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template>
<section v-if="detail.returnReceipt" class="table-panel" aria-label="退货签收与箱内清点"> <section v-if="detail.returnReceipt" class="table-panel" aria-label="退货签收与箱内清点">
<h3>{{ detail.returnReceipt.order.returnNo }} · 实收 {{ detail.returnReceipt.order.receivedQuantity }} / 预报 {{ detail.returnReceipt.order.expectedQuantity }} 件</h3> <h3>{{ detail.returnReceipt.order.returnNo }} · 实收 {{ detail.returnReceipt.order.receivedQuantity }} / 预报 {{ detail.returnReceipt.order.expectedQuantity }} 件</h3>
@@ -237,11 +259,12 @@ onMounted(load)
<el-table-column prop="boxCount" label="实收箱数" width="100" /> <el-table-column prop="boxCount" label="实收箱数" width="100" />
<el-table-column prop="receivedQuantity" label="实收数量" width="100" /> <el-table-column prop="receivedQuantity" label="实收数量" width="100" />
</el-table> </el-table>
<h3>签收箱号明细({{ detail.returnReceipt.cartons?.length || 0 }} 箱)</h3> <h3>签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})</h3>
<el-collapse v-if="detail.returnReceipt.cartons?.length"> <el-collapse v-if="detail.returnReceipt.cartons?.length">
<el-collapse-item v-for="box in detail.returnReceipt.cartons" :key="box.cartonMark" :name="box.cartonMark"> <el-collapse-item v-for="box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark">
<template #title>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</template> <template #title><span class="return-carton-title"><span>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</span><span class="return-carton-measurements">长 {{ box.length ?? '—' }} cm · 宽 {{ box.width ?? '—' }} cm · 高 {{ box.height ?? '—' }} cm · 体积 {{ returnCartonVolume(box) }} m³ · 重量 {{ box.weight ?? '—' }} kg</span></span></template>
<p>签收单 {{ box.receiptNo }}<template v-if="box.palletCode"> · 托盘 {{ box.palletCode }}</template><template v-if="box.countedAt"> · 确认时间 {{ formatOverseasDateTime(box.countedAt) }}</template></p> <p>签收单 {{ box.receiptNo }}<template v-if="box.palletCode"> · 托盘 {{ box.palletCode }}</template><template v-if="box.countedAt"> · 确认时间 {{ formatOverseasDateTime(box.countedAt) }}</template></p>
<p v-if="box.length != null || box.width != null || box.height != null || box.weight != null">测量:{{ box.length ?? '—' }} × {{ box.width ?? '—' }} × {{ box.height ?? '—' }} cm · 重量 {{ box.weight ?? '—' }} kg</p>
<el-table v-if="box.countStatus === 'CONFIRMED'" :data="box.items" size="small" border> <el-table v-if="box.countStatus === 'CONFIRMED'" :data="box.items" size="small" border>
<el-table-column prop="skuCode" label="SKU" min-width="220" /> <el-table-column prop="skuCode" label="SKU" min-width="220" />
<el-table-column prop="productNameZh" label="中文品名" min-width="180" /> <el-table-column prop="productNameZh" label="中文品名" min-width="180" />
@@ -251,6 +274,28 @@ onMounted(load)
</el-collapse-item> </el-collapse-item>
</el-collapse> </el-collapse>
<el-empty v-else description="暂无签收箱号明细" /> <el-empty v-else description="暂无签收箱号明细" />
<h3>合箱记录</h3>
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本单合入数量" width="130" />
</el-table>
<h3>当前关联实物箱</h3>
<el-collapse v-if="detail.returnReceipt.currentCartons?.length">
<el-collapse-item v-for="current in detail.returnReceipt.currentCartons" :key="current.carton.cartonMark" :name="`current-${current.carton.cartonMark}`">
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
<p>来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
<el-table :data="current.ownership" size="small" border>
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
</el-table>
<p>{{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}</p>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无当前关联实物箱" />
</section> </section>
<el-empty v-else description="暂无退货签收记录" /> <el-empty v-else description="暂无退货签收记录" />
</el-tab-pane> </el-tab-pane>
@@ -280,6 +325,8 @@ onMounted(load)
<style scoped> <style scoped>
.source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; } .source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; }
.return-carton-title { display:flex; align-items:center; flex-wrap:wrap; gap:8px 16px; padding:4px 0; line-height:1.5; }
.return-carton-measurements { color:var(--cc-text-secondary); font-weight:400; }
.detail-fields > div.wide { grid-column: span 2; } .detail-fields > div.wide { grid-column: span 2; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; } .package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
@@ -21,6 +21,7 @@ import {
listLastMileOrders, listLastMileOrders,
reviewCargoDiscard, reviewCargoDiscard,
transferLastMileOrders, transferLastMileOrders,
updateLastMileOrderPalletMerge,
getLastMileOrderManualNodes, getLastMileOrderManualNodes,
updateLastMileOrderHold, updateLastMileOrderHold,
updateLastMileOrderManualNodes, updateLastMileOrderManualNodes,
@@ -38,7 +39,7 @@ vi.mock('../api/lastMileOrders', () => ({
changeContainerReceivingWarehouse: vi.fn(), changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(), createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(), getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
reviewCargoDiscard: vi.fn(), reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
})) }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -1189,3 +1190,40 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) }) expect(routerPush).toHaveBeenCalledWith({ name: 'overseas-return-inventory', query: expect.objectContaining({ packing: '1', returnNos: 'RETURN-1' }) })
}) })
}) })
describe('tail-leg pallet merge toolbar', () => {
beforeEach(() => {
canAccess.mockImplementation(() => true)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(updateLastMileOrderPalletMerge).mockReset().mockResolvedValue(null)
})
it('submits the selected group and cancellation with the customer scope', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
const wrapper = await mountView()
const row = { id: '2092873463568596994', customerId, currentStatusCode: 'PENDING_ASSIGNMENT', palletMergeFlag: '' }
expect(wrapper.findAll('button').find(b => b.text() === '合托')!.attributes('disabled')).toBeDefined()
await selectRows(wrapper, [row])
await wrapper.findAll('button').find(b => b.text() === '合托')!.trigger('click')
await flushPromises()
expect(updateLastMileOrderPalletMerge).toHaveBeenLastCalledWith([row.id], customerId, true)
await selectRows(wrapper, [{ ...row, palletMergeFlag: 'REQUIRED' }])
await wrapper.findAll('button').find(b => b.text() === '取消合托')!.trigger('click')
await flushPromises()
expect(updateLastMileOrderPalletMerge).toHaveBeenLastCalledWith([row.id], customerId, false)
})
it('blocks completed and mixed-customer selections and hides unauthorized actions', async () => {
const wrapper = await mountView()
for (const rows of [
[{ id: '1', customerId, currentStatusCode: 'INBOUND_COMPLETED', palletMergeFlag: 'REQUIRED' }],
[{ id: '1', customerId, palletMergeFlag: 'MERGED' }],
[{ id: '1', customerId }, { id: '2', customerId: 'other' }],
]) {
await selectRows(wrapper, rows)
expect(wrapper.findAll('button').find(b => b.text() === '合托')!.attributes('disabled')).toBeDefined()
expect(wrapper.findAll('button').find(b => b.text() === '取消合托')!.attributes('disabled')).toBeDefined()
}
canAccess.mockImplementation(() => false)
const denied = await mountView()
expect(denied.findAll('button').some(b => b.text() === '合托' || b.text() === '取消合托')).toBe(false)
})
})
@@ -16,7 +16,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders' import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink' import { businessOrderLink } from '../businessOrderLink'
import OrderReferenceText from '../components/OrderReferenceText.vue' import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders' import { reviewCargoDiscard } from '../api/lastMileOrders'
@@ -64,6 +64,33 @@ function openReturnPackingPlan() {
const exportLoading = ref(false) const exportLoading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } } async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
const palletMergeSelectionEditable = computed(() => selectedRows.value.length > 0
&& selectedRows.value.every(row => row.customerId && row.currentStatusCode !== 'INBOUND_COMPLETED' && row.palletMergeFlag !== 'MERGED')
&& new Set(selectedRows.value.map(row => row.customerId)).size === 1)
const canCancelPalletMerge = computed(() => palletMergeSelectionEditable.value
&& selectedRows.value.every(row => row.palletMergeFlag === 'REQUIRED'))
async function submitPalletMerge(required: boolean) {
if (actionLoading.value || !access.can(permission.lastMileOrderPalletMerge)
|| !(required ? palletMergeSelectionEditable.value : canCancelPalletMerge.value)) return
const selection = [...selectedRows.value]
const label = required ? '合托' : '取消合托'
try {
await ElMessageBox.confirm(required
? `将勾选的 ${selection.length} 票订单标记为同一合托组?所选订单须属于同一客户、同一目的仓,PDA 将提示现场合托。`
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, label,
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
} catch { return }
actionLoading.value = true
actionError.value = null
try {
await updateLastMileOrderPalletMerge(selection.map(row => row.id), selection[0]!.customerId, required)
ElMessage.success(required ? '已标记合托' : '已取消合托标记')
selectedRows.value = []
await load()
} catch (cause) {
actionError.value = getApiErrorDetails(cause, `${label}失败,请重试`)
} finally { actionLoading.value = false }
}
const actionError = ref<ApiErrorDetails | null>(null) const actionError = ref<ApiErrorDetails | null>(null)
const actionTarget = ref<LastMileOrderListItem | null>(null) const actionTarget = ref<LastMileOrderListItem | null>(null)
const inboundPlanOpen = ref(false) const inboundPlanOpen = ref(false)
@@ -1108,9 +1135,9 @@ onMounted(() => { void load() })
</div> </div>
</section></template> </section></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template> <template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!palletMergeSelectionEditable || actionLoading" @click="submitPalletMerge(true)">合托</el-button><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!canCancelPalletMerge || actionLoading" @click="submitPalletMerge(false)">取消合托</el-button><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template> <template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template> <template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template> <template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template> <template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template> <template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
@@ -10,11 +10,12 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn().mockRes
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: vi.fn().mockResolvedValue([]), listReturnOutboundCandidates: vi.fn().mockResolvedValue([]), createReturnOutbound: api.create })) vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: vi.fn().mockResolvedValue([]), listReturnOutboundCandidates: vi.fn().mockResolvedValue([]), createReturnOutbound: api.create }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.detail })) vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.detail }))
const sources = [ const sources = [
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] }, { cartonMark: 'LOOSE-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
{ cartonMark: 'BOX-B', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] }, { cartonMark: 'LOOSE-B', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
{ cartonMark: 'PARCEL', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-B', quantity: 6, availableQuantity: 6 }] }, { cartonMark: 'PARCEL', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-B', quantity: 6, availableQuantity: 6 }] },
{ cartonMark: 'PACKED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
{ cartonMark: 'MIXED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] }, { cartonMark: 'MIXED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] },
{ cartonMark: 'OTHER-ORDER', sourceUnitType: 'BOX', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] }, { cartonMark: 'OTHER-ORDER', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] },
] ]
let wrapper: VueWrapper let wrapper: VueWrapper
beforeEach(() => { beforeEach(() => {
@@ -37,54 +38,34 @@ async function open(sku: string) {
const order = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '选择已签收入库的退货单')! const order = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '选择已签收入库的退货单')!
order.vm.$emit('update:modelValue', 'RT-1'); order.vm.$emit('change', 'RT-1'); await flushPromises() order.vm.$emit('update:modelValue', 'RT-1'); order.vm.$emit('change', 'RT-1'); await flushPromises()
await wrapper.get('input[placeholder="请输入收件人、电话和地址"]').setValue('本地测试收件信息') await wrapper.get('input[placeholder="请输入收件人、电话和地址"]').setValue('本地测试收件信息')
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))!
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
const left = wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[0]!
left.findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(sku))!.vm.$emit('change', true); await flushPromises()
} }
function right() { return wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[1]! } function right() { return wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[1]! }
async function selectMarks(marks: string[]) { async function selectMarks(marks: string[]) {
for (const mark of marks) right().findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(mark === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : mark))!.vm.$emit('change', true) for (const mark of marks) {
const rows = right().props('data') as Array<{ cartonMark: string }>
right().findAllComponents(ElCheckbox)[rows.findIndex(row => row.cartonMark === mark)]!.vm.$emit('change', true)
}
await flushPromises() await flushPromises()
} }
describe('single SKU outbound source and piece quantity', () => { describe('physical return outbound', () => {
it('selects packed single-SKU boxes, scopes quantities to the return order and excludes mixed boxes', async () => { it('submits whole multi-SKU parcels and boxes without a direct partial payload', async () => {
await open('SKU-A') await open('SKU-A')
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['BOX-A', 'BOX-B']) await selectMarks(['LOOSE-A', 'PARCEL'])
expect(wrapper.text()).not.toContain('OTHER-ORDER') expect(wrapper.findAllComponents(ElInputNumber)).toHaveLength(0)
await selectMarks(['BOX-A', 'BOX-B'])
expect(wrapper.findComponent(ElInputNumber).props('modelValue')).toBe(10)
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['BOX-A', 'BOX-B'] })) expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ cartonMarks: ['LOOSE-A', 'PARCEL'], unitType: 'BOX' }))
const request = api.create.mock.calls[0][1]
expect(request.items).toBeUndefined(); expect(request.skuCode).toBeUndefined(); expect(request.quantity).toBeUndefined()
}) })
it('submits six pieces from two five-piece boxes', async () => { it('includes physical parcels while excluding other return orders', async () => {
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B']) await open('SKU-A')
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 6); await flushPromises() const marks = right().props('data').map((row: { cartonMark: string }) => row.cartonMark)
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() expect(marks).toContain('PARCEL'); expect(marks).toContain('LOOSE-A'); expect(marks).not.toContain('OTHER-ORDER')
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ quantity: 6 })) expect(wrapper.text()).toContain('合箱')
}) })
it('allows loose PARCEL sources even when the box/pallet candidate endpoint excludes them', async () => { it('blocks submission without a physical parcel or carton', async () => {
await open('SKU-B'); expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['PARCEL']) await open('SKU-A')
await selectMarks(['PARCEL']); wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 2); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-B', quantity: 2, cartonMarks: ['PARCEL'] }))
})
it('never offers a parcel as a physical box in box/pallet mode', async () => {
await open('SKU-B')
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))!
mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change', 'BOX'); await flushPromises()
expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false)
expect(right().text()).not.toContain('包裹散件')
})
it.each([[0,1],[11,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => {
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({quantity: expected}))
})
it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => {
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).not.toHaveBeenCalled() expect(api.create).not.toHaveBeenCalled()
}) })
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue' import OutboundsView from './OutboundsView.vue'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn() })) const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), transfers: vi.fn(), bindDestination: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
@@ -13,7 +14,8 @@ vi.mock('../api/outbounds', () => ({
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
listOneClickOutboundCandidates: api.candidates, listOneClickOutboundCandidates: api.candidates,
createOneClickDropshipOutbound: api.create, createOneClickDropshipOutbound: api.create,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: vi.fn(), listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bindDestination,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
})) }))
const fixtures = Array.from({ length: 7 }, (_, index) => ({ const fixtures = Array.from({ length: 7 }, (_, index) => ({
@@ -48,12 +50,43 @@ async function selectBothPages() {
} }
describe('outbound candidate search and selection', () => { describe('outbound candidate search and selection', () => {
it('offers whole physical parcels and boxes scoped to the selected return', async () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: 'customer-a', tenantName: '客户甲', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 } as Awaited<ReturnType<typeof listPlatformTenants>>)
const sources = [
{ cartonMark: 'P-A', sourceUnitType: 'PALLET', skuCode: 'SKU-A', quantity: 5 },
{ cartonMark: 'P-B', sourceUnitType: 'PALLET', skuCode: 'SKU-B', quantity: 6 },
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', skuCode: 'SKU-A', quantity: 3 },
{ cartonMark: 'SP-A', sourceUnitType: 'PARCEL', skuCode: 'SKU-A', quantity: 1 },
{ cartonMark: 'SP-B', sourceUnitType: 'PARCEL', skuCode: 'SKU-B', quantity: 5 },
{ cartonMark: 'SP-OTHER', sourceUnitType: 'PARCEL', skuCode: 'SKU-A', quantity: 20 },
].map(row => ({ ...row, status: 'AVAILABLE', sourceReturnNo: row.cartonMark === 'SP-OTHER' ? 'RT-OTHER' : 'RT-MIXED' }))
api.returnCandidates.mockResolvedValue([])
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 29 }, { skuCode: 'SKU-B', availableQuantity: 11 }] })
api.cartons.mockResolvedValue({ records: sources })
api.carton.mockImplementation(async (mark: string) => { const row = sources.find(row => row.cartonMark === mark)!; return { ...row, items: [{ skuCode: row.skuCode, quantity: row.quantity, availableQuantity: row.quantity }] } })
await openCreate()
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'RETURN_OUTBOUND'); type.vm.$emit('change'); await flushPromises()
const customer = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择客户')!
customer.vm.$emit('update:modelValue', 'customer-a'); customer.vm.$emit('change'); await flushPromises()
const order = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '选择已签收入库的退货单')!
order.vm.$emit('update:modelValue', 'RT-MIXED'); order.vm.$emit('change'); await flushPromises()
const sourceTable = wrapper.findAll('.return-list-card')[1]!
expect(sourceTable.findAll('tbody tr')).toHaveLength(5)
expect(sourceTable.text()).toContain('SP-A')
expect(sourceTable.text()).toContain('BOX-A')
expect(sourceTable.text()).not.toContain('SP-OTHER')
expect(wrapper.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
expect(wrapper.text()).toContain('合箱')
})
beforeEach(() => { beforeEach(() => {
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }) vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item }))) api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
api.create.mockReset().mockResolvedValue({}) api.create.mockReset().mockResolvedValue({})
api.transfers.mockReset().mockResolvedValue([])
api.bindDestination.mockReset().mockResolvedValue([])
api.warehouses.mockReset().mockResolvedValue({ records: [ api.warehouses.mockReset().mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, { id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, { id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
@@ -61,6 +94,40 @@ describe('outbound candidate search and selection', () => {
}) })
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() }) afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
it('groups pallet candidates by customer and transfer and binds each selected transfer once', async () => {
const transfer = { customerId: 'customer-a', transferNo: 'WT-TEST', overseasWarehouseId: 'warehouse-a', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false }
api.transfers.mockResolvedValue([
{ ...transfer, packageCount: 4, volumeCbm: 0.15 },
{ ...transfer, packageCount: 6, volumeCbm: 0.08 },
{ ...transfer, customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 },
])
api.warehouses.mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'fba-a', warehouseName: 'ABE5', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' },
] })
await openCreate()
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'WAREHOUSE_TRANSFER_OUTBOUND')
type.vm.$emit('change')
await flushPromises()
const table = wrapper.findAllComponents({ name: 'ElTable' }).find(row => row.classes().includes('dropship-candidate-table'))!
expect(table.props('data')).toEqual([
expect.objectContaining({ customerId: 'customer-a', packageCount: 10, volumeCbm: 0.23 }),
expect.objectContaining({ customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 }),
])
for (const checkbox of table.findAllComponents(ElCheckbox)) checkbox.vm.$emit('change', true)
const destination = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择FBA目的仓')!
destination.vm.$emit('update:modelValue', 'fba-a')
await flushPromises()
await wrapper.find('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.bindDestination).toHaveBeenCalledOnce()
expect(api.bindDestination).toHaveBeenCalledWith(expect.objectContaining({ transfers: [
{ customerId: 'customer-a', transferNo: 'WT-TEST' },
{ customerId: 'customer-b', transferNo: 'WT-TEST' },
], destinationWarehouseCode: 'ABE5' }))
})
it('offers enabled customers from later pages when creating a return outbound', async () => { it('offers enabled customers from later pages when creating a return outbound', async () => {
vi.mocked(listPlatformTenants) vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>) .mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
@@ -79,7 +79,7 @@ describe('OutboundsView permissions and navigation', () => {
expect(source).toContain('@click="searchReturnSkus"') expect(source).toContain('@click="searchReturnSkus"')
expect(source).toContain('@click="searchReturnCartons"') expect(source).toContain('@click="searchReturnCartons"')
expect(source).toContain(':page-sizes="[5, 10, 20, 50]"') expect(source).toContain(':page-sizes="[5, 10, 20, 50]"')
expect(source).toContain('width="min(1320px, calc(100vw - 32px))"') expect(source).toContain('width="min(1760px, calc(100vw - 32px))" fullscreenable')
}) })
it('resolves warehouse-transfer customer names from the platform customer list', () => { it('resolves warehouse-transfer customer names from the platform customer list', () => {
@@ -36,9 +36,6 @@ const selectedDestinationWarehouseId = ref('')
const deliveryAddress = ref(''); const postalCode = ref(''); const contactName = ref(''); const contactPhone = ref('') const deliveryAddress = ref(''); const postalCode = ref(''); const contactName = ref(''); const contactPhone = ref('')
const returnStockRows = ref<ReturnStockRow[]>([]) const returnStockRows = ref<ReturnStockRow[]>([])
const returnCartonRows = ref<ReturnCartonRow[]>([]) const returnCartonRows = ref<ReturnCartonRow[]>([])
const selectedReturnSkus = ref<string[]>([])
const returnPickMode = ref<'BOX' | 'SKU'>('BOX')
const returnQuantity = ref<number | undefined>()
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('') const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('') const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX') const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
@@ -65,15 +62,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId)) const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId))
return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}` return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}`
} }
const selectedReturnStock = computed(() => returnStockRows.value.map((item) => { const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0))
if (returnPickMode.value !== 'SKU') return item
const quantity = selectedReturnCartons.value.reduce((sum, carton) => {
if (selectedReturnNo.value && !(carton.sourceReturnNo || '').split(',').includes(selectedReturnNo.value)) return sum
const items = returnCartonDetails.value[carton.cartonMark]?.items
return items?.length === 1 && items[0]?.skuCode === item.skuCode ? sum + Number(items[0].availableQuantity ?? items[0].quantity) : sum
}, 0)
return { ...item, availableQuantity: quantity }
}).filter(item => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE')) const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
const returnOrderOptions = computed(() => [...new Set([ const returnOrderOptions = computed(() => [...new Set([
...returnCandidates.value.flatMap(item => (item.returnNo || '').split(',')), ...returnCandidates.value.flatMap(item => (item.returnNo || '').split(',')),
@@ -85,23 +74,17 @@ const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => it
const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) } const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) }
const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value))) const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value)))
const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => { const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => {
if (returnPickMode.value === 'BOX' && item.sourceUnitType === 'PARCEL') return false
if (selectedReturnNo.value && !(item.sourceReturnNo || returnCandidateByMark.value[item.cartonMark]?.returnNo || '').split(',').includes(selectedReturnNo.value)) return false if (selectedReturnNo.value && !(item.sourceReturnNo || returnCandidateByMark.value[item.cartonMark]?.returnNo || '').split(',').includes(selectedReturnNo.value)) return false
if (returnPickMode.value === 'SKU') {
const detail = returnCartonDetails.value[item.cartonMark]
if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || detail.items[0]?.skuCode !== selectedReturnSkus.value[0]) return false
}
const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || '' const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || ''
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value) return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value)
})) }))
const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value)) const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value))
const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value)) const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value))
const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[]) const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[])
const selectedReturnQuantity = computed(() => effectiveReturnMarks.value.reduce((sum, mark) => {
const line = returnCartonDetails.value[mark]?.items.find(item => item.skuCode === selectedReturnSkus.value[0]) watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => {
return sum + Number(line?.availableQuantity ?? line?.quantity ?? 0) selectedReturnMarks.value = []
}, 0)) })
watch(selectedReturnQuantity, quantity => { returnQuantity.value = quantity || undefined })
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } }) const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase()))) const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase())))
const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value)) const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value))
@@ -150,12 +133,24 @@ async function loadInventoryOptions() {
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() } async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } } async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() } async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() } async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() } async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function loadTransferCandidates() { async function loadTransferCandidates() {
inventoryLoading.value = true inventoryLoading.value = true
try { try {
transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined }) const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
// The API returns pallet rows, but destination binding applies to the whole transfer.
const transfers = new Map<string, WarehouseTransferOutboundCandidate>()
for (const pallet of pallets) {
const key = transferSelectionKey(pallet)
const transfer = transfers.get(key)
if (transfer) {
transfer.packageCount += pallet.packageCount
transfer.grossWeightKg = sumTransferMeasurement(transfer.grossWeightKg, pallet.grossWeightKg)
transfer.volumeCbm = sumTransferMeasurement(transfer.volumeCbm, pallet.volumeCbm)
} else transfers.set(key, { ...pallet })
}
transferCandidates.value = [...transfers.values()]
const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey)) const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey))
selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key)) selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key))
if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])] if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]
@@ -166,6 +161,9 @@ async function loadTransferCandidates() {
} finally { inventoryLoading.value = false } } finally { inventoryLoading.value = false }
} }
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` } function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
function sumTransferMeasurement(left?: number | null, right?: number | null) {
return left == null && right == null ? null : Number(((left ?? 0) + (right ?? 0)).toFixed(6))
}
function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) } function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) }
function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) { function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) { if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) {
@@ -177,7 +175,7 @@ function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
function transferHasDestination(row: WarehouseTransferOutboundCandidate) { function transferHasDestination(row: WarehouseTransferOutboundCandidate) {
return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim()) return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim())
} }
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } } async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` } function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) } function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) } function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
@@ -192,10 +190,10 @@ function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuer
function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') } function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') }
function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 } function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 }
function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 } function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [sku] : []; selectedReturnMarks.value = [] }
function changeReturnPickMode() { selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnUnitType.value = 'BOX' }
function changeReturnUnitType() { selectedReturnMarks.value = [] } function changeReturnUnitType() { selectedReturnMarks.value = [] }
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) { selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter((value) => value !== cartonMark) } function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) {
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter(value => value !== cartonMark)
}
function toggleReturnPallet(palletCode: string, checked: boolean | string | number) { function toggleReturnPallet(palletCode: string, checked: boolean | string | number) {
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!) const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark)) selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark))
@@ -216,17 +214,13 @@ async function submit() {
} else if (outboundType.value === 'RETURN_OUTBOUND') { } else if (outboundType.value === 'RETURN_OUTBOUND') {
if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return } if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return }
if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return } if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return }
if (returnPickMode.value === 'SKU' && selectedReturnSkus.value.length !== 1) { ElMessage.warning('请选择一个 SKU'); return } // Return outbound is physical-unit based. Partial SKU quantities must go
if (returnPickMode.value === 'SKU' && (!Number.isSafeInteger(returnQuantity.value) || Number(returnQuantity.value) <= 0 || Number(returnQuantity.value) > selectedReturnQuantity.value)) { // through the PDA packing flow and produce a new labelled carton first.
ElMessage.warning(`请填写 1-${selectedReturnQuantity.value} 件的出库数量`); return
}
if (!selectedReturnMarks.value.length) { if (!selectedReturnMarks.value.length) {
ElMessage.warning(returnPickMode.value === 'SKU' ElMessage.warning('请选择出库包裹/箱号或托盘')
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的来源箱或散件记录' : '该退货单没有此 SKU 的可用单 SKU 箱或散件库存;混合箱请先拆箱'
: '请选择出库箱号或托盘')
return return
} }
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, skuCode: returnPickMode.value === 'SKU' ? selectedReturnSkus.value[0] : undefined, quantity: returnPickMode.value === 'SKU' ? returnQuantity.value : undefined, unitType: returnPickMode.value === 'SKU' ? 'BOX' : returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }) await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, unitType: returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA') ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA')
} else { } else {
const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))) const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item)))
@@ -264,7 +258,7 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit"> <AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top"> <el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required> <el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType"> <el-radio-group v-model="outboundType" @change="changeOutboundType">
@@ -346,21 +340,21 @@ onMounted(load)
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item> <el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item> <el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item> <el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item>
<p v-if="returnPickMode === 'SKU'" class="candidate-hint">单 SKU 模式可填写全部或部分件数;已装箱货物由 PDA 扫箱唛按计划件数拣货,剩余数量保留在原箱。</p><el-form-item v-if="returnPickMode === 'SKU'" label="出库数量(件)" required><el-input-number v-model="returnQuantity" :min="1" :max="selectedReturnQuantity || 1" :precision="0" placeholder="选择来源后填写件数" /><span class="candidate-hint">所选来源可用 {{ selectedReturnQuantity }} 件;按来源箱号顺序分配</span></el-form-item><el-form-item label="出库维度"><el-radio-group v-model="returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU">单 SKU</el-radio-button></el-radio-group></el-form-item> <p class="candidate-hint">整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p>
<el-form-item v-if="returnPickMode === 'BOX'" label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item> <el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnPickMode === 'BOX' && returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item> <el-form-item v-if="returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
<div class="return-lists"> <div class="return-lists">
<section class="return-list-card"> <section class="return-list-card">
<div class="list-title"><span>SKU库存</span><small>按 SKU 查询</small></div> <div class="list-title"><span>SKU库存</span><small>按 SKU 查询</small></div>
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div> <div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column v-if="returnPickMode === 'SKU'" label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnSkus.includes(row.skuCode)" @change="toggleReturnSku(row.skuCode, $event)" /></template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table> <el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>{{ returnPickMode === 'SKU' ? `已选择 ${selectedReturnSkus.length} 个 SKU` : 'SKU 库存' }}</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div> <div class="return-pagination"><span>SKU 库存</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
</section> </section>
<section class="return-list-card"> <section class="return-list-card">
<div class="list-title"><span>{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div> <div class="list-title"><span>包裹 / 箱号列表</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div> <div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column :label="returnPickMode === 'SKU' ? '库存来源' : '箱号'" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="箱内SKU及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table> <el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个{{ returnPickMode === 'SKU' ? '来源' : '箱号' }}</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div> <div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
</section> </section>
</div> </div>
</template> </template>
@@ -392,7 +386,7 @@ onMounted(load)
.candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; } .candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; }
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; } .candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; } .candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); } .return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; } .return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); } .return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); } .list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
@@ -0,0 +1,27 @@
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { getRelabelOrder } from '../api/relabelOrders'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/relabelOrders', () => ({ getRelabelOrder: vi.fn(), confirmRelabelCompletion: vi.fn(), getRelabelReviewTask: vi.fn(), returnRelabelForRework: vi.fn(), downloadRelabelCompletionPhoto: vi.fn(), downloadRelabelPdf: vi.fn(), uploadRelabelPdf: vi.fn() }))
it('reloads authoritative status when a cached detail tab is reopened without double loading initially', async () => {
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }, items: [], history: [] })
.mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }, items: [], history: [] })
const active = ref(true)
const Host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(RelabelOrderDetailView) : h('div') }) })
const wrapper = mount(Host, { global: { stubs: { OverseasReadOnlyDetail: true, AppDialog: true } } })
await flushPromises()
expect(getRelabelOrder).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'OverseasReadOnlyDetail' }).props('statusText')).toBe('待审核')
active.value = false
await flushPromises()
active.value = true
await flushPromises()
expect(getRelabelOrder).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent({ name: 'OverseasReadOnlyDetail' }).props('statusText')).toBe('待处理')
wrapper.unmount()
})
@@ -18,6 +18,9 @@ describe('RelabelOrderDetailView', () => {
expect(source).toContain("PENDING_REVIEW: '待审核'") expect(source).toContain("PENDING_REVIEW: '待审核'")
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo') expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId') expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('items.flatMap(relabelSkuCodes)')
expect(source).toContain('v-for="(sku, index) in item.skuItems"')
expect(source).toContain('{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件')
}) })
it('lets platform operators confirm completion or return PDA work for rework', () => { it('lets platform operators confirm completion or return PDA work for rework', () => {
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue' import { computed, onActivated, onMounted, ref, onBeforeUnmount } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue' import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,6 +14,7 @@ import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, up
import { formatOverseasDateTime } from '../lastMilePresentation' import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue' import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -80,7 +81,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
资金状态: walletStatus(value?.application.walletStatus), 资金状态: walletStatus(value?.application.walletStatus),
创建时间: formatOverseasDateTime(value?.application.createdAt), 创建时间: formatOverseasDateTime(value?.application.createdAt),
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—', 更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—', 商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—', 中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: Number(value?.application.totalPackageCount || 0), 箱数: Number(value?.application.totalPackageCount || 0),
预算总价: '—', 预算总价: '—',
@@ -100,6 +101,7 @@ async function load() {
} }
function back() { void router.push({ name: 'overseas-relabel-orders' }) } function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load) onMounted(load)
onActivated(() => { if (!loading.value) void load() })
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) }) onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) { async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = '' const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
@@ -135,7 +137,7 @@ async function previewCompletionPhoto(fileId: string) {
<table class="density-table"> <table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead> <thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody> <tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr> <tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr> <tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody> </tbody>
</table> </table>
@@ -6,6 +6,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { import {
changeRelabelOrderStatus, changeRelabelOrderStatus,
createRelabelOrder, createRelabelOrder,
getRelabelOrder,
listRelabelOrders, listRelabelOrders,
listRelabelWarehouseOptions, listRelabelWarehouseOptions,
} from '../api/relabelOrders' } from '../api/relabelOrders'
@@ -18,6 +19,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/relabelOrders', () => ({ vi.mock('../api/relabelOrders', () => ({
changeRelabelOrderStatus: vi.fn(), changeRelabelOrderStatus: vi.fn(),
createRelabelOrder: vi.fn(), createRelabelOrder: vi.fn(),
getRelabelOrder: vi.fn(),
listRelabelOrders: vi.fn(), listRelabelOrders: vi.fn(),
listRelabelWarehouseOptions: vi.fn(), listRelabelWarehouseOptions: vi.fn(),
})) }))
@@ -40,6 +42,25 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals()) afterAll(() => vi.unstubAllGlobals())
describe('RelabelOrdersView', () => { describe('RelabelOrdersView', () => {
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-MULTI', skuCodes: 'SKU-A,SKU-B', totalPackageCount: 1, status: 'PENDING_REVIEW' }], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-MULTI', totalPackageCount: 1 }, items: [{ id: '1', skuCode: 'SKU-A', newSkuCode: 'NEW-A', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('SKU-A,SKU-B')
await wrapper.findAll('button').find(button => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-A、SKU-B')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-A × 10 件;NEW-B × 5 件')
expect(formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
})
it('renders product names and labeling requirements supplied by the list projection', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', productNameZh: '商品甲、商品乙', labelRequirement: '贴标要求111', status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('商品甲、商品乙')
expect(wrapper.text()).toContain('贴标要求111')
})
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage()) vi.stubGlobal('localStorage', memoryStorage())
@@ -48,6 +69,7 @@ describe('RelabelOrdersView', () => {
vi.mocked(listRelabelWarehouseOptions).mockReset() vi.mocked(listRelabelWarehouseOptions).mockReset()
vi.mocked(createRelabelOrder).mockReset() vi.mocked(createRelabelOrder).mockReset()
vi.mocked(changeRelabelOrderStatus).mockReset() vi.mocked(changeRelabelOrderStatus).mockReset()
vi.mocked(getRelabelOrder).mockReset().mockResolvedValue({ application: { relabelNo: 'RL-1' }, items: [], history: [] } as never)
}) })
it('loads shared receiving warehouse options independently from the selected customer', async () => { it('loads shared receiving warehouse options independently from the selected customer', async () => {
@@ -133,6 +155,27 @@ describe('RelabelOrdersView', () => {
expect(labels).toEqual(['审核通过', '已驳回']) expect(labels).toEqual(['审核通过', '已驳回'])
}) })
it('loads the full detail before opening audit so item fields are echoed', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-OLD', totalPackageCount: 2, chargeAmount: 0.01, status: 'PENDING_REVIEW' }],
page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(getRelabelOrder).mockResolvedValueOnce({
application: { relabelNo: 'RL-1', customerOrderNo: 'CO-1', chargeAmount: 12.5 },
remark: '请按新标签处理',
items: [{ skuCode: 'SKU-NEW', productNameZh: '中文商品', totalPackageCount: 3, shipmentNo: 'SHIP-9', newSkuCode: 'NEW-SKU-9', labelRequirement: '换箱唛和SKU标' }],
history: [],
} as never)
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-NEW')
expect(formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('中文商品')
expect(formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SHIP-9')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-SKU-9')
})
it('passes the relabel order number search to the backend', async () => { it('passes the relabel order number search to the backend', async () => {
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
import { Plus, PackagePlus } from '@lucide/vue' import { Plus, PackagePlus } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -14,7 +15,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders' import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation' import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation'
import type { RelabelOrderListItem } from '../types' import type { RelabelOrderListItem } from '../types'
@@ -55,6 +56,7 @@ const columns: DataTableColumn[] = [
const dialogOpen = ref(false) const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create') const dialogMode = ref<'create' | 'edit'>('create')
const submitting = ref(false) const submitting = ref(false)
const detailLoading = ref(false)
const editSourceStatus = ref('') const editSourceStatus = ref('')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' }) const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createIdempotencyKey = ref('') const createIdempotencyKey = ref('')
@@ -73,7 +75,7 @@ function openPackingPlan() {
if (customerIds.length > 1) { ElMessage.warning('合箱请选择同一客户的退货库存'); return } if (customerIds.length > 1) { ElMessage.warning('合箱请选择同一客户的退货库存'); return }
void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } }) void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } })
} }
function openForm(mode: 'create' | 'edit', source?: object) { async function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelOrderListItem | undefined const row = source as RelabelOrderListItem | undefined
dialogMode.value = mode dialogMode.value = mode
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' }) Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
@@ -89,6 +91,27 @@ function openForm(mode: 'create' | 'edit', source?: object) {
void loadWarehouses() void loadWarehouses()
} }
dialogOpen.value = true dialogOpen.value = true
if (mode === 'edit' && row?.orderNo) {
detailLoading.value = true
try {
const detail = await getRelabelOrder(String(row.orderNo))
Object.assign(form, {
orderNo: String(detail.application?.relabelNo || row.orderNo || ''),
relatedOrderNo: String(detail.application?.lastlegOrderNo || detail.application?.customerOrderNo || row.relatedOrderNo || ''),
sku: [...new Set(detail.items.flatMap(relabelSkuCodes))].join('、') || row.sku || '',
productName: [...new Set(detail.items.map(line => line.productNameZh?.trim()).filter(Boolean))].join('、') || row.productName || '',
cartons: optionalNumber(detail.application?.totalPackageCount ?? row.cartons),
labelRequirement: [...new Set(detail.items.map(line => line.labelRequirement?.trim()).filter(Boolean))].join(';') || row.labelRequirement || '',
shipmentNo: [...new Set(detail.items.map(line => line.shipmentNo?.trim()).filter(Boolean))].join('、'),
newSkuCode: detail.items.flatMap(line => line.skuItems?.length ? line.skuItems.map(sku => `${sku.newSkuCode} × ${sku.quantity} 件`) : [line.newSkuCode || '']).filter(Boolean).join(';'),
relabelCost: optionalNumber(detail.application?.chargeAmount ?? row.relabelCost),
remark: String(detail.remark || detail.application?.reviewRemark || row.remark || ''),
})
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '换标单详情加载失败,请重试').message)
dialogOpen.value = false
} finally { detailLoading.value = false }
}
} }
async function loadCustomers() { async function loadCustomers() {
@@ -182,7 +205,7 @@ async function load(model: QueryModel = filters.value) {
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }) const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({ rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'), customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '', productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
reviewChannel: item.reviewChannel || undefined, reviewChannel: item.reviewChannel || undefined,
createdAt: formatOverseasDateTime(item.createdAt), createdAt: formatOverseasDateTime(item.createdAt),
})) }))
@@ -193,6 +216,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) } function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) } function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load) onMounted(load)
onActivated(() => { if (!loading.value) void load() })
watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' }) watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</script> </script>
@@ -212,13 +236,13 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm"> <AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading" :confirm-disabled="detailLoading" @confirm="submitForm">
<el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16"> <el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col> <el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col> <el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col> <el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col> <el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
@@ -228,7 +252,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</el-row><h3>作业要求</h3><el-row :gutter="16"> </el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col> <el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col> <el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col> <el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form> </el-row></el-form>
@@ -5,7 +5,7 @@ import ElementPlus from 'element-plus'
import DataTable from '@/shared/components/DataTable.vue' import DataTable from '@/shared/components/DataTable.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory' import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory'
import { listReturnPackingPlans } from '../api/relabelOrders' import { createReturnPackingPlan, listReturnPackingPlans } from '../api/relabelOrders'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue' import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
import { listPlatformTenants } from '@/modules/iam' import { listPlatformTenants } from '@/modules/iam'
const routeQuery = vi.hoisted(() => ({} as Record<string, string>)) const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
@@ -25,7 +25,7 @@ vi.mock('../api/relabelOrders', () => ({
reviewReturnPackingPlan: vi.fn(), cancelReturnPackingPlan: vi.fn(), reviewReturnPackingPlan: vi.fn(), cancelReturnPackingPlan: vi.fn(),
})) }))
const wrappers: VueWrapper[] = [] const wrappers: VueWrapper[] = []
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' } const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, relabelLockedQuantity: 2, cartonQuantity: 1, updatedAt: '2026-09-30' }
const carton = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', cartonMark: 'RETURN-BOX-1', status: 'AVAILABLE', skuTypes: 1, totalQuantity: 8, createdAt: '2026-09-30' } const carton = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', cartonMark: 'RETURN-BOX-1', status: 'AVAILABLE', skuTypes: 1, totalQuantity: 8, createdAt: '2026-09-30' }
async function mountView(mode: 'inventory' | 'cartons') { async function mountView(mode: 'inventory' | 'cartons') {
const wrapper = mount(ReturnInventoryManagementView, { const wrapper = mount(ReturnInventoryManagementView, {
@@ -56,6 +56,35 @@ afterEach(() => {
}) })
describe('ReturnInventoryManagementView shared list', () => { describe('ReturnInventoryManagementView shared list', () => {
it('退货库存展示服务端换标占用列', async () => {
const wrapper = await mountView('inventory')
const columns = wrapper.findComponent(DataTable).props('columns')
expect(columns).toEqual(expect.arrayContaining([expect.objectContaining({ prop: 'relabelLockedQuantity', label: '换标占用(件)' })]))
expect(wrapper.findComponent(DataTable).props('data')[0]).toEqual(expect.objectContaining({ relabelLockedQuantity: 2 }))
})
it('keeps partial quantities bound to each physical parcel and box when they share a SKU', async () => {
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [
{ ...carton, cartonMark: 'PARCEL-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
{ ...carton, cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 4, availableQuantity: 4 }] },
], page: 1, size: 200, total: 2, pages: 1 })
const wrapper = await mountView('inventory')
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click'); await flushPromises()
const form = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const customer = form.getComponent({ name: 'ElSelect' })
customer.vm.$emit('update:modelValue', 'customer-1'); customer.vm.$emit('change', 'customer-1'); await flushPromises()
const mode = form.getComponent({ name: 'ElRadioGroup' })
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
const rows = form.findAll('.packing-sku-row')
expect(rows).toHaveLength(2)
rows.forEach(row => row.getComponent({ name: 'ElCheckbox' }).vm.$emit('change', true)); await flushPromises()
rows[1]!.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
wrapper.findAllComponents({ name: 'AppDialog' }).find(dialog => dialog.props('modelValue'))!.vm.$emit('confirm'); await flushPromises()
expect(createReturnPackingPlan).toHaveBeenCalledWith(expect.objectContaining({
mode: 'SKU', sourceCartonMarks: ['PARCEL-A', 'BOX-A'],
sourceDeductions: [{ cartonMark: 'PARCEL-A', skuCode: 'SKU-A', quantity: 1 }, { cartonMark: 'BOX-A', skuCode: 'SKU-A', quantity: 2 }],
boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 3 }] }],
}))
})
it('prefills the selected return orders after loading sources from the tail-order entry', async () => { it('prefills the selected return orders after loading sources from the tail-order entry', async () => {
Object.assign(routeQuery, { packing: '1', customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', returnNos: 'RETURN-1' }) Object.assign(routeQuery, { packing: '1', customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', returnNos: 'RETURN-1' })
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [{ ...carton, sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-TEST', quantity: 8, availableQuantity: 8 }] }], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [{ ...carton, sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-TEST', quantity: 8, availableQuantity: 8 }] }], page: 1, size: 200, total: 1, pages: 1 })
@@ -248,3 +277,12 @@ describe('ReturnInventoryManagementView shared list', () => {
expect(document.body.textContent).toContain('SKU-TEST') expect(document.body.textContent).toContain('SKU-TEST')
}) })
}) })
it('箱列表不展示换标单号和换标进度', async () => {
vi.mocked(listReturnCartons).mockResolvedValue({ records: [{ ...carton, relabelStatus: 'PARTIALLY_COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 2 }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = await mountView('cartons')
expect(wrapper.text()).not.toContain('换标单号')
expect(wrapper.text()).not.toContain('换标进度')
expect(wrapper.text()).not.toContain('实际完成 3 件')
expect(wrapper.text()).not.toContain('处理中占用 2 件')
})
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
import "@/styles/adminDensity.css"; import "@/styles/adminDensity.css";
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -54,7 +55,7 @@ const warehouses = ref<Array<{ id: string; warehouseName: string }>>([]);
const optionsLoading = ref(false), const optionsLoading = ref(false),
packingDialogOpen = ref(false), packingDialogOpen = ref(false),
packingSaving = ref(false); packingSaving = ref(false);
const packingSkuRows = ref<Array<Pick<ReturnStockRow, 'skuCode' | 'availableQuantity'>>>([]), const packingSkuRows = ref<Array<{ cartonMark: string; skuCode: string; availableQuantity: number; key: string }>>([]),
packingReturnOptions = ref< packingReturnOptions = ref<
Array<{ returnNo: string; unitTypes: string[]; cartonCount: number }> Array<{ returnNo: string; unitTypes: string[]; cartonCount: number }>
>([]); >([]);
@@ -176,9 +177,20 @@ function openPlanDetail(row: Record<string, unknown>) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo); planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
planDetailOpen.value = !!planDetail.value; planDetailOpen.value = !!planDetail.value;
} }
const planCartonItemRows = computed(() => (planDetail.value?.resultCartons || []).flatMap(carton => const planCartonItemRows = computed(() => (planDetail.value?.completionCartons || []).flatMap(carton =>
carton.items.map(item => ({ ...carton, skuCode: item.skuCode, quantity: item.quantity })), carton.items.map(item => ({ ...carton, skuCode: item.skuCode, quantity: item.quantity })),
)); ));
const planSourceRows = computed(() => (planDetail.value?.sources || []).flatMap(source => {
const original = source.originalItems?.length ? source.originalItems : source.items;
const existingTarget = planDetail.value?.status === 'COMPLETED' && planDetail.value.completionCartons?.some(carton => carton.cartonMark === source.cartonMark);
return original.map(item => {
const take = existingTarget ? 0 : source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0;
return { cartonMark: source.cartonMark, sourceReturnNo: source.sourceReturnNo, participation: source.participation,
role: existingTarget ? '目标原箱(保留)' : '取出来源',
skuCode: item.skuCode, originalQuantity: source.originalItems?.length ? item.quantity : null, take,
remaining: source.originalItems?.length ? item.quantity - take : null };
});
}));
const plannedItemRows = computed(() => (planDetail.value?.boxes || []).flatMap((box, index) => const plannedItemRows = computed(() => (planDetail.value?.boxes || []).flatMap((box, index) =>
(box.items || []).map(item => ({ boxNo: index + 1, skuCode: item.skuCode, quantity: item.quantity })), (box.items || []).map(item => ({ boxNo: index + 1, skuCode: item.skuCode, quantity: item.quantity })),
)); ));
@@ -311,7 +323,7 @@ async function loadPackingSources() {
} }
function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); } function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); }
function updatePackingScope() { function updatePackingScope() {
const sources = packingSources.value.filter(row => packingForm.mode === 'SKU' ? row.sourceUnitType === 'PARCEL' : row.sourceUnitType !== 'PARCEL'); const sources = packingSources.value;
const options = new Map<string, { returnNo: string; unitTypes: string[]; cartonCount: number }>(); const options = new Map<string, { returnNo: string; unitTypes: string[]; cartonCount: number }>();
for (const source of sources) for (const returnNo of sourceReturnNos(source)) { for (const source of sources) for (const returnNo of sourceReturnNos(source)) {
const option = options.get(returnNo) || { returnNo, unitTypes: [], cartonCount: 0 }; const option = options.get(returnNo) || { returnNo, unitTypes: [], cartonCount: 0 };
@@ -320,13 +332,11 @@ function updatePackingScope() {
} }
packingReturnOptions.value = [...options.values()]; packingReturnOptions.value = [...options.values()];
const scoped = sources.filter(row => !packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no))); const scoped = sources.filter(row => !packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no)));
packingBoxRows.value = scoped.filter(row => row.sourceUnitType !== 'PARCEL').map(row => ({ cartonMark: row.cartonMark, returnNo: row.sourceReturnNo || '', unitType: row.sourceUnitType || 'BOX', items: row.items })); packingBoxRows.value = scoped.map(row => ({ cartonMark: row.cartonMark, returnNo: row.sourceReturnNo || '', unitType: row.sourceUnitType || 'BOX', items: row.items }));
const quantities = new Map<string, number>(); packingSkuRows.value = scoped.flatMap(source => source.items.map(item => ({
for (const source of scoped.filter(row => row.sourceUnitType === 'PARCEL')) for (const item of source.items) cartonMark: source.cartonMark, skuCode: item.skuCode,
quantities.set(item.skuCode, (quantities.get(item.skuCode) || 0) + Number(item.availableQuantity ?? item.quantity)); availableQuantity: Number(item.availableQuantity ?? item.quantity), key: `${source.cartonMark}|${item.skuCode}`,
packingSkuRows.value = [...quantities].filter(([, quantity]) => quantity > 0).map(([skuCode, quantity]) => ({ }))).filter(item => item.availableQuantity > 0);
skuCode, availableQuantity: quantity,
}));
} }
function togglePackingSku(sku: string, checked: boolean) { function togglePackingSku(sku: string, checked: boolean) {
if (checked) packingForm.skuQuantities[sku] = 1; if (checked) packingForm.skuQuantities[sku] = 1;
@@ -341,12 +351,14 @@ function changePackingMode() {
updatePackingScope(); updatePackingScope();
} }
async function submitPackingPlan() { async function submitPackingPlan() {
const selectedBoxes = packingSources.value.filter(row => packingForm.sourceCartonMarks.includes(row.cartonMark)); const sourceDeductions = packingSkuRows.value.filter(row => Number(packingForm.skuQuantities[row.key]) > 0)
const selectedSources = packingForm.mode === 'BOX' ? selectedBoxes : packingSources.value.filter(row => row.sourceUnitType === 'PARCEL' && (!packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no)))); .map(row => ({ cartonMark: row.cartonMark, skuCode: row.skuCode, quantity: Number(packingForm.skuQuantities[row.key]) }));
const returnNos = [...new Set(selectedSources.flatMap(sourceReturnNos))]; const selectedMarks = packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [...new Set(sourceDeductions.map(row => row.cartonMark))];
const items = Object.entries(packingForm.skuQuantities) const selectedBoxes = packingSources.value.filter(row => selectedMarks.includes(row.cartonMark));
.filter(([, quantity]) => Number(quantity) > 0) const returnNos = [...new Set(selectedBoxes.flatMap(sourceReturnNos))];
.map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) })); const quantities = new Map<string, number>();
sourceDeductions.forEach(row => quantities.set(row.skuCode, (quantities.get(row.skuCode) || 0) + row.quantity));
const items = [...quantities].map(([skuCode, quantity]) => ({ skuCode, quantity }));
const boxes = [{ items }]; const boxes = [{ items }];
if ( if (
!packingForm.customerId || !packingForm.overseasWarehouseId || !packingForm.customerId || !packingForm.overseasWarehouseId ||
@@ -368,7 +380,8 @@ async function submitPackingPlan() {
returnNos, returnNos,
mode: packingForm.mode, mode: packingForm.mode,
boxes, boxes,
sourceCartonMarks: packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [], sourceCartonMarks: selectedMarks,
sourceDeductions: packingForm.mode === 'SKU' ? sourceDeductions : undefined,
remark: packingForm.remark.trim() || undefined, remark: packingForm.remark.trim() || undefined,
idempotencyKey: idempotencyKey:
globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`, globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`,
@@ -425,6 +438,7 @@ const stockColumns: DataTableColumn[] = [
prop: "availableQuantity", prop: "availableQuantity",
align: "right", align: "right",
}, },
{ key: "relabelLockedQuantity", label: "换标占用(件)", prop: "relabelLockedQuantity", minWidth: 140, align: "right" },
{ key: "updatedAt", label: "更新时间", prop: "updatedAt", minWidth: 180 }, { key: "updatedAt", label: "更新时间", prop: "updatedAt", minWidth: 180 },
]; ];
const movementColumns: DataTableColumn[] = [ const movementColumns: DataTableColumn[] = [
@@ -487,8 +501,6 @@ const cartonColumns: DataTableColumn[] = [
minWidth: 180, minWidth: 180,
}, },
{ key: "carton-6", label: "目的仓", minWidth: 180, slotName: "carton-6" }, { key: "carton-6", label: "目的仓", minWidth: 180, slotName: "carton-6" },
{ key: "relabelNo", label: "换标单号", prop: "relabelNo", minWidth: 200 },
{ key: "carton-8", label: "换标状态", width: 110, slotName: "carton-8" },
{ key: "carton-9", label: "状态", width: 100, slotName: "carton-9" }, { key: "carton-9", label: "状态", width: 100, slotName: "carton-9" },
{ key: "skuTypes", label: "SKU种类", prop: "skuTypes", align: "right" }, { key: "skuTypes", label: "SKU种类", prop: "skuTypes", align: "right" },
{ {
@@ -667,11 +679,10 @@ const tableColumns = computed(() =>
warehouseName(row.overseasWarehouseId) warehouseName(row.overseasWarehouseId)
}}</template> }}</template>
<template #movement-4="{ row }">{{ <template #movement-4="{ row }">{{
row.movementType === "RECEIPT" ({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库",
? "退货签收" SKU_CONVERSION_OUT: "SKU换标", SKU_CONVERSION_IN: "SKU换标",
: row.movementType === "MERGE" SKU_CONVERSION_REVERSE_IN: "SKU换标撤销", SKU_CONVERSION_REVERSE_OUT: "SKU换标撤销"
? "退货合箱" } as Record<string, string>)[row.movementType] || row.movementType || "—"
: "退货出库"
}}</template> }}</template>
<template #plan-customer="{ row }">{{ customerName(row.customerId) }}</template> <template #plan-customer="{ row }">{{ customerName(row.customerId) }}</template>
<template #plan-return-nos="{ row }">{{ <template #plan-return-nos="{ row }">{{
@@ -679,9 +690,9 @@ const tableColumns = computed(() =>
}}</template }}</template
><template #plan-mode="{ row }">{{ ><template #plan-mode="{ row }">{{
row.mode === "SKU" row.mode === "SKU"
? "按 SKU 合箱" ? "部分包裹/箱合箱"
: row.mode === "BOX" : row.mode === "BOX"
? "按箱合箱" ? "整包裹/箱合箱"
: row.mode : row.mode
}}</template }}</template
><template #plan-status="{ row }">{{ ><template #plan-status="{ row }">{{
@@ -732,13 +743,6 @@ const tableColumns = computed(() =>
<template #carton-6="{ row }">{{ <template #carton-6="{ row }">{{
row.destinationWarehouseCode || row.destinationWarehouseName || "—" row.destinationWarehouseCode || row.destinationWarehouseName || "—"
}}</template> }}</template>
<template #carton-8="{ row }">{{
row.relabelStatus === "COMPLETED"
? "已完成"
: row.relabelStatus === "BOUND"
? "已绑定"
: row.relabelStatus || "未绑定"
}}</template>
<template #carton-9="{ row }">{{ statusLabel(row.status) }}</template> <template #carton-9="{ row }">{{ statusLabel(row.status) }}</template>
<template #carton-12="{ row }">{{ <template #carton-12="{ row }">{{
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—" row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
@@ -767,9 +771,9 @@ const tableColumns = computed(() =>
:value="item.value" /></el-select></el-form-item :value="item.value" /></el-select></el-form-item
><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式" ><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式"
><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode" ><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"
><el-radio-button label="BOX">按箱合箱</el-radio-button ><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button
><el-radio-button label="SKU" ><el-radio-button label="SKU"
>按 SKU 合箱</el-radio-button >部分包裹/箱合箱</el-radio-button
></el-radio-group ></el-radio-group
></el-form-item ></el-form-item
><el-form-item label="来源退货单筛选(可选,后台自动追溯)" ><el-form-item label="来源退货单筛选(可选,后台自动追溯)"
@@ -779,7 +783,7 @@ const tableColumns = computed(() =>
placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔" placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"
style="width: 100%" style="width: 100%"
><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item ><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item
><el-form-item v-if="packingForm.mode === 'SKU'" label="合箱 SKU" ><el-form-item v-if="packingForm.mode === 'SKU'" label="来源包裹/箱和合箱数量"
><div class="packing-sku-list-wrap"> ><div class="packing-sku-list-wrap">
<el-input <el-input
v-model="packingForm.skuSearchInput" v-model="packingForm.skuSearchInput"
@@ -791,19 +795,19 @@ const tableColumns = computed(() =>
<div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div> <div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div>
<div <div
v-for="item in visiblePackingSkuRows" v-for="item in visiblePackingSkuRows"
:key="item.skuCode" :key="item.key"
class="packing-sku-row" class="packing-sku-row"
> >
<el-checkbox <el-checkbox
:model-value=" :model-value="
packingForm.skuQuantities[item.skuCode] !== undefined packingForm.skuQuantities[item.key] !== undefined
" "
@change="togglePackingSku(item.skuCode, Boolean($event))" @change="togglePackingSku(item.key, Boolean($event))"
>{{ item.skuCode }}</el-checkbox >{{ item.cartonMark }} · {{ item.skuCode }}</el-checkbox
><span>可用 {{ item.availableQuantity }}</span ><span>可用 {{ item.availableQuantity }}</span
><el-input-number ><el-input-number
v-if="packingForm.skuQuantities[item.skuCode] !== undefined" v-if="packingForm.skuQuantities[item.key] !== undefined"
v-model="packingForm.skuQuantities[item.skuCode]" v-model="packingForm.skuQuantities[item.key]"
:min="1" :min="1"
:max="item.availableQuantity" :max="item.availableQuantity"
:controls="false" :controls="false"
@@ -812,7 +816,7 @@ const tableColumns = computed(() =>
</div> </div>
<el-empty <el-empty
v-if="!visiblePackingSkuRows.length" v-if="!visiblePackingSkuRows.length"
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无包裹签收的单 SKU 库存'" :description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'"
/></div></div></el-form-item /></div></div></el-form-item
><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细" ><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细"
><div class="packing-sku-list-wrap"> ><div class="packing-sku-list-wrap">
@@ -821,14 +825,14 @@ const tableColumns = computed(() =>
<div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div> <div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div>
<div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row"> <div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group> <el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group>
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span> <strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
</div> </div>
<el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" /> <el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" />
</div></div></el-form-item </div></div></el-form-item
><el-alert ><el-alert
type="info" type="info"
:closable="false" :closable="false"
title="散 SKU 创建新箱,箱规由 PDA 填写;按箱合并由 PDA 扫描已有目标箱。提交即锁定来源库存,取消计划可释放。" /><el-form-item title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" /><el-form-item
label="合箱备注(可选)" label="合箱备注(可选)"
><el-input ><el-input
v-model="packingForm.remark" v-model="packingForm.remark"
@@ -854,7 +858,7 @@ const tableColumns = computed(() =>
customerName(planDetail.customerId) customerName(planDetail.customerId)
}}</el-descriptions-item }}</el-descriptions-item
><el-descriptions-item label="合箱方式">{{ ><el-descriptions-item label="合箱方式">{{
planDetail.mode === "SKU" ? "按 SKU 合箱" : "按箱合箱" planDetail.mode === "SKU" ? "部分包裹/箱合箱" : "整包裹/箱合箱"
}}</el-descriptions-item }}</el-descriptions-item
><el-descriptions-item label="状态">{{ ><el-descriptions-item label="状态">{{
planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核' planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核'
@@ -865,14 +869,37 @@ const tableColumns = computed(() =>
><el-descriptions-item label="备注" :span="2">{{ ><el-descriptions-item label="备注" :span="2">{{
planDetail.remark || "—" planDetail.remark || "—"
}}</el-descriptions-item></el-descriptions }}</el-descriptions-item></el-descriptions
><el-table v-if="planDetail.resultCartons?.length" :data="planCartonItemRows" class="detail-items"> >
<h3>计划来源箱明细</h3>
<el-table :data="planSourceRows" class="detail-items" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210" />
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
<template v-if="planDetail.status === 'COMPLETED'">
<h3>实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" class="detail-items" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本次合入" width="100" />
</el-table>
<h3>合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})</h3>
<p>结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
</template>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows" class="detail-items">
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250" /> <el-table-column prop="cartonMark" label="实际箱唛" min-width="250" />
<el-table-column prop="skuCode" label="SKU" min-width="300" /> <el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" /> <el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column prop="locationCode" label="库位" min-width="100" /> <el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column prop="palletCode" label="载体托盘" min-width="120" />
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column> <el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" @click="printLabel(row.cartonMark)">打印箱唛</el-button></template></el-table-column> <el-table-column label="操作" width="130"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" :disabled="!planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></template></el-table-column>
</el-table> </el-table>
<el-table v-else :data="plannedItemRows" class="detail-items" empty-text="待 PDA 填写箱子明细"> <el-table v-else :data="plannedItemRows" class="detail-items" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" /> <el-table-column prop="boxNo" label="箱序号" width="90" />
@@ -912,12 +939,8 @@ const tableColumns = computed(() =>
detail.relabelNo || "—" detail.relabelNo || "—"
}}</el-descriptions-item }}</el-descriptions-item
><el-descriptions-item label="换标状态">{{ ><el-descriptions-item label="换标状态">{{
detail.relabelStatus === "COMPLETED" returnRelabelStatusLabel(detail.relabelStatus)
? "已完成" }}|实际完成 {{ detail.relabelledQuantity ?? "—" }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? "—" }} 件</el-descriptions-item
: detail.relabelStatus === "BOUND"
? "已绑定"
: detail.relabelStatus || "未绑定"
}}</el-descriptions-item
><el-descriptions-item label="状态">{{ ><el-descriptions-item label="状态">{{
statusLabel(detail.status) statusLabel(detail.status)
}}</el-descriptions-item }}</el-descriptions-item
@@ -369,7 +369,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(detail).toContain('单据信息') expect(detail).toContain('单据信息')
expect(detail).toContain('关联订单明细') expect(detail).toContain('关联订单明细')
expect(detail).toContain(':data="relatedOrders"') expect(detail).toContain(':data="relatedOrders"')
expect(detail).toContain("router.push({ name: 'overseas-dispatches' })") expect(detail).toContain("router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' })")
expect(detail).toContain("inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'")
expect(detail).not.toContain('ElMessage.success') expect(detail).not.toContain('ElMessage.success')
}) })
@@ -14,7 +14,7 @@ import InventoryTransactionsView from './InventoryTransactionsView.vue'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue' import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } })) const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/inboundOrders', () => ({ vi.mock('../api/inboundOrders', () => ({
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
@@ -2,6 +2,7 @@ export const overseasWarehousePermissions = {
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list', lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export', lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export',
lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit', lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit',
lastMileOrderPalletMerge: 'platform:overseas-warehouse:last-mile-order:edit',
warehouseTransferApprove: 'platform:overseas-warehouse:warehouse-transfer:approve', warehouseTransferApprove: 'platform:overseas-warehouse:warehouse-transfer:approve',
cargoDiscardApprove: 'lastleg:cargo-discard:approve', cargoDiscardApprove: 'lastleg:cargo-discard:approve',
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list', inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',

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