Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec1e2d77c | ||
|
|
dd47143d6e | ||
|
|
10da46beab | ||
|
|
cc5ac2e68a | ||
|
|
e8e7465e4e | ||
|
|
b4b802a547 |
@@ -43,9 +43,7 @@
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 149 KiB |
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"testOnly": true,
|
||||
"deployment": {
|
||||
"admin": {
|
||||
"build": 536,
|
||||
"result": "SUCCESS",
|
||||
"commit": "73c29f5"
|
||||
},
|
||||
"firstleg": {
|
||||
"build": 488,
|
||||
"result": "SUCCESS",
|
||||
"commit": "e94271706ce7"
|
||||
},
|
||||
"lastleg": {
|
||||
"build": 234,
|
||||
"result": "SUCCESS",
|
||||
"commit": "7fd7699f6e83"
|
||||
}
|
||||
},
|
||||
"databaseReset": {
|
||||
"tables": [
|
||||
"ll_inbound_fba_appointment",
|
||||
"ll_inbound_fba_appointment_item"
|
||||
],
|
||||
"beforeCounts": [
|
||||
49,
|
||||
59
|
||||
],
|
||||
"afterResetCounts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"httpPassed": [
|
||||
"five_customer_one_appointment",
|
||||
"same_command_replay",
|
||||
"duplicate_cargo_conflict",
|
||||
"update_version",
|
||||
"stale_update_conflict",
|
||||
"stale_delete_conflict",
|
||||
"delete_and_calendar_absence",
|
||||
"rebook_after_delete",
|
||||
"two_new_customer_accounts_10_negative_isolation_checks",
|
||||
"nonempty_customer_75_rows_all_owned",
|
||||
"nonempty_customer_forged_filter_stays_scoped",
|
||||
"own_plan_detail_visible_other_plan_hidden",
|
||||
"existing_customer_platform_appointment_forbidden",
|
||||
"browser_deleted_appointment_absence_http_confirmed"
|
||||
],
|
||||
"uiPassed": [
|
||||
"1920x1080_month",
|
||||
"five_customer_detail",
|
||||
"edit_saved",
|
||||
"week_view",
|
||||
"candidate_existing_appointment_disabled",
|
||||
"two_customer_selection",
|
||||
"cross_page_selection_retained",
|
||||
"create_saved",
|
||||
"final_month_top_alignment",
|
||||
"final_week_top_alignment",
|
||||
"delete_confirmation_and_active_dispatch_rejection",
|
||||
"month_week_three_previews_233_total_title",
|
||||
"browser_successful_delete_two_to_one"
|
||||
],
|
||||
"initialCalendarRequests": [
|
||||
{
|
||||
"type": "appointments",
|
||||
"count": 1,
|
||||
"durationMs": 59
|
||||
},
|
||||
{
|
||||
"type": "arrivals",
|
||||
"count": 1,
|
||||
"durationMs": 106
|
||||
}
|
||||
],
|
||||
"pending": [],
|
||||
"screenshots": [
|
||||
"month-five-customers.png",
|
||||
"week-five-customers.png",
|
||||
"final-month.png",
|
||||
"compact-month.png",
|
||||
"compact-week.png",
|
||||
"delete-success.png"
|
||||
],
|
||||
"scope": {
|
||||
"admin": "appointment calendar and inbound appointment consumer",
|
||||
"client": "no page or code change; merchant inbound API positive and negative isolation verified",
|
||||
"remainingSample": "QA-UI-20261009-2CUSTOMERS"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 110 KiB |
@@ -1 +0,0 @@
|
||||
{"repo": "html/admin-web", "number": 18, "url": "http://8.163.30.128:10082/html/admin-web/pulls/18", "head": "73c29f5264e46f648713c58b469a8c7bd335cd9c", "branch": "release/appointment-gray-20261009"}
|
||||
@@ -1 +0,0 @@
|
||||
f9dd30b8b9c6b756c4502e4efc33df4ddb59e566
|
||||
@@ -1 +0,0 @@
|
||||
{"repo": "java-project/baiyue-firstleg", "number": 25, "url": "http://8.163.30.128:10082/java-project/baiyue-firstleg/pulls/25", "head": "7fd7699f6e83e77e487aceff914d160cf42376a0", "branch": "release/appointment-gray-20261009"}
|
||||
@@ -1 +0,0 @@
|
||||
f60f44793f289f7612b561f1c470e093f67f4e32
|
||||
|
Before Width: | Height: | Size: 105 KiB |
@@ -1,30 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "permissions",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 358,
|
||||
"elapsedMs": 115
|
||||
},
|
||||
{
|
||||
"name": "arrivals",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 10,
|
||||
"elapsedMs": 297
|
||||
},
|
||||
{
|
||||
"name": "appointments",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 0,
|
||||
"elapsedMs": 287
|
||||
},
|
||||
{
|
||||
"name": "inboundCandidates",
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"count": 10,
|
||||
"elapsedMs": 221
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"job": "baiyue-firstleg-prod-lastleg-service", "queue": "https://jenkins.buggoglobal.com/queue/item/7294/", "number": 13, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "baiyue-firstleg-prod-firstleg", "queue": "https://jenkins.buggoglobal.com/queue/item/7295/", "number": 14, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "admin-web-prod", "queue": "https://jenkins.buggoglobal.com/queue/item/7300/", "number": 26, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}]
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"scope": [
|
||||
"admin-web",
|
||||
"firstleg",
|
||||
"lastleg-service"
|
||||
],
|
||||
"databaseAuthorized": true,
|
||||
"schemaPreflight": {
|
||||
"appointmentRows": 0,
|
||||
"itemRows": 0,
|
||||
"permissionConflicts": 0,
|
||||
"adminRoleCount": 1,
|
||||
"affectedAdminSubjects": 1
|
||||
},
|
||||
"targets": {
|
||||
"backend": "7fd7699f6",
|
||||
"admin": "73c29f5"
|
||||
},
|
||||
"sourceEquivalence": {
|
||||
"backendMergeTreeMatchesTestedSource": true,
|
||||
"adminMergeTreeMatchesTestedSource": true
|
||||
},
|
||||
"sqlExecuted": true,
|
||||
"deployed": true,
|
||||
"pending": null,
|
||||
"releaseBranchesDeleted": [
|
||||
"backend",
|
||||
"admin"
|
||||
],
|
||||
"browserVerified": true,
|
||||
"grayWriteCrudRetested": false
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
-- REVIEW ONLY: gray appointment schema and platform administrator permissions.
|
||||
-- Execute only with explicit gray SQL authorization, after verifying both appointment tables remain empty.
|
||||
-- No DROP TABLE, DELETE, TRUNCATE, or business-data backfill.
|
||||
-- DDL auto-commits; stop immediately on any error. Do not use mysql --force.
|
||||
USE baiyue_lastleg;
|
||||
ALTER TABLE ll_inbound_fba_appointment
|
||||
DROP INDEX uk_ll_fba_appointment_no,
|
||||
DROP INDEX idx_ll_fba_appointment_list,
|
||||
DROP COLUMN customer_id,
|
||||
ADD COLUMN appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空' AFTER appointment_at,
|
||||
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '并发版本' AFTER appointment_end_at,
|
||||
ADD UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||
ADD KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code);
|
||||
ALTER TABLE ll_inbound_fba_appointment_item
|
||||
ADD COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID' AFTER appointment_id,
|
||||
ADD KEY idx_ll_fba_appointment_item_cargo (line_id, appointment_id);
|
||||
USE baiyue_iam;
|
||||
START TRANSACTION;
|
||||
INSERT INTO iam_permission
|
||||
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||
VALUES
|
||||
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
|
||||
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0);
|
||||
INSERT INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r CROSS JOIN iam_permission p
|
||||
WHERE r.id=4000001 AND r.tenant_id=0 AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
|
||||
AND p.id IN (5107280,5107281);
|
||||
INSERT INTO iam_security_subject_version (auth_user_id,permission_version,updated_at)
|
||||
SELECT DISTINCT u.auth_user_id,1,NOW(3)
|
||||
FROM iam_membership_role mr
|
||||
JOIN iam_membership m ON m.id=mr.membership_id AND m.tenant_id=mr.tenant_id
|
||||
JOIN iam_user u ON u.id=m.user_id
|
||||
WHERE mr.tenant_id=0 AND mr.role_id=4000001 AND m.deleted=0 AND u.deleted=0
|
||||
ON DUPLICATE KEY UPDATE permission_version=permission_version+1,updated_at=NOW(3);
|
||||
COMMIT;
|
||||
@@ -1,5 +0,0 @@
|
||||
ll_inbound_fba_appointment 13
|
||||
ll_inbound_fba_appointment_item 9
|
||||
platform:appointment:list
|
||||
platform:appointment:delete
|
||||
2
|
||||
@@ -16,12 +16,10 @@
|
||||
"@lucide/vue": "^1.27.0",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.40",
|
||||
"vue-echarts": "^8.3.2",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -20,9 +20,6 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.21
|
||||
version: 1.11.21
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
element-plus:
|
||||
specifier: ^2.14.3
|
||||
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -35,9 +32,6 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.40
|
||||
version: 3.5.40(typescript@5.9.3)
|
||||
vue-echarts:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3))
|
||||
vue-router:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||
@@ -647,9 +641,6 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
editorconfig@1.0.7:
|
||||
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1159,9 +1150,6 @@ packages:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -1337,12 +1325,6 @@ packages:
|
||||
vue-component-type-helpers@3.3.8:
|
||||
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
|
||||
|
||||
vue-echarts@8.3.2:
|
||||
resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==}
|
||||
peerDependencies:
|
||||
echarts: ^6.0.0
|
||||
vue: ^3.3.0
|
||||
|
||||
vue-router@5.2.0:
|
||||
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
|
||||
peerDependencies:
|
||||
@@ -1424,9 +1406,6 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/css-color@5.1.11':
|
||||
@@ -1981,11 +1960,6 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
editorconfig@1.0.7:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
@@ -2462,8 +2436,6 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
@@ -2591,11 +2563,6 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.8: {}
|
||||
|
||||
vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)):
|
||||
dependencies:
|
||||
echarts: 6.1.0
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0
|
||||
@@ -2691,7 +2658,3 @@ snapshots:
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -33,8 +33,6 @@ import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
|
||||
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
||||
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
|
||||
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
|
||||
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
|
||||
import { selfOperatedRoutes } from '@/modules/self-operated/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -81,8 +79,6 @@ const router = createRouter({
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
...sailingCalendarRoutes,
|
||||
...appointmentCalendarRoutes,
|
||||
...selfOperatedRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
|
||||
@@ -242,62 +242,6 @@ describe('admin auth session', () => {
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an old refresh after another tab has switched to a different account', async () => {
|
||||
localStorage.setItem('cc-admin-token', token())
|
||||
localStorage.setItem('cc-admin-refresh-token', 'admin-refresh')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||
let finishRefresh!: (value: ReturnType<typeof loginResponse>) => void
|
||||
mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve }))
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const listeners = vi.spyOn(window, 'addEventListener')
|
||||
const auth = useAuthStore()
|
||||
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
|
||||
listeners.mockRestore()
|
||||
const refreshing = auth.refreshSession()
|
||||
await Promise.resolve()
|
||||
const salesToken = token({ user_id: '2000002', username: 'sales' })
|
||||
localStorage.setItem('cc-admin-token', salesToken)
|
||||
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
|
||||
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
|
||||
expect(auth.user?.account).toBe('sales')
|
||||
finishRefresh(loginResponse(token(), 'admin-rotated'))
|
||||
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
|
||||
expect(auth.user?.account).toBe('sales')
|
||||
expect(auth.token).toBe(salesToken)
|
||||
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
|
||||
})
|
||||
|
||||
it('discards late permission results after another tab switches accounts without clearing the new session', async () => {
|
||||
mocks.post.mockResolvedValue(loginResponse())
|
||||
let finishPermissions!: (value: string[]) => void
|
||||
let permissionsStarted!: () => void
|
||||
const started = new Promise<void>(resolve => { permissionsStarted = resolve })
|
||||
mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => {
|
||||
permissionsStarted()
|
||||
return new Promise(resolve => { finishPermissions = resolve })
|
||||
})
|
||||
const listeners = vi.spyOn(window, 'addEventListener')
|
||||
const auth = useAuthStore()
|
||||
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
|
||||
listeners.mockRestore()
|
||||
const login = auth.login('platform_admin', 'password')
|
||||
await started
|
||||
const salesToken = token({ user_id: '2000002', username: 'sales' })
|
||||
localStorage.setItem('cc-admin-token', salesToken)
|
||||
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
|
||||
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
|
||||
finishPermissions(['platform:warehouse:list'])
|
||||
await expect(login).rejects.toThrow('登录会话已被后续操作替代')
|
||||
expect(auth.user?.account).toBe('sales')
|
||||
expect(auth.token).toBe(salesToken)
|
||||
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
|
||||
expect(mocks.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => {
|
||||
const previous = token()
|
||||
const rotated = token({ token_version: 2 })
|
||||
|
||||
@@ -144,10 +144,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
try {
|
||||
const session = sessionFromToken(storedToken)
|
||||
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
||||
// A different account in another tab supersedes this tab's pending login/refresh responses.
|
||||
if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) {
|
||||
sessionGeneration += 1
|
||||
}
|
||||
token.value = storedToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
user.value = session.user
|
||||
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
} from './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', () => {
|
||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
|
||||
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
|
||||
@@ -30,17 +22,15 @@ describe('adminNavigation', () => {
|
||||
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车接货', '出车记录'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '拼车需求池', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
|
||||
expect(visible).toHaveLength(11)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(51)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -82,7 +72,7 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/system/permissions')
|
||||
expect(new Set(paths).size).toBe(paths.length)
|
||||
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list']))
|
||||
expect(new Set(duplicatedPermissions)).toEqual(new Set())
|
||||
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -118,14 +118,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'appointment-management',
|
||||
title: '预约管理',
|
||||
icon: CalendarDays,
|
||||
children: [
|
||||
{ key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'vehicle-management',
|
||||
title: '车辆管理',
|
||||
@@ -135,7 +127,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车接货', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-capacity-offers', title: '拼车需求池', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
},
|
||||
@@ -181,17 +173,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'self-operated',
|
||||
title: '海外仓',
|
||||
icon: PrototypeSalesIcon,
|
||||
children: [
|
||||
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' },
|
||||
{ key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'general',
|
||||
title: '通用',
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
|
||||
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, {
|
||||
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 },
|
||||
] },
|
||||
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 () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="2026-09-25,0 项安排"]').trigger('click')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无预约')
|
||||
})
|
||||
|
||||
it('shows an overnight slot on both days without fetching again and excludes midnight ends', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
{ key: 'overnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T01:00:00', time: '23:00–01:00', title: '跨日送仓', fields: [] },
|
||||
{ key: 'midnight', type: 'delivery', date: '2026-09-23', endAt: '2026-09-24T00:00:00', time: '23:00–00:00', title: '午夜结束', fields: [] },
|
||||
] })
|
||||
expect(wrapper.find('[aria-label="2026-09-23,2 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="2026-09-24,1 项安排"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('跨日送仓')
|
||||
expect(wrapper.get('[aria-label="当天安排"]').text()).not.toContain('午夜结束')
|
||||
expect(wrapper.emitted('range')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requests exactly one range for a view switch and navigates seven days', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-09-21', to: '2026-09-28' }]])
|
||||
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-09-28', to: '2026-10-05' }])
|
||||
})
|
||||
|
||||
it('keeps busy days to three previews in both views and exposes the full count', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: Array.from({ length: 233 }, (_, index) => ({
|
||||
key: `arrival-${index}`, type: 'arrival' as const, date: '2026-09-24', time: '09:00',
|
||||
title: `船舶 ${index}`, fields: [],
|
||||
})) })
|
||||
await wrapper.get('[aria-label="参考图层"] input').setValue(true)
|
||||
for (const view of ['month', 'week']) {
|
||||
if (view === 'week') await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||
const day = wrapper.get('[aria-label="2026-09-24,233 项安排"]')
|
||||
expect(day.findAll('.event-chip')).toHaveLength(3)
|
||||
expect(day.attributes('title')).toContain('共 233 条安排')
|
||||
expect(day.get('.event-more').text()).toBe('还有 230 条')
|
||||
expect(wrapper.findAll('.detail-event')).toHaveLength(233)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the selected day for creation and emits edit/delete only from detail actions', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.findAll('button').find(button => button.text() === '+ 新增预约')!.trigger('click')
|
||||
expect(wrapper.emitted('create')).toEqual([['2026-09-24']])
|
||||
await wrapper.findAll('button').find(button => button.text() === '编辑')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '取消预约')!.trigger('click')
|
||||
expect(wrapper.emitted('edit')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
expect(wrapper.emitted('delete')?.[0]?.[0]).toMatchObject({ key: 'a' })
|
||||
})
|
||||
|
||||
it('renders many inbound codes outside the two-column fields with only three previews', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [{
|
||||
key: 'many', type: 'delivery', date: '2026-09-24', time: '09:00', title: 'ONT8 预约送仓',
|
||||
fields: [{ label: '货物信息', value: '20 票 / 12 个客户' }],
|
||||
inboundCodes: Array.from({ length: 20 }, (_, index) => `WT-INBOUND-${index}`), canEdit: true,
|
||||
}] })
|
||||
const card = wrapper.get('.detail-event')
|
||||
expect(card.get('dl').text()).not.toContain('WT-INBOUND')
|
||||
expect(card.findAll('.inbound-codes li')).toHaveLength(3)
|
||||
expect(card.get('.inbound-codes').text()).toContain('20 个')
|
||||
expect(card.get('footer').text()).toContain('编辑')
|
||||
})
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import { addDays, calendarDates, calendarRange, dateKey, moveCalendar, occursOnDate, type CalendarMode } from './calendar'
|
||||
|
||||
export interface CalendarEvent {
|
||||
key: string
|
||||
date: string
|
||||
endAt?: string
|
||||
time: string
|
||||
type: 'arrival' | 'delivery' | 'pickup' | 'trip'
|
||||
title: string
|
||||
subtitle?: string
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
inboundCodes?: Array<string | null | undefined>
|
||||
canEdit?: boolean
|
||||
canView?: boolean
|
||||
canDelete?: boolean
|
||||
sourcePath?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
notices?: string[]
|
||||
events: CalendarEvent[]
|
||||
loading?: boolean
|
||||
incomplete?: boolean
|
||||
canCreate?: boolean
|
||||
initialDate?: string
|
||||
}>(), { notices: () => [], loading: false, incomplete: false, canCreate: false, initialDate: '' })
|
||||
const emit = defineEmits<{
|
||||
layers: [layers: { arrivals: boolean; trips: boolean }]
|
||||
range: [range: { from: string; to: string }]
|
||||
create: [date: string]
|
||||
edit: [event: CalendarEvent]
|
||||
view: [event: CalendarEvent]
|
||||
delete: [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 mode = ref<CalendarMode>('month')
|
||||
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||
const grouped = computed(() => {
|
||||
const result = new Map<string, CalendarEvent[]>()
|
||||
for (const event of visibleEvents.value) {
|
||||
for (const date of dates.value) {
|
||||
if (!occursOnDate(event.date, event.endAt, date)) continue
|
||||
const group = result.get(date) || []
|
||||
group.push(event)
|
||||
result.set(date, group)
|
||||
}
|
||||
}
|
||||
for (const group of result.values()) group.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||
return result
|
||||
})
|
||||
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 eventTypeLabels = { arrival: '船舶到港', delivery: '预约送仓', pickup: '预约提柜', trip: '用车计划' }
|
||||
const heading = computed(() => mode.value === 'month'
|
||||
? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月`
|
||||
: `${dates.value[0]} — ${addDays(calendarRange(selectedDate.value, mode.value).to, -1)}`)
|
||||
function select(date: string) {
|
||||
const before = calendarRange(selectedDate.value, mode.value)
|
||||
selectedDate.value = date
|
||||
const after = calendarRange(date, mode.value)
|
||||
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||
}
|
||||
function changeMode(next: CalendarMode) {
|
||||
if (mode.value === next) return
|
||||
mode.value = next
|
||||
emit('range', calendarRange(selectedDate.value, next))
|
||||
}
|
||||
defineExpose({ selectedDate, mode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="appointment-calendar admin-density" :aria-busy="loading">
|
||||
<div class="calendar-main">
|
||||
<header class="calendar-toolbar">
|
||||
<div class="calendar-label">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||
<strong>预约日历</strong>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
|
||||
</div>
|
||||
<div class="calendar-controls">
|
||||
<div class="view-toggle" role="group" aria-label="预约视图">
|
||||
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
|
||||
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
|
||||
</div>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||
<strong class="calendar-period">{{ heading }}</strong>
|
||||
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||
</div>
|
||||
<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>
|
||||
<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 v-loading="loading" class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||
<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) }"
|
||||
: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-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 ? '(当前范围不完整)' : ''}`">
|
||||
{{ event.title }}<small v-if="mode === 'week'">{{ event.time }}</small>
|
||||
</span>
|
||||
<span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail" aria-label="当天安排">
|
||||
<header><strong>{{ 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">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '当前范围内暂无预约;请按上方提示核对缺失资料或失败来源' : '当天暂无预约' }}</p>
|
||||
<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>
|
||||
<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" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
|
||||
<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.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>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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-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-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 > 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::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; background: #4b78e8; }
|
||||
.legend.delivery::before { background: #27a87a; }
|
||||
.legend.pickup::before { background: #c17a20; }
|
||||
.legend.trip::before { background: #8562b0; }
|
||||
.view-toggle { display: flex; border: 1px solid #dbe3ee; border-radius: 6px; padding: 2px; background: #f7f9fc; }
|
||||
.view-toggle button, .nav-button { border: 0; border-radius: 5px; background: transparent; cursor: pointer; color: #64748b; min-width: 30px; height: 30px; }
|
||||
.view-toggle button[aria-pressed='true'] { background: #fff; color: #4167d8; font-weight: 700; }
|
||||
.nav-button { border: 1px solid #dbe3ee; font-size: 22px; }
|
||||
.calendar-period { font-size: 14px; white-space: nowrap; }
|
||||
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.weekdays { text-align: center; color: #8491a5; background: #f8faff; padding: var(--admin-space-3) var(--admin-space-5); font-size: var(--admin-font-caption); }
|
||||
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: #fbfcff; }
|
||||
.calendar-day { display: flex; flex-direction: column; align-items: stretch; 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:hover { border-color: #bac8ed; background: #f8faff; }
|
||||
.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; }
|
||||
.day-number { display: block; font-size: 12px; font-weight: 600; }
|
||||
.day-events { display: flex; flex-direction: column; gap: 2px; margin-top: var(--admin-space-1); }
|
||||
.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; }
|
||||
.delivery { --event-bg: #e8f8f1; --event-fg: #18845f; }
|
||||
.pickup { --event-bg: #fff4e3; --event-fg: #995a10; }
|
||||
.trip { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }
|
||||
.event-chip small { display: block; margin-top: 2px; font-size: 11px; opacity: .8; }
|
||||
.event-more { padding: 1px 6px; line-height: 16px; }
|
||||
.event-more, .empty-day { color: #667085; font-size: 12px; }
|
||||
.week-grid .calendar-day { height: 220px; }
|
||||
.calendar-detail { align-self: start; }
|
||||
.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
|
||||
.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||
.calendar-detail header p { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-caption); color: #8491a5; }
|
||||
.calendar-detail header b { color: #617ce9; font-size: var(--admin-font-title); }
|
||||
.detail-events { padding: var(--admin-space-5); max-height: 660px; overflow-y: auto; }
|
||||
.detail-event + .detail-event { border-top: 1px solid #e4e9f2; margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
|
||||
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 12px; }
|
||||
.event-type { padding: 5px 8px; border-radius: 4px; font-weight: 600; }
|
||||
.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; }
|
||||
.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 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; }
|
||||
</style>
|
||||
@@ -1,408 +0,0 @@
|
||||
import { inject, onBeforeUnmount } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
|
||||
import { listArrivalCalendar, listPickupCalendar } from './api'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
|
||||
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
|
||||
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() }))
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: 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('@/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')>()),
|
||||
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
|
||||
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
|
||||
overseasWarehousePermissions: { inboundOrderAppointment: 'write', lastMileOrderList: 'source-read' },
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
|
||||
const candidates = [
|
||||
{ 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' },
|
||||
]
|
||||
function render(openCalendar = true) {
|
||||
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>' },
|
||||
AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() {
|
||||
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>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElDatePicker: { props: ['modelValue'], template: '<input data-date :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
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>' },
|
||||
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
|
||||
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>' },
|
||||
} } })
|
||||
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('appointment calendar data integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
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(listFbaAppointmentCalendar).mockResolvedValue([])
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({records:[],unresolvedRecords:[]})
|
||||
vi.mocked(listPickupCalendar).mockResolvedValue({records: [], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||||
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 () => {
|
||||
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 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('incomplete')).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('目的仓国家待核实')
|
||||
expect(listPickupCalendar).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('does not request pickup records without pickup list permission and discloses the omitted source', async () => {
|
||||
routing.canPickup=false
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(listPickupCalendar).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentCalendarPanel' }).props('notices').join(' ')).toContain('无提柜查看权限')
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('incomplete')).toBe(true)
|
||||
})
|
||||
it('keeps successful arrival data visible when the pickup source fails', async () => {
|
||||
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',cabinetNo:'D0001',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
|
||||
const wrapper=render(); await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
})
|
||||
it('omits trip requests without trip list permission and keeps other sources usable', async () => {
|
||||
routing.canTrips=false
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled();expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
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 () => {
|
||||
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()
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||||
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)
|
||||
})
|
||||
it('creates one shared appointment for two customers without sending a fabricated header customer', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listArrivalCalendar).toHaveBeenCalledTimes(1)
|
||||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(createFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
expect(createFbaAppointment).toHaveBeenCalledWith(expect.objectContaining({
|
||||
originWarehouseId: '100', fbaWarehouseCode: 'ONT8', lineIds: ['9007199254740993', '9007199254740994'], idempotencyKey: expect.any(String), details: expect.objectContaining({plannedDate: '2026-10-09', appointmentStatus: 'PENDING', appointmentAt: null, confirmationNo: null}),
|
||||
}))
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[0]![0]).not.toHaveProperty('customerId')
|
||||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||||
expect(listFbaAppointmentCalendar).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('only searches active FBA warehouses and loads cargo automatically after selection', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
|
||||
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('查询可预约货物')
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
|
||||
})
|
||||
it('keeps a pending unknown save retry on the same immutable command', async () => {
|
||||
vi.mocked(createFbaAppointment).mockRejectedValueOnce(new Error('response lost'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click'); await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8'); await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
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()
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
|
||||
expect(first.details.appointmentAt).toBeNull()
|
||||
expect(first.details.confirmationNo).toBeNull()
|
||||
expect(first.idempotencyKey).toBeTruthy()
|
||||
})
|
||||
it('keeps undated arrangements outside calendar time slots', async () => {
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValueOnce({ records: [{ id:'1', version:0, appointmentNo:'AP-UNSCHEDULED', originWarehouseId:'100', appointmentStatus:'PENDING', selectedCount:2, fbaWarehouseCode:'ONT8', items:[] }], total:1 })
|
||||
const wrapper=render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('未定时安排(1)')
|
||||
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')
|
||||
})
|
||||
|
||||
it('changing or clearing the warehouse clears prior cargo selection and blocks saving it', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
expect(wrapper.find('input[placeholder="填写仓库提供的预约编号"]').exists()).toBe(false)
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('')
|
||||
await wrapper.get('[data-save]').trigger('click')
|
||||
expect(createFbaAppointment).not.toHaveBeenCalled()
|
||||
expect(listInboundOrders).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('[data-select]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('discards cargo responses from the previously selected warehouse', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof listInboundOrders>>) => void
|
||||
vi.mocked(listInboundOrders).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
|
||||
vi.mocked(listInboundOrders).mockResolvedValueOnce({ records: [], total: 0, page: 1, size: 10, pages: 0 })
|
||||
await wrapper.get('select[aria-label="预约仓库"]').setValue('LAX9')
|
||||
await flushPromises()
|
||||
resolveOld({ records: candidates, total: 2, page: 1, size: 10, pages: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
|
||||
})
|
||||
|
||||
it('searches by keyword and exposes warehouse failures without accepting free text', async () => {
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
await flushPromises()
|
||||
const select = wrapper.findAllComponents({ name: 'ElSelect' }).find(item => item.props('remoteMethod'))!
|
||||
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('仓库暂不可用'))
|
||||
await select.props('remoteMethod')(' LAX ')
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' }))
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('仓库加载失败')
|
||||
expect(wrapper.findAll('select[aria-label="预约仓库"] option')).toHaveLength(1)
|
||||
expect(listInboundOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
|
||||
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
|
||||
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0001', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('incomplete')).toBe(true)
|
||||
expect(calendar.props('events')).toHaveLength(1)
|
||||
expect(calendar.props('events')[0].type).toBe('arrival')
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('送仓预约加载失败')
|
||||
})
|
||||
|
||||
it('keeps delivery appointments when the arrival source fails', async () => {
|
||||
vi.mocked(listArrivalCalendar).mockRejectedValueOnce(new Error('到港暂不可用'))
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValueOnce([{ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }])
|
||||
const wrapper = render()
|
||||
await flushPromises()
|
||||
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
|
||||
expect(calendar.props('incomplete')).toBe(true)
|
||||
expect(calendar.props('events')[0].key).toBe('APT-AB')
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('船舶到港加载失败')
|
||||
})
|
||||
|
||||
it('resolves an external booking deep link through the backend before opening followup', async () => {
|
||||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValueOnce({ source: { sourceKey: 'BOOKING:B-1', customerId: 'A', orderId: null, inboundCode: null, bookingNo: 'B-1', containerNo: null, originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: 'BOOKING:1', canFollowup: true, blockReasons: [] }, coverage: { status: 'SOURCE_NOT_MATERIALIZED', appointments: [], missingFields: ['REAL_CARGO_MATERIALS'], changeReviewRequired: false } })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningSource).toHaveBeenCalledWith(routing.query)
|
||||
const dialog = wrapper.findComponent({ name: 'AppointmentFollowupDialog' })
|
||||
expect(dialog.props('source').bookingNo).toBe('B-1')
|
||||
expect(dialog.props('source').orderId).toBeNull()
|
||||
expect(dialog.props('coverage').status).toBe('SOURCE_NOT_MATERIALIZED')
|
||||
expect(createFbaAppointment).not.toHaveBeenCalled(); expect(saveFollowup).not.toHaveBeenCalled()
|
||||
expect(dialog.props('canArrange')).toBe(true)
|
||||
dialog.vm.$emit('arrange');await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentFollowupDialog'}).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({name:'AppointmentCargoPanel'}).props('externalSource')).toMatchObject({sourceKey:'BOOKING:B-1',bookingNo:'B-1',orderId:null})
|
||||
|
||||
})
|
||||
|
||||
it('does not trust stale destination query data or open followup after a source error', async () => {
|
||||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:OLD' }
|
||||
vi.mocked(resolvePlanningSource).mockRejectedValueOnce(new Error('目的地已变化'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('title')).toBe('预约货源核对失败')
|
||||
})
|
||||
|
||||
it('does not request deep-link cargo without the underlying source permission', async () => {
|
||||
routing.query = { sourceKey: 'BOOKING:B-1', customerId: 'A', destinationKey: 'FBA:ONT8' }; routing.canSourceRead = false
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'AppointmentFollowupDialog' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,485 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import {
|
||||
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
|
||||
overseasWarehousePermissions as permission,
|
||||
FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError,
|
||||
type FbaAppointmentDto, type FbaAppointmentDetailsPayload, type InboundOrderSummaryDto,
|
||||
} from '@/modules/overseas-warehouse'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.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 AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import { calendarRange, dateKey } from './calendar'
|
||||
import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
const linkedSource = ref<ResolvedPlanningSource | null>(null)
|
||||
const selectedSource = ref<PlanningSource | null>(null)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkLoading = ref(false)
|
||||
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 calendarVisited = ref(true)
|
||||
const showTripLayer = ref(false)
|
||||
const cargoVisited = ref(false)
|
||||
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 arrivals = ref<ArrivalCalendarEntry[]>([])
|
||||
const pickups = ref<PickupCalendarEntry[]>([])
|
||||
const pickupNotice = ref('')
|
||||
const tripEvents = ref<CalendarEvent[]>([])
|
||||
const unresolvedTrips = ref<TripCalendarEntry[]>([])
|
||||
const tripNotice = ref('')
|
||||
const unresolvedDialogOpen = ref(false)
|
||||
const unresolvedPage = ref(1), unresolvedSize = ref(10)
|
||||
const unresolvedRows = computed(() => unresolvedTrips.value.slice((unresolvedPage.value - 1) * unresolvedSize.value, unresolvedPage.value * unresolvedSize.value))
|
||||
const unresolvedColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' },
|
||||
{ prop: 'tripNo', label: '车次号', minWidth: 210 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 170 },
|
||||
{ prop: 'plannedDepartureAt', label: '原计划出发(UTC)', minWidth: 225 },
|
||||
{ key: 'issues', label: '待核实资料', slotName: 'issues', minWidth: 230 },
|
||||
]
|
||||
const range = ref(calendarRange(dateKey(new Date()), 'month'))
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
|
||||
const errorTitle = ref('预约日历加载失败')
|
||||
const dialogError = ref<ApiErrorDetails | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailReady = ref(false)
|
||||
const editing = ref<FbaAppointmentDto | null>(null)
|
||||
const warehouse = ref('')
|
||||
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseKeyword = ref('')
|
||||
const warehouseTotal = ref(0)
|
||||
let warehouseRequest = 0
|
||||
const candidates = ref<InboundOrderSummaryDto[]>([])
|
||||
const selected = ref<InboundOrderSummaryDto[]>([])
|
||||
const candidateLoading = ref(false)
|
||||
const candidateError = ref<ApiErrorDetails | null>(null)
|
||||
const candidatePage = ref(1)
|
||||
const candidateSize = ref(10)
|
||||
const candidateTotal = ref(0)
|
||||
const searchedWarehouse = ref('')
|
||||
const selectionEpoch = ref(0)
|
||||
let calendarController: AbortController | undefined
|
||||
let calendarRequest = 0
|
||||
let candidateRequest = 0
|
||||
let detailRequest = 0
|
||||
const form = reactive(createAppointmentForm())
|
||||
const pendingSave = ref<{ appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: FbaAppointmentDetailsPayload; idempotencyKey: string } | null>(null)
|
||||
const unscheduled = ref<FbaAppointmentDto[]>([]), unscheduledTotal = ref(0), unscheduledPage = ref(1), unscheduledSize = ref(10)
|
||||
const unscheduledError = ref<ApiErrorDetails | null>(null)
|
||||
const unscheduledColumns: DataTableColumn[] = [{key:'actions',label:'操作',slotName:'actions',width:100,fixed:'left'},{prop:'appointmentNo',label:'系统预约单号',minWidth:200},{prop:'fbaWarehouseCode',label:'目的仓',width:100},{prop:'selectedCount',label:'票数',width:80}]
|
||||
let unscheduledRequest = 0
|
||||
async function loadUnscheduled() {
|
||||
const current = ++unscheduledRequest; unscheduledError.value = null
|
||||
try { const result = await listUnscheduledAppointments(unscheduledPage.value, unscheduledSize.value); if(current === unscheduledRequest) {unscheduled.value = result.records; unscheduledTotal.value = result.total} }
|
||||
catch(cause) {if(current === unscheduledRequest) unscheduledError.value = getApiErrorDetails(cause, '未定时安排加载失败')}
|
||||
}
|
||||
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
|
||||
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)) },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 220, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 180, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
|
||||
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'packageCount', label: '箱数', width: 80 },
|
||||
{ prop: 'palletCount', label: '托数', width: 80 },
|
||||
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160, singleLine: false, showOverflowTooltip: false },
|
||||
]
|
||||
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
|
||||
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
|
||||
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
|
||||
title: `${arrival.voyageNo || arrival.cabinetNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
|
||||
fields: [
|
||||
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
|
||||
{ label: '目的港', value: arrival.destinationPortName },
|
||||
{ label: '已装货柜号', value: arrival.containerNo },
|
||||
{ label: '柜次号', value: arrival.cabinetNo },
|
||||
{ label: '货物去向', value: arrival.destinationWarehouseCode },
|
||||
],
|
||||
})), ...pickups.value.map(pickup => ({
|
||||
key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const,
|
||||
date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16),
|
||||
title: `${pickup.containerNo || pickup.cabinetNo || '未关联柜号'} 预约提柜`,
|
||||
sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`,
|
||||
subtitle: '美国目的仓相关提柜安排',
|
||||
fields: [
|
||||
{ label: '柜次号', value: pickup.cabinetNo }, { label: '目的港', value: pickup.destinationPortName },
|
||||
{ label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName },
|
||||
{ label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' },
|
||||
{ label: '时间口径', value: '原提柜业务时间,港口时区待核实' },
|
||||
],
|
||||
})), ...appointments.value.map(appointment => ({
|
||||
key: appointment.appointmentNo, type: 'delivery' as const, date: (appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '').slice(0, 10),
|
||||
endAt: (appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) || undefined,
|
||||
time: (appointment.appointmentAt || appointment.plannedAt || '').slice(11, 16) + ((appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) ? `–${(appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt)!.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} ${appointment.appointmentStatus === 'SUCCESS' ? '已确认送仓' : '计划送仓'}`,
|
||||
fields: [
|
||||
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
|
||||
{ label: '系统单号', value: appointment.appointmentNo },
|
||||
{ label: '外部约号', value: appointment.confirmationNo },
|
||||
{ label: '地点时区', value: appointment.timezone || '待核实' },
|
||||
{ label: '完整时段', value: `${appointment.appointmentAt || appointment.plannedAt || appointment.plannedDate || '待定'}${(appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt) ? ` — ${appointment.appointmentAt ? appointment.appointmentEndAt : appointment.plannedEndAt}` : ''}` },
|
||||
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
|
||||
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
|
||||
{ label: '预约备注', value: appointment.appointmentRemark },
|
||||
].filter(field => field.value != null && field.value !== ''),
|
||||
inboundCodes: appointment.items.map(item => item.inboundCode),
|
||||
canEdit: access.can(permission.inboundOrderAppointment),
|
||||
canView: access.can('platform:appointment:list'),
|
||||
canDelete: access.can('platform:appointment:delete'),
|
||||
})), ...tripEvents.value])
|
||||
|
||||
async function load(nextRange = range.value) {
|
||||
unresolvedDialogOpen.value = false
|
||||
range.value = nextRange
|
||||
const request = ++calendarRequest
|
||||
calendarController?.abort()
|
||||
calendarController = new AbortController()
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
sourceErrors.value = []
|
||||
errorTitle.value = '预约日历加载失败'
|
||||
appointments.value = []
|
||||
arrivals.value = []
|
||||
pickups.value = []
|
||||
tripEvents.value = []; unresolvedTrips.value = []; unresolvedPage.value = 1
|
||||
const canReadPickup = access.can('platform:firstleg:terminal-drayage:list')
|
||||
const canReadTrips = access.can('platform:fleet:trip:list')
|
||||
pickupNotice.value = canReadPickup ? '' : '无提柜查看权限,当前日历不包含提柜预约。'
|
||||
tripNotice.value = canReadTrips ? '' : '无车次查看权限,当前日历不包含用车计划。'
|
||||
try {
|
||||
const [result, arrivalResult, pickupResult, tripResult] = await Promise.allSettled([
|
||||
listFbaAppointmentCalendar(nextRange, calendarController.signal),
|
||||
listArrivalCalendar(nextRange, calendarController.signal),
|
||||
canReadPickup ? listPickupCalendar(nextRange, calendarController.signal) : Promise.resolve(null),
|
||||
canReadTrips ? listTripCalendar(nextRange, calendarController.signal) : Promise.resolve(null),
|
||||
])
|
||||
if (request !== calendarRequest) return
|
||||
if (result.status === 'fulfilled') appointments.value = result.value
|
||||
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
|
||||
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
|
||||
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
|
||||
if (pickupResult.status === 'fulfilled' && pickupResult.value) {
|
||||
pickups.value = pickupResult.value.records
|
||||
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, '提柜预约加载失败') })
|
||||
if (tripResult.status === 'fulfilled' && tripResult.value) {
|
||||
const projection = projectTripCalendar(tripResult.value, nextRange)
|
||||
tripEvents.value = projection.events; unresolvedTrips.value = projection.unresolved
|
||||
if (projection.unresolved.length) tripNotice.value = '部分车次无法确定起运地点或当地日期,已单列待核实,未计入每日安排。'
|
||||
} else if (tripResult.status === 'rejected') sourceErrors.value.push({ title: '用车计划加载失败', details: getApiErrorDetails(tripResult.reason, '用车计划加载失败') })
|
||||
} catch (error) {
|
||||
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
|
||||
} finally { if (request === calendarRequest) loading.value = false }
|
||||
}
|
||||
|
||||
function openSource(event: CalendarEvent) {
|
||||
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) {
|
||||
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
|
||||
forceReadOnly.value = false; cancellationHistoryOpen.value = false
|
||||
++candidateRequest
|
||||
++detailRequest
|
||||
detailLoading.value = false
|
||||
candidateLoading.value = false
|
||||
candidatePage.value = 1
|
||||
editing.value = null
|
||||
warehouse.value = ''
|
||||
searchedWarehouse.value = ''
|
||||
candidates.value = []
|
||||
selected.value = []
|
||||
selectionEpoch.value++
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = null
|
||||
dialogError.value = null
|
||||
Object.assign(form, createAppointmentForm(), { plannedDate: date }); pendingSave.value = null
|
||||
dialogOpen.value = true
|
||||
detailReady.value = true
|
||||
void searchWarehouses('')
|
||||
}
|
||||
|
||||
async function openEdit(event: Pick<CalendarEvent, 'key'>, readOnly = false) {
|
||||
if (!canLeave()) return
|
||||
forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false
|
||||
const request = ++detailRequest
|
||||
dialogError.value = null
|
||||
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
|
||||
pendingSave.value = null
|
||||
dialogOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailReady.value = false
|
||||
try {
|
||||
const detail = await getFbaAppointment(event.key)
|
||||
if (request !== detailRequest || !dialogOpen.value) return
|
||||
editing.value = detail
|
||||
detailReady.value = true
|
||||
warehouse.value = detail.fbaWarehouseCode
|
||||
Object.assign(form, createAppointmentForm(detail))
|
||||
} catch (error) {
|
||||
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
|
||||
} finally { if (request === detailRequest) detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function searchWarehouses(keyword: string) {
|
||||
warehouseKeyword.value = keyword
|
||||
const request = ++warehouseRequest
|
||||
warehouseLoading.value = true
|
||||
warehouseError.value = null
|
||||
warehouseOptions.value = []
|
||||
warehouseTotal.value = 0
|
||||
try {
|
||||
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
|
||||
if (request !== warehouseRequest || !dialogOpen.value) return
|
||||
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
|
||||
warehouseTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
|
||||
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
|
||||
}
|
||||
|
||||
function changeWarehouse(value: string) {
|
||||
++candidateRequest
|
||||
candidateLoading.value = false
|
||||
candidates.value = []
|
||||
selected.value = []
|
||||
candidateTotal.value = 0
|
||||
candidateError.value = null
|
||||
candidatePage.value = 1
|
||||
selectionEpoch.value++
|
||||
searchedWarehouse.value = value
|
||||
if (value) void loadCandidates()
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
if (!searchedWarehouse.value) return
|
||||
candidateLoading.value = true
|
||||
candidateError.value = null
|
||||
const request = ++candidateRequest
|
||||
try {
|
||||
const result = await listInboundOrders({ page: candidatePage.value, size: candidateSize.value, fbaWarehouseCode: searchedWarehouse.value })
|
||||
if (request !== candidateRequest || !dialogOpen.value) return
|
||||
candidates.value = result.records
|
||||
candidateTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request === candidateRequest) {
|
||||
candidates.value = []
|
||||
candidateError.value = getApiErrorDetails(error, '可预约货物加载失败')
|
||||
}
|
||||
} finally { if (request === candidateRequest) candidateLoading.value = false }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return
|
||||
const validation = appointmentFormError(form)
|
||||
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
|
||||
if (!editing.value) {
|
||||
if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return }
|
||||
if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return }
|
||||
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
|
||||
if (selected.value.some(item => !item.overseasWarehouseId) || new Set(selected.value.map(item => item.overseasWarehouseId)).size !== 1) { ElMessage.warning('请选择同一接仓点的货物'); return }
|
||||
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
|
||||
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
dialogError.value = null
|
||||
try {
|
||||
pendingSave.value ||= {appointmentNo: editing.value?.appointmentNo || null, version: editing.value?.version || 0, originWarehouseId: String(selected.value[0]?.overseasWarehouseId || ''), fbaWarehouseCode: searchedWarehouse.value, lineIds: selected.value.map(item => item.lineId), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID()}
|
||||
const payload = pendingSave.value
|
||||
if (payload.appointmentNo) await updateFbaAppointment(payload.appointmentNo, { details: payload.details, version: payload.version, idempotencyKey: payload.idempotencyKey })
|
||||
else await createFbaAppointment({ originWarehouseId: payload.originWarehouseId, fbaWarehouseCode: payload.fbaWarehouseCode, details: payload.details, idempotencyKey: payload.idempotencyKey, lineIds: payload.lineIds })
|
||||
pendingSave.value = null
|
||||
dialogOpen.value = false
|
||||
ElMessage.success('预约已保存')
|
||||
await Promise.all([load(), loadUnscheduled()])
|
||||
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(dialogError.value.code || '')) pendingSave.value = null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
function selectCandidates(rows: object[]) {
|
||||
selected.value = rows as InboundOrderSummaryDto[]
|
||||
}
|
||||
|
||||
function remove(event: CalendarEvent) {
|
||||
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
|
||||
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
||||
}
|
||||
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 => {
|
||||
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
|
||||
})
|
||||
|
||||
async function resolveLink() {
|
||||
const request = ++linkRequest
|
||||
linkedSource.value = null; linkError.value = null; linkLoading.value = false
|
||||
const { sourceKey, customerId, destinationKey } = route.query
|
||||
if (!sourceKey && !customerId && !destinationKey) return
|
||||
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
|
||||
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
|
||||
}
|
||||
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
|
||||
linkLoading.value = true
|
||||
try {
|
||||
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
|
||||
if (request === linkRequest) linkedSource.value = result
|
||||
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
|
||||
finally { if (request === linkRequest) linkLoading.value = false }
|
||||
}
|
||||
function arrangeLinkedSource() {
|
||||
if (!linkedSource.value || !access.can(permission.inboundOrderAppointment) || !linkedSource.value.source.canFollowup || linkedSource.value.coverage.appointments.length) return
|
||||
selectedSource.value = linkedSource.value.source
|
||||
linkedSource.value = null
|
||||
workView.value = 'cargo'
|
||||
}
|
||||
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
|
||||
|
||||
onMounted(() => { void load(); void loadUnscheduled() })
|
||||
onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<p v-if="linkLoading">正在核对预约货源…</p>
|
||||
<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" />
|
||||
<div class="appointment-heading">
|
||||
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
|
||||
<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="switchWorkView('containers')">提柜与到仓</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 v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
|
||||
<AppointmentContainerPanel v-if="workView === 'containers'" />
|
||||
<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-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" />
|
||||
<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()">
|
||||
<template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="cancellationsOpen" v-model="cancellationsOpen" title="取消记录" width="min(1160px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
|
||||
<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">
|
||||
<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>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppointmentCancellationDialog v-if="cancellationTarget" :appointment-no="cancellationTarget.appointmentNo" :version="cancellationTarget.version" :selected-count="cancellationTarget.selectedCount" @close="cancellationTarget = null" @cancelled="cancelled" />
|
||||
<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">
|
||||
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||||
<div v-loading="detailLoading">
|
||||
<el-form label-position="top" class="calendar-form density-form">
|
||||
<el-form-item label="预约仓库" required>
|
||||
<el-input v-if="editing" :model-value="warehouse" readonly />
|
||||
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
|
||||
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
|
||||
<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="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">
|
||||
<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 #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
|
||||
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
|
||||
<div v-else class="appointment-cargo">
|
||||
<span>{{ editing.selectedCount }} 票货物</span>
|
||||
<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>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<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 :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-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
|
||||
</style>
|
||||
@@ -1,53 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
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}})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
<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>
|
||||
@@ -1,32 +0,0 @@
|
||||
<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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
@@ -1,67 +0,0 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import { listPlanningCargo } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
function render(attach = false) {
|
||||
return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: {
|
||||
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '<section class="integrated-query-group"><slot name="query-tabs"/></section>' },
|
||||
} } })
|
||||
}
|
||||
describe('appointment planning cargo query', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
|
||||
it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => {
|
||||
const wrapper = render(true); await flushPromises()
|
||||
const tabs = () => wrapper.findAll('[role="tab"]')
|
||||
expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物'])
|
||||
expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true)
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
await tabs()[1]!.trigger('click')
|
||||
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true)
|
||||
await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(tabs()[0]!.element)
|
||||
expect(tabs()[1]!.attributes('tabindex')).toBe('-1')
|
||||
await tabs()[0]!.trigger('keydown', { key: 'End' })
|
||||
await flushPromises()
|
||||
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||
expect(document.activeElement).toBe(tabs()[1]!.element)
|
||||
await tabs()[1]!.trigger('keydown', { key: 'Home' })
|
||||
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses one server-page request per entry, page change and reset', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
await wrapper.get('[data-search]').trigger('click'); await flushPromises()
|
||||
expect(listPlanningCargo).toHaveBeenCalledTimes(3)
|
||||
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||
})
|
||||
it('shows a source failure instead of an authoritative empty list', async () => {
|
||||
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||
})
|
||||
it('discards a late previous page and uses the new server total', async () => {
|
||||
let resolveOld!: (value: typeof empty) => void
|
||||
vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const wrapper = render()
|
||||
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
|
||||
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
|
||||
resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
|
||||
expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
|
||||
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const props = defineProps<{ externalSource?: PlanningSource | null }>()
|
||||
const emit = defineEmits<{ created: []; sourceAccepted: [] }>()
|
||||
const arrangement = reactive(createAppointmentForm())
|
||||
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
|
||||
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
|
||||
const following = ref<ResolvedPlanningSource | null>(null)
|
||||
const candidateMode = ref<'firstleg' | 'external'>('firstleg')
|
||||
const externalRevision = ref(0)
|
||||
const followupError = ref<ApiErrorDetails | null>(null)
|
||||
type Selection = Pick<PlanningSource, 'sourceKey' | 'customerId' | 'originWarehouseId' | 'destinationWarehouseCode' | 'inboundCode'> & { bookingNo?: string | null }
|
||||
const selected = ref<Record<string, Selection>>({})
|
||||
const selectedRows = computed(() => Object.values(selected.value))
|
||||
const externalRows = computed(() => selectedRows.value.filter(row => row.sourceKey.startsWith('BOOKING:')))
|
||||
const batchPreview = ref<PlanningBatchPreview | null>(null)
|
||||
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
|
||||
let batchRequest = 0
|
||||
function addSource(source: PlanningSource) {
|
||||
if (!access.can(overseasWarehousePermissions.inboundOrderAppointment) || batchLoading.value || creating.value) return false
|
||||
if (!source.canFollowup) { ElMessage.warning('当前来源尚不能安排预约'); return false }
|
||||
const anchor = selectedRows.value[0]
|
||||
if (anchor && (anchor.originWarehouseId !== source.originWarehouseId || anchor.destinationWarehouseCode !== source.destinationWarehouseCode)) {
|
||||
ElMessage.warning('请先完成或清空当前合批选择;不同起运仓或目的仓不能混入'); return false
|
||||
}
|
||||
if (!selected.value[source.sourceKey] && selectedRows.value.length >= 100) { ElMessage.warning('每批最多选择100票'); return false }
|
||||
selected.value[source.sourceKey] = source; batchError.value = null
|
||||
return true
|
||||
}
|
||||
function acceptExternalSource() {
|
||||
if (props.externalSource && addSource(props.externalSource)) emit('sourceAccepted')
|
||||
}
|
||||
function arrangeFollowing() {
|
||||
if (following.value && !following.value.coverage.appointments.length && addSource(following.value.source)) following.value = null
|
||||
}
|
||||
function selectable(row: PlanningCargo) {
|
||||
const anchor = selectedRows.value[0]
|
||||
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
|
||||
}
|
||||
function toggle(row: PlanningCargo, checked: unknown) {
|
||||
batchError.value = null
|
||||
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
|
||||
else delete selected.value[row.sourceKey]
|
||||
}
|
||||
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
|
||||
async function previewBatch() {
|
||||
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
|
||||
const current = ++batchRequest
|
||||
batchLoading.value = true; batchError.value = null
|
||||
try {
|
||||
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
|
||||
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
|
||||
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
|
||||
finally { if (current === batchRequest) batchLoading.value = false }
|
||||
}
|
||||
async function saveArrangement() {
|
||||
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
|
||||
const error = appointmentFormError(arrangement)
|
||||
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
|
||||
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
|
||||
creating.value = true; createError.value = null
|
||||
try {
|
||||
const result = await createSourceAppointment(pendingCreate.value)
|
||||
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
|
||||
} catch (cause) {
|
||||
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
let followupRequest = 0
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
|
||||
const page = ref(1), size = ref(20), total = ref(0)
|
||||
const rows = ref<PlanningCargo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
let controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
|
||||
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
|
||||
{ prop: 'originWarehouseName', label: '接仓点', minWidth: 140 },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '预报箱数', width: 100 },
|
||||
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
|
||||
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
|
||||
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
|
||||
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
|
||||
]
|
||||
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listPlanningCargo({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||
if (request !== current) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (cause) {
|
||||
if (request === current) { error.value = getApiErrorDetails(cause, '已开船货物加载失败'); rows.value = []; total.value = 0 }
|
||||
} finally { if (request === current) loading.value = false }
|
||||
}
|
||||
function source(row: PlanningCargo) {
|
||||
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
|
||||
}
|
||||
async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId' | 'destinationWarehouseCode'>) {
|
||||
const request = ++followupRequest
|
||||
followupError.value = null
|
||||
try {
|
||||
const result = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })
|
||||
if (request === followupRequest) following.value = result
|
||||
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
|
||||
}
|
||||
function canLeave() {
|
||||
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function closeBatch() { if (canLeave()) batchPreview.value = null }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
|
||||
<template #query-tabs><AppointmentCargoTabs v-model="candidateMode" /></template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #notices>
|
||||
<details v-if="selectedRows.length" class="external-selection"><summary>已选 {{ selectedRows.length }} 票(切换货源保留选择)</summary>
|
||||
<div class="external-selection-list"><div v-for="row in selectedRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
|
||||
</details>
|
||||
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false" />
|
||||
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||
</template>
|
||||
</AppointmentExternalCandidates>
|
||||
<DataTable v-show="candidateMode === 'firstleg'" v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
|
||||
<template #toolbar-actions>
|
||||
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
|
||||
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||
</template>
|
||||
<template #filters>
|
||||
<section class="integrated-query-group" aria-label="头程货物类型与筛选">
|
||||
<AppointmentCargoTabs v-if="candidateMode === 'firstleg'" v-model="candidateMode" />
|
||||
<QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||
</section>
|
||||
</template>
|
||||
<template #notices>
|
||||
<div v-if="externalSource" class="cargo-note">
|
||||
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
|
||||
<el-button v-if="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :disabled="batchLoading || creating" @click="acceptExternalSource">加入本次选择</el-button>
|
||||
</div>
|
||||
<details v-if="externalRows.length" class="external-selection">
|
||||
<summary>已选外接货 {{ externalRows.length }} 票(可与同仓头程货合批)</summary>
|
||||
<div class="external-selection-list"><div v-for="row in externalRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
|
||||
</details>
|
||||
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="(value: boolean | string | number) => toggle(row as PlanningCargo, value)" /></template>
|
||||
<template #warehouseEta="{ row }"><span>{{ row.estimatedWarehouseAt?.replace('T', ' ') || '—' }}</span><div v-if="row.estimatedWarehouseAt" class="cargo-note">来源时区未明确</div></template>
|
||||
<template #progress="{ row }">
|
||||
<span v-if="row.detained">暂扣,待处理</span>
|
||||
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
|
||||
<span v-else-if="row.hasActiveAppointment">已有预约</span>
|
||||
<span v-else>{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
|
||||
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cargo-note { margin: var(--admin-space-3) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.external-selection { margin: var(--admin-space-3) 0; }
|
||||
.external-selection-list { max-height: 216px; overflow-y: auto; }
|
||||
.external-selection-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<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,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||
import { listImpacts, refreshImpacts, impactStatus } from './impactApi'
|
||||
|
||||
const impactReady = ref(false), impactError = ref<ApiErrorDetails | null>(null)
|
||||
const impactId = ref<string | null>(null), refreshing = ref(false), onlyChanges = ref(false)
|
||||
const access = useAccessPreviewStore()
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
|
||||
const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total = ref(0)
|
||||
const loading = ref(false), opening = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
|
||||
const context = ref<ContainerFollowupContext | null>(null)
|
||||
const changedCount = computed(() => rows.value.filter(row => row.impact?.status === 'OPEN').length)
|
||||
const displayedRows = computed(() => onlyChanges.value ? rows.value.filter(row => row.impact?.status === 'OPEN') : rows.value)
|
||||
async function refreshForecasts() {
|
||||
refreshing.value = true; contextError.value = null
|
||||
try { await refreshImpacts({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }); await load() }
|
||||
catch (cause) { contextError.value = getApiErrorDetails(cause, '船期核对未完成,原记录保留') }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
let request = 0, contextRequest = 0, controller: AbortController | undefined
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
|
||||
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
|
||||
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
|
||||
{ prop: 'customerCount', label: '客户数', width: 90 },
|
||||
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
|
||||
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
|
||||
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
|
||||
{ key: 'impact', label: '船期跟进', slotName: 'impact', minWidth: 160 },
|
||||
{ key: 'owner', label: '负责人', slotName: 'owner', minWidth: 110 },
|
||||
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
|
||||
]
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
|
||||
try {
|
||||
const params = { page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }
|
||||
const [cargo, impacts] = await Promise.allSettled([listPlanningContainers(params, controller.signal), listImpacts(params, controller.signal)])
|
||||
if (current !== request) return
|
||||
if (cargo.status === 'rejected') throw cargo.reason
|
||||
rows.value = cargo.value.records; total.value = cargo.value.total
|
||||
if (impacts.status === 'fulfilled') {
|
||||
const states = new Map(impacts.value.records.map(row => [row.container.containerBookingId, row.impact]))
|
||||
rows.value = rows.value.map(row => ({ ...row, impact: states.get(row.containerBookingId) || null }))
|
||||
impactReady.value = true
|
||||
} else { onlyChanges.value = false; impactError.value = getApiErrorDetails(impacts.reason, '船期跟进暂不可用') }
|
||||
|
||||
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
|
||||
finally { if (current === request) loading.value = false }
|
||||
}
|
||||
async function follow(row: PlanningContainer, warehouseId?: string) {
|
||||
const current = ++contextRequest
|
||||
contextError.value = null; opening.value = true
|
||||
try {
|
||||
const result = await resolvePlanningContainer(row.containerBookingId, warehouseId)
|
||||
if (current === contextRequest) context.value = result
|
||||
} catch (cause) { if (current === contextRequest) contextError.value = getApiErrorDetails(cause, '整柜范围核对失败') }
|
||||
finally { if (current === contextRequest) opening.value = false }
|
||||
}
|
||||
const displayTime = (value: string | null) => value?.replace('T', ' ') || '—'
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="displayedRows" :total="total" :loading="loading || refreshing" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
|
||||
<template #actions>
|
||||
<el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
|
||||
<el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
|
||||
</template>
|
||||
</QueryToolbar></template>
|
||||
<template #notices>
|
||||
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
||||
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
||||
<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 #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
|
||||
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
|
||||
<template #impact="{ row }">{{ row.impact ? impactStatus[row.impact.status] : (impactReady ? '未核对' : '暂不可用') }}</template>
|
||||
<template #owner="{ row }">{{ row.impact?.ownerName || '未认领' }}</template>
|
||||
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
|
||||
</DataTable>
|
||||
<AppointmentImpactDialog v-if="impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
|
||||
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-help { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.container-help summary { cursor: pointer; }
|
||||
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
</style>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listExternalAppointmentCandidates, type ExternalAppointmentCandidate, type PlanningSource } from './planningApi'
|
||||
|
||||
defineProps<{ canArrange: boolean; selectedKeys: string[]; busy: boolean }>()
|
||||
const emit = defineEmits<{ select: [source: PlanningSource]; followup: [row: ExternalAppointmentCandidate] }>()
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
|
||||
const rows = ref<ExternalAppointmentCandidate[]>([]), error = ref<ApiErrorDetails | null>(null)
|
||||
const query = ref<QueryModel>({ keyword: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '外接货', placeholder: '登记号、车次号、客户、来货号', width: 300 }]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 160, fixed: 'left' },
|
||||
{ key: 'booking', label: '原登记号', slotName: 'booking', minWidth: 190 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 130 },
|
||||
{ prop: 'tripNo', label: '车次号', minWidth: 190 },
|
||||
{ key: 'origin', label: '接仓点', slotName: 'origin', minWidth: 140 },
|
||||
{ key: 'destination', label: '目的FBA仓', slotName: 'destination', width: 110 },
|
||||
{ key: 'packages', label: '箱数', slotName: 'packages', width: 90 },
|
||||
{ key: 'coverage', label: '预约情况', slotName: 'coverage', minWidth: 180 },
|
||||
]
|
||||
let request = 0
|
||||
let controller: AbortController | undefined
|
||||
async function load(reset = false) {
|
||||
if (reset) page.value = 1
|
||||
const current = ++request
|
||||
controller?.abort(); controller = new AbortController()
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
const result = await listExternalAppointmentCandidates({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||
if (current === request) { rows.value = result.records; total.value = result.total }
|
||||
} catch (cause) {
|
||||
if (current === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '外接候选加载失败') }
|
||||
} finally { if (current === request) loading.value = false }
|
||||
}
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; controller?.abort() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
|
||||
<template #toolbar-actions><slot name="toolbar-actions" /></template>
|
||||
<template #filters>
|
||||
<section class="integrated-query-group" aria-label="外接货物类型与筛选">
|
||||
<slot name="query-tabs" />
|
||||
<QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||
</section>
|
||||
</template>
|
||||
<template #notices>
|
||||
<slot name="notices" />
|
||||
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-else>已确认承运的美国FBA货物,来货前后保留同一原登记;箱数未知可先保存待确认安排。</p>
|
||||
</template>
|
||||
<template #actions="{ row }"><template v-if="row.source && row.coverage">
|
||||
<el-button v-if="canArrange" link type="primary" :disabled="busy || selectedKeys.includes(row.source.sourceKey) || !row.source.canFollowup || row.coverage.appointments.length > 0" @click="emit('select', row.source)">{{ selectedKeys.includes(row.source.sourceKey) ? '已加入' : '加入合批' }}</el-button>
|
||||
<el-button link type="primary" @click="emit('followup', row as ExternalAppointmentCandidate)">跟进</el-button>
|
||||
</template></template>
|
||||
<template #booking="{ row }">{{ row.source?.bookingNo || '—' }}</template>
|
||||
<template #origin="{ row }">{{ row.source?.originWarehouseName || '—' }}</template>
|
||||
<template #destination="{ row }">{{ row.source?.destinationWarehouseCode || '—' }}</template>
|
||||
<template #packages="{ row }">{{ row.source?.packages ?? '待核实' }}</template>
|
||||
<template #coverage="{ row }"><span v-if="row.source && row.coverage">{{ row.coverage.appointments.length ? (row.coverage.changeReviewRequired ? '已有预约,待复核' : '已有预约') : (row.source.canFollowup ? '待安排' : row.source.blockReasons.join(';')) }}</span></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
|
||||
function render(canWrite = true, container?: ContainerFollowupContext) {
|
||||
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '<div><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElSelect: true, ElOption: true, ElDatePicker: true,
|
||||
DataTable: true, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } })
|
||||
}
|
||||
describe('appointment followup recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); vi.mocked(getFollowups).mockResolvedValue(history)
|
||||
vi.mocked(getContainerFollowups).mockResolvedValue(history)
|
||||
vi.mocked(saveFollowup).mockResolvedValue({ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '已联系', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' })
|
||||
})
|
||||
it.each([null, '100'])('saves the correct container stage for warehouse %s without customer-scoped calls', async warehouseId => {
|
||||
const container = { containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId, warehouseName: warehouseId ? '洛杉矶仓' : null, canFollowup: true, blockReasons: [] }
|
||||
const wrapper = render(true, container); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('同柜统一联系')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveContainerFollowup).toHaveBeenCalledWith(container, expect.objectContaining({ stage: warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT', expectedVersion: 0, result: '同柜统一联系' }))
|
||||
expect(getContainerFollowups).toHaveBeenCalledTimes(2)
|
||||
expect(getFollowups).not.toHaveBeenCalled()
|
||||
expect(saveFollowup).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('describes FBA followup independently of batch action visibility', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('FBA收货跟进')
|
||||
expect(wrapper.text()).not.toContain('同柜多客户共用提柜')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(saveFollowup).toHaveBeenCalledWith('ORDER:1', 'A', expect.objectContaining({ stage: 'FBA_RECEIVING', expectedVersion: 0, result: '已联系,等待回复', followupDate: null }))
|
||||
const body = vi.mocked(saveFollowup).mock.calls[0]![2]
|
||||
expect(body).not.toHaveProperty('appointmentNo'); expect(body).not.toHaveProperty('driverId'); expect(body).not.toHaveProperty('operatorId')
|
||||
expect(getFollowups).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('retries an unknown outcome with the exact same body and persisted command key', async () => {
|
||||
vi.mocked(saveFollowup).mockRejectedValueOnce(new Error('network'))
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
|
||||
expect(first.idempotencyKey).toBeTruthy()
|
||||
})
|
||||
it('does not allow a write without write permission or a successfully loaded version', async () => {
|
||||
const readonly = render(false); await flushPromises()
|
||||
expect(readonly.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
vi.mocked(getFollowups).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const failed = render(); await flushPromises()
|
||||
expect(failed.get('[data-save]').attributes('disabled')).toBeDefined()
|
||||
expect(saveFollowup).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type ContainerFollowupContext, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
|
||||
|
||||
const props = defineProps<({ source: PlanningSource; coverage: AppointmentCoverage; container?: never } | { container: ContainerFollowupContext; source?: never; coverage?: never }) & { canWrite: boolean; canArrange?: boolean }>()
|
||||
const emit = defineEmits<{ close: []; arrange: [] }>()
|
||||
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
|
||||
const coverageNeeds: Record<string, string> = {
|
||||
DESTINATION: '目的仓与原安排不一致,请核对。',
|
||||
DESTINATION_UNVERIFIED: '来源目的地资料待核实,尚不能判断是否发生变更。',
|
||||
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
|
||||
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先保存待确认安排;正式确认前需补齐资料。',
|
||||
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
|
||||
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
|
||||
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
|
||||
RECEIVING_CONFIRMATION: '尚未取得全部货物的收货确认。',
|
||||
APPOINTMENT_TIMEZONE_UNVERIFIED: '原预约未记录时区,请核实收货地点当地时间。',
|
||||
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
|
||||
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
|
||||
}
|
||||
const allowedStages = computed(() => props.container ? props.container.warehouseId ? { WAREHOUSE_RECEIVING: stages.WAREHOUSE_RECEIVING } : { DRAYAGE_CONTACT: stages.DRAYAGE_CONTACT, TERMINAL_APPOINTMENT: stages.TERMINAL_APPOINTMENT } : { FBA_RECEIVING: stages.FBA_RECEIVING })
|
||||
const canFollowup = computed(() => props.container?.canFollowup ?? props.source?.canFollowup ?? false)
|
||||
const blockReasons = computed(() => props.container?.blockReasons ?? props.source?.blockReasons ?? [])
|
||||
const title = computed(() => props.container ? `${props.container.containerNo || '未回传柜号'} · ${props.container.warehouseName || '提柜'}` : props.source?.inboundCode || props.source?.bookingNo || '未关联入仓码')
|
||||
const form = reactive({ stage: (props.container ? props.container.warehouseId ? 'WAREHOUSE_RECEIVING' : 'DRAYAGE_CONTACT' : 'FBA_RECEIVING') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
|
||||
const rows = ref<FollowupRecord[]>([])
|
||||
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
|
||||
const page = ref(1), size = ref(10), total = ref(0)
|
||||
const loading = ref(false), saving = ref(false), ready = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<SaveFollowup | null>(null)
|
||||
let generation = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'stage', label: '事项', slotName: 'stage', width: 120 },
|
||||
{ prop: 'contactedParty', label: '联系对象', minWidth: 130 },
|
||||
{ prop: 'result', label: '联系结果', minWidth: 200 },
|
||||
{ prop: 'nextAction', label: '下一步', minWidth: 150 },
|
||||
{ prop: 'followupDate', label: '下次跟进日', width: 120 },
|
||||
{ prop: 'operatorName', label: '跟进人', minWidth: 110 },
|
||||
]
|
||||
function rowKey(row: object) { const value = row as FollowupRecord; return `${value.stage}:${value.version}` }
|
||||
async function load() {
|
||||
const request = ++generation
|
||||
loading.value = true; error.value = null; ready.value = false
|
||||
try {
|
||||
const result = props.container ? await getContainerFollowups(props.container, page.value, size.value) : await getFollowups(props.source!.sourceKey, props.source!.customerId, page.value, size.value)
|
||||
if (request !== generation) return
|
||||
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
|
||||
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!props.canWrite || !canFollowup.value || saving.value || !ready.value) return
|
||||
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
|
||||
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
|
||||
}
|
||||
pending.value ||= { stage: form.stage, expectedVersion: versions.value[form.stage], idempotencyKey: crypto.randomUUID(),
|
||||
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
|
||||
saving.value = true; saveError.value = null
|
||||
try {
|
||||
if (props.container) await saveContainerFollowup(props.container, pending.value)
|
||||
else await saveFollowup(props.source!.sourceKey, props.source!.customerId, pending.value)
|
||||
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
|
||||
ElMessage.success('跟进记录已保存'); await load()
|
||||
} catch (cause) {
|
||||
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
function canLeave() {
|
||||
if (saving.value || pending.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
function arrange() { if (canLeave()) emit('arrange') }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="close" :before-close="beforeClose">
|
||||
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
|
||||
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
|
||||
<p v-if="container">同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
|
||||
<p v-else>本条记录用于此货源的FBA收货跟进;保存联系不自动确认预约或推进收货。</p>
|
||||
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
|
||||
<p v-if="coverage?.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
|
||||
<p v-if="coverage?.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
|
||||
<p v-else-if="coverage">尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
|
||||
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
|
||||
</el-form>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
|
||||
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.followup-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-6); }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.followup-form :deep(.el-select), .followup-form :deep(.el-date-editor) { width: 100%; }
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
<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,41 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
|
||||
describe('appointment inbound code list', () => {
|
||||
const codes = Array.from({ length: 20 }, (_, index) => `WT-LONG-INBOUND-CODE-${index + 1}`)
|
||||
|
||||
it('previews three codes, exposes the total and expands without losing full codes', async () => {
|
||||
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
expect(wrapper.text()).toContain('20 个')
|
||||
expect(wrapper.get('button').text()).toContain('还有 17 个')
|
||||
expect(wrapper.get('li').attributes('title')).toBe(codes[0])
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.findAll('li').map(row => row.text())).toEqual(codes)
|
||||
expect(wrapper.get('ul').classes()).toContain('expanded')
|
||||
expect(wrapper.get('ul').attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('button').attributes('aria-controls')).toBe(wrapper.get('ul').attributes('id'))
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not count missing or duplicate codes and shows no redundant toggle', () => {
|
||||
const wrapper = mount(AppointmentInboundCodes, { props: { codes: [' A ', 'A', '', null, undefined, 'B'] } })
|
||||
expect(wrapper.findAll('li').map(row => row.text())).toEqual(['A', 'B'])
|
||||
expect(wrapper.text()).toContain('2 个')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(mount(AppointmentInboundCodes, { props: { codes: [] } }).text()).toContain('—')
|
||||
})
|
||||
|
||||
it('preserves expansion on equivalent data but resets when the appointment codes change', async () => {
|
||||
const wrapper = mount(AppointmentInboundCodes, { props: { codes } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.setProps({ codes: [...codes] })
|
||||
expect(wrapper.findAll('li')).toHaveLength(20)
|
||||
await wrapper.setProps({ codes: codes.map(code => `${code}-NEW`) })
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ codes: Array<string | null | undefined> }>()
|
||||
const expanded = ref(false)
|
||||
const listId = useId()
|
||||
const codes = computed(() => [...new Set(props.codes.map(code => code?.trim()).filter((code): code is string => !!code))])
|
||||
const visibleCodes = computed(() => expanded.value ? codes.value : codes.value.slice(0, 3))
|
||||
watch(() => codes.value.join('\n'), () => { expanded.value = false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-codes" aria-label="关联入仓码">
|
||||
<div class="codes-heading">
|
||||
<span :title="`共 ${codes.length} 个入仓码`">入仓码 <span v-if="codes.length">({{ codes.length }} 个)</span></span>
|
||||
<button v-if="codes.length > 3" type="button" :aria-expanded="expanded" :aria-controls="listId" @click="expanded = !expanded">
|
||||
{{ expanded ? '收起' : `查看全部(还有 ${codes.length - 3} 个)` }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="codes.length" :id="listId" class="codes-list" :class="{ expanded }" :tabindex="expanded ? 0 : undefined" aria-label="入仓码列表">
|
||||
<li v-for="code in visibleCodes" :key="code" :title="code">{{ code }}</li>
|
||||
</ul>
|
||||
<span v-else class="codes-empty">—</span>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-codes { min-width: 0; margin: var(--admin-space-5) 0; font-size: var(--admin-font-body); }
|
||||
.codes-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.codes-heading button { border: 0; background: transparent; color: var(--el-color-primary); cursor: pointer; font: inherit; min-height: var(--admin-action-height); padding: 0; }
|
||||
.codes-list { list-style: none; padding: 0; margin: var(--admin-space-1) 0 0; line-height: 1.8; }
|
||||
.codes-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.codes-list.expanded { max-height: 216px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.codes-list.expanded li { white-space: normal; overflow-wrap: anywhere; }
|
||||
.codes-heading button:focus-visible, .codes-list:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.codes-empty { display: block; margin-top: var(--admin-space-1); }
|
||||
</style>
|
||||
@@ -1,173 +0,0 @@
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { listImpacts } from './impactApi'
|
||||
vi.mock('./impactApi', async original => ({ ...await original<typeof import('./impactApi')>(), listImpacts: vi.fn(), refreshImpacts: vi.fn() }))
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningContainer, type PlanningSource } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
|
||||
const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: '2026-10-10T13:48:15', orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }
|
||||
|
||||
describe('appointment tables use actual shared table slots', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(listImpacts).mockImplementation(async () => {
|
||||
const page = await vi.mocked(listPlanningContainers).mock.results.at(-1)!.value
|
||||
return { total: page.total, records: page.records.map((container: PlanningContainer) => ({ container, impact: container.impact || null })) }
|
||||
})
|
||||
})
|
||||
it('discovers external candidates lazily and mixes original bookings with selected first-leg cargo', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null }
|
||||
vi.mocked(listExternalAppointmentCandidates).mockResolvedValue({ records: [{ source: external, customerName: '客户乙', tripNo: 'TR-1', coverage: { status: 'UNSCHEDULED', appointments: [], missingFields: [], changeReviewRequired: false } }], total: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source, external], missingFields: [] })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('.data-table__filters [role="tablist"]').text()).toContain('头程货物')
|
||||
await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
|
||||
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('.data-table__filters [role="tablist"]')).toHaveLength(1)
|
||||
expect(wrapper.get('.data-table__filters [role="tablist"]').isVisible()).toBe(true)
|
||||
expect(wrapper.get('.data-table__filters [role="tab"][aria-selected="true"]').text()).toBe('外接货物')
|
||||
expect(wrapper.text()).toContain('B-1')
|
||||
await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'BOOKING:B-1', customerId: 'B', destinationKey: 'FBA:ONT8' }])
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('combines an original external booking with first-leg cargo and retries the identical save command', async () => {
|
||||
const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null}
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',destinationTimezone:null,sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]})
|
||||
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}])
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
const first=vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
|
||||
expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenNthCalledWith(2,first);expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('retains the current selection when a linked source has a different origin', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
|
||||
vi.mocked(previewPlanningBatch).mockRejectedValue(new Error('stop after observing request'))
|
||||
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:{...source,sourceKey:'BOOKING:B-2',bookingNo:'B-2',originWarehouseId:'200'}},global:{plugins:[ElementPlus]}})
|
||||
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
|
||||
expect(wrapper.emitted('sourceAccepted')).toBeUndefined()
|
||||
await wrapper.findAll('button').find(b=>b.text().startsWith('合批核对'))!.trigger('click');await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}]);wrapper.unmount()
|
||||
})
|
||||
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text().startsWith('合批核对'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'ORDER:2', customerId: 'B', destinationKey: 'FBA:ONT8' }])
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱')
|
||||
expect(wrapper.text()).toContain('保存前尚未占用货物')
|
||||
expect(wrapper.text()).toContain('America/Los_Angeles')
|
||||
expect(wrapper.text()).not.toContain('目的仓时区待核实')
|
||||
expect(wrapper.text()).toContain('2026-10-10 13:48:15')
|
||||
expect(wrapper.text()).toContain('来源时区未明确')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the cargo list usable when impact service is unavailable and never reports a false zero', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [] }] })
|
||||
vi.mocked(listImpacts).mockRejectedValueOnce(new Error('Resource not found'))
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
expect(wrapper.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('CONTAINER-A')
|
||||
expect(wrapper.text()).toContain('船期跟进暂不可用,货柜列表仍可查看')
|
||||
expect(wrapper.text()).not.toContain('本页待处理 0')
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('details').every(node => node.attributes('open') === undefined)).toBe(true)
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1); expect(listImpacts).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders one shared container and resolves each receiving warehouse without per-row requests', async () => {
|
||||
vi.mocked(listPlanningContainers).mockResolvedValue({ total: 1, records: [{ impact: { containerBookingId: 'C1', impactNo: 'AI-T', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: 'A', ownerName: '运营甲', baseline: null, latest: null, forecastToken: 'x', updatedAt: '2026-10-10T00:00:00' }, containerBookingId: 'C1', containerNo: 'CONTAINER-A', orderCount: 20, customerCount: 12, packages: null, unknownPackageOrders: 1, earliestArrivalAt: null, latestArrivalAt: null, standardNode: 'DEPARTED', sourceVersion: null, incompleteScope: false, warehouses: [{ warehouseId: '100', warehouseName: '洛杉矶仓', orderCount: 20, customerCount: 12 }] }] })
|
||||
vi.mocked(resolvePlanningContainer).mockResolvedValue({ containerBookingId: 'C1', containerNo: 'CONTAINER-A', warehouseId: '100', warehouseName: '洛杉矶仓', canFollowup: true, blockReasons: [] })
|
||||
const wrapper = mount(AppointmentContainerPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(listPlanningContainers).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('本页待处理 1')
|
||||
expect(wrapper.text()).toContain('处理船期变化')
|
||||
expect(wrapper.text()).toContain('运营甲')
|
||||
expect(resolvePlanningContainer).not.toHaveBeenCalled()
|
||||
const rows = wrapper.findAll('.el-table__body-wrapper .el-table__row')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text()).toContain('待核实')
|
||||
expect(rows[0]!.text()).toContain('洛杉矶仓')
|
||||
await rows[0]!.findAll('button').find(button => button.text() === '接仓跟进')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(resolvePlanningContainer).toHaveBeenCalledExactlyOnceWith('C1', '100')
|
||||
expect(wrapper.findComponent(AppointmentFollowupDialog).props('container')?.warehouseId).toBe('100')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('renders working source actions and readable progress through DataTable and RowActions', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppointmentFollowupDialog: true } } })
|
||||
await flushPromises()
|
||||
const row = wrapper.get('.el-table__body-wrapper .el-table__row')
|
||||
expect(row.text()).toContain('IN-A')
|
||||
expect(row.text()).toContain('尚未建入仓计划')
|
||||
const followup = row.findAll('button').find(button => button.text() === '跟进')
|
||||
expect(followup).toBeDefined()
|
||||
expect(row.findAll('button').some(button => button.text() === '查看来源')).toBe(true)
|
||||
await followup!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(resolvePlanningSource).toHaveBeenCalledExactlyOnceWith({ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' })
|
||||
expect(wrapper.findComponent(AppointmentFollowupDialog).exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => {
|
||||
vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}})
|
||||
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /></section>'}}}})
|
||||
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
|
||||
expect(wrapper.emitted('arrange')).toHaveLength(1)
|
||||
await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
|
||||
await wrapper.setProps({canArrange:true,coverage:{...coverage,appointments:[{appointmentNo:'AP-EXISTING',confirmationNo:null,status:'PENDING',confirmedAt:null,confirmedEndAt:null,timezone:null}]}})
|
||||
expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false);wrapper.unmount()
|
||||
})
|
||||
it('renders followup stage names instead of blank table cells', async () => {
|
||||
vi.mocked(getFollowups).mockResolvedValue({ history: { records: [{ stage: 'FBA_RECEIVING', version: 1, contactedParty: null, result: '等待回复', nextAction: null, followupDate: null, operatorName: '运营', createdAt: '2026-10-09T12:00:00Z' }], total: 1, page: 1, size: 10, pages: 1 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 1 } })
|
||||
const wrapper = mount(AppointmentFollowupDialog, { props: { source, coverage, canWrite: false }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
const row = wrapper.get('.el-table__body-wrapper .el-table__row')
|
||||
expect(row.text()).toContain('FBA收货跟进')
|
||||
expect(row.text()).toContain('等待回复')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
# 预约管理交互矩阵
|
||||
|
||||
原型:用户提供 `C:/Users/BY-OY/Desktop/新版.html` 的 `page-appointment-calendar`、`modal-appointment-edit`、`renderAppointmentCalendar`、`renderAppointmentDay`、`openAppointmentModal`、`deleteAppointment`,以及本对话三张截图。
|
||||
|
||||
用户确认:同一目的仓允许多个客户货物统一预约、统一送仓,不能按客户拆预约。客户隔离保留在货物明细。
|
||||
|
||||
| 页面/操作 | 原型承载与布局 | 实现约束 |
|
||||
|---|---|---|
|
||||
| 补充计划时段 | 用户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三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
|
||||
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
|
||||
| 到港详情 | 当天安排右侧只读 | 使用货柜/船节点事实,无逐行详情请求,无模拟到港数据 |
|
||||
| 取消预约 | 二次确认 | 显式提示全部货物释放占用、历史保留;沿用独立权限,已关联有效排车时先处理运输安排,不能自动回退库存 |
|
||||
| 月/周切换 | 顶部切换与前后按钮 | 周模式按7天移动,修正原型仍按月移动的问题 |
|
||||
| 多条安排 | 日历标签、右侧明细 | 月/周日格均最多显示3条,超出显示“还有N条”,悬停显示当天总数;日格固定高度,右侧全部展示并独立滚动 |
|
||||
| 多入仓码 | 用户2026-10-09补充截图:单预约20票导致两列详情被长字符串撑高 | 右侧卡片与编辑弹框共用独立整行入仓码列表;去空去重后显示总数,默认3条,长码悬停可读全文;超过3条可展开/收起,展开后216px内独立滚动并可键盘聚焦,不拼成长段文字 |
|
||||
| 在途货物及外接来源跟进 | 美国预约统一方案的提前联系需求 | 日历与已开船FBA列表切换;跟进在居中1080px弹框中承载,3列表单、联系结果及下一步整行,历史分页;BOOKING深链先由服务端核对,再展示原登记及真实预约缺项 |
|
||||
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
|
||||
| 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 |
|
||||
| 外接货进入合批 | 同仓跨客户共用预约,不因未入仓而只允许跟进 | 后端核实BOOKING深链后,“加入预约合批”进入现有合批工作区,再显式加入选择;可与同起运仓/目的仓头程货混选,保留原BOOKING键与未知箱数。跨仓不清空已有选择,已有关联预约不再开放新增入口;切换日历/合批工作视图保留本页选择,外接选择默认折叠、展开216px内滚动 |
|
||||
| 完整外接候选 | 统一方案第九节两类货源均可筹划、合批及核对覆盖 | 在同一合批工作区切换头程/外接来源列表,外接按服务端分页、关键词筛选,展示原登记、客户、车次、接仓点、目的仓、可空箱数及预约覆盖。加入选择复用原100票合批选择与保存弹框;查看跟进沿用原弹框,不新增预约数据副本。切换来源保留选择,未打开外接列表不请求;待1920×1080验收 |
|
||||
| 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 |
|
||||
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
|
||||
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |
|
||||
| 来源失败 | 统一方案的部分结果规则 | 日历保留成功来源并提示不完整;深链解析失败不打开假来源;跟进版本加载失败禁止保存,未知提交结果保留原请求供重试 |
|
||||
| 船期变化影响与处理 | 统一方案第十节场景16,旧/新预测、下游影响、负责人及结果闭环 | 并入提柜与到仓的货柜列表,不设第四工作视图;行内处理船期变化,本页待办计数明确标识范围;显式本页批量核对,不逐柜轮询。1200px居中详情展示直接货物/预约、关联车次与同车待复核承诺;各明细220px内滚动,历史服务端分页。认领及结果表单复用现有控件;未知提交保留原命令并禁止关闭,已确认预约不因核对动作改变。待1920×1080真实验收 |
|
||||
|
||||
结束时间未明确时允许为空。系统单号、外部确认号、计划时段及确认时段保持不同语义。日历范围最多42天,每次范围变化查询预约、到港及有权限的提柜、车次来源各一次;未定时列表独立分页,选中日期不发送请求。候选选仓后自动分页读取,跨页选择保留;换仓清空旧选择并丢弃旧请求,清空仓库不查货。时区由目的仓主档核实,夏令时重叠明确时差,不凭浏览器时区猜测。仓库请求失败可重试;只展示前50个匹配仓,超出提示缩小关键词。
|
||||
|
||||
本轮视觉验收:待人工1920×1080截图确认。
|
||||
|
||||
视觉验收目标为1920×1080、100%缩放。该矩阵描述设计和代码边界,不代替测试环境浏览器验收证据。
|
||||
|
||||
按用户确认收敛:主视图仅提柜与到仓、送仓与排车、预约日历,默认有权限的列表视图;日历首次进入才加载。到港参考、用车计划默认隐藏,可独立开关,不计入预约数量;缺国家资料与查询超限分开提示。送仓与排车目前仍以选货合批为主,目的仓货量汇总及7/14/30天窗口尚未完成,不因标题调整宣称完成。
|
||||
|
||||
用户截图密度修正:业务标题复用density-detail-title并与三视图同行,不独占大标题区;船期操作并入查询栏,不单独占空白工具栏。常驻业务解释改为默认折叠“说明”;错误保留重试,诊断信息默认折叠。基础货柜与船期状态独立读取,后者失败不阻断货柜列表、不展示假的零待办。
|
||||
|
||||
用户最新截图确认:移除“美国预约总览”文字,仅保留三视图导航;日历图例和图层开关归入有内边距的辅助栏,长范围提示默认折叠,月格112px仍容纳3条摘要及剩余数;右侧固定360px,系统单号/完整时段/备注跨整行。该项以当前用户要求覆盖原标题建议,待1920×1080实际页面确认。
|
||||
|
||||
用户最新确认覆盖默认列表建议:预约日历排第一并默认打开,之后为提柜与到仓、送仓与排车;范围资料提示移入日历工具栏“数据提示”,点击展开浮层,不在日历上方占行。真实接口错误仍保留错误与重试入口。
|
||||
|
||||
用户确认待核实车次不占日历顶部:入口并入“数据提示”,显式点击后打开1000px居中分页明细;保留原UTC、缺项及查看来源操作,未打开不渲染明细,不增加查询;日期范围变化关闭旧明细。
|
||||
@@ -1,73 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface ArrivalCalendarEntry {
|
||||
containerBookingId: string
|
||||
cabinetNo: string
|
||||
containerNo?: string | null
|
||||
vesselName?: string | null
|
||||
voyageNo?: string | null
|
||||
destinationPortName?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
arrivalAt: string
|
||||
arrivalType: 'ACTUAL' | 'ESTIMATED'
|
||||
}
|
||||
|
||||
export interface PickupCalendarEntry {
|
||||
containerBookingId: string
|
||||
cabinetNo: string
|
||||
containerNo: string | null
|
||||
destinationPortName: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
fleetSupplierName: string | null
|
||||
pickupAppointmentAt: string
|
||||
actualPickupAt: string | null
|
||||
}
|
||||
export interface PickupCalendar {
|
||||
records: PickupCalendarEntry[]
|
||||
truncated: boolean
|
||||
unclassifiedCount: number
|
||||
scopeBasis: 'US_DESTINATION_WAREHOUSE'
|
||||
timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'
|
||||
}
|
||||
export async function listPickupCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<PickupCalendar>, ApiEnvelope<PickupCalendar>>(
|
||||
'/platform/v1/terminal-drayages/calendar', { params: query, signal },
|
||||
))
|
||||
}
|
||||
|
||||
export async function listArrivalCalendar(query: { from: string; to: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<ArrivalCalendarEntry[]>, ApiEnvelope<ArrivalCalendarEntry[]>>(
|
||||
'/platform/v1/port-schedules/arrival-calendar', { params: query, signal },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deleteAppointment(appointmentNo: string, version: number, cancelReason: string) {
|
||||
const response = await http.delete<ApiEnvelope<void>, ApiEnvelope<void>>(
|
||||
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
|
||||
{ data: { version, cancelReason } },
|
||||
)
|
||||
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)}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, calendarDates, calendarRange, moveCalendar } from './calendar'
|
||||
|
||||
describe('appointment calendar', () => {
|
||||
it('shows six complete Monday-first weeks across year boundaries', () => {
|
||||
const dates = calendarDates('2027-01-15', 'month')
|
||||
expect(dates).toHaveLength(42)
|
||||
expect(dates[0]).toBe('2026-12-28')
|
||||
expect(dates.at(-1)).toBe('2027-02-07')
|
||||
expect(calendarRange('2027-01-15', 'month').to).toBe('2027-02-08')
|
||||
})
|
||||
|
||||
it('moves by a week in week mode instead of following the prototype month-navigation bug', () => {
|
||||
expect(moveCalendar('2026-09-24', 'week', 1)).toBe('2026-10-01')
|
||||
expect(calendarDates('2026-09-27', 'week')).toEqual([
|
||||
'2026-09-21', '2026-09-22', '2026-09-23', '2026-09-24',
|
||||
'2026-09-25', '2026-09-26', '2026-09-27',
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps month-end selection and handles leap days', () => {
|
||||
expect(moveCalendar('2028-01-31', 'month', 1)).toBe('2028-02-29')
|
||||
expect(moveCalendar('2027-03-31', 'month', -1)).toBe('2027-02-28')
|
||||
expect(addDays('2028-02-28', 1)).toBe('2028-02-29')
|
||||
expect(() => addDays('2027-02-29', 1)).toThrow('无效的日历日期')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
export type CalendarMode = 'month' | 'week'
|
||||
|
||||
/** End times are exclusive; a slot ending at midnight does not occupy the next day. */
|
||||
export function occursOnDate(startDate: string, endAt: string | undefined, date: string): boolean {
|
||||
if (date === startDate) return true
|
||||
if (date < startDate || !endAt) return false
|
||||
const endDate = endAt.slice(0, 10)
|
||||
return date < endDate || (date === endDate && /[1-9]/.test(endAt.slice(11)))
|
||||
}
|
||||
|
||||
/** Calendar dates are warehouse-local dates, never UTC conversions. */
|
||||
export function dateKey(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseDate(value: string): Date {
|
||||
const date = new Date(`${value}T12:00:00`)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(date.getTime()) || dateKey(date) !== value) {
|
||||
throw new Error('无效的日历日期')
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
export function addDays(value: string, days: number): string {
|
||||
const date = parseDate(value)
|
||||
date.setDate(date.getDate() + days)
|
||||
return dateKey(date)
|
||||
}
|
||||
|
||||
export function calendarRange(anchor: string, mode: CalendarMode): { from: string; to: string } {
|
||||
const date = parseDate(anchor)
|
||||
if (mode === 'month') date.setDate(1)
|
||||
date.setDate(date.getDate() - (date.getDay() + 6) % 7)
|
||||
const from = dateKey(date)
|
||||
// A half-open interval also works directly with database timestamps.
|
||||
return { from, to: addDays(from, mode === 'month' ? 42 : 7) }
|
||||
}
|
||||
|
||||
export function calendarDates(anchor: string, mode: CalendarMode): string[] {
|
||||
const { from } = calendarRange(anchor, mode)
|
||||
return Array.from({ length: mode === 'month' ? 42 : 7 }, (_, index) => addDays(from, index))
|
||||
}
|
||||
|
||||
export function moveCalendar(anchor: string, mode: CalendarMode, offset: number): string {
|
||||
if (mode === 'week') return addDays(anchor, offset * 7)
|
||||
const date = parseDate(anchor)
|
||||
const day = date.getDate()
|
||||
date.setDate(1)
|
||||
date.setMonth(date.getMonth() + offset)
|
||||
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
||||
date.setDate(Math.min(day, lastDay))
|
||||
return dateKey(date)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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,22 +0,0 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPlanningCargo, listPlanningContainers } from './planningApi'
|
||||
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 () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ sourceKey: 'ORDER:9007199254740993', orderId: '9007199254740993', estimatedArrivalAt: null }], page: '2', size: '20', total: '21', pages: '2' } })
|
||||
const controller = new AbortController()
|
||||
const result = await listPlanningCargo({ page: 2, size: 20, keyword: 'IN-A' }, controller.signal)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/cargo', { params: { page: 2, size: 20, keyword: 'IN-A' }, signal: controller.signal })
|
||||
expect(result.records[0]!.orderId).toBe('9007199254740993')
|
||||
expect(result.records[0]!.estimatedArrivalAt).toBeNull()
|
||||
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)
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { FbaAppointmentDetailsPayload, FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
|
||||
export interface PlanningCargo {
|
||||
sourceKey: string
|
||||
orderId: string
|
||||
customerId: string
|
||||
customerName: string | null
|
||||
inboundCode: string | null
|
||||
containerBookingId: string
|
||||
containerNo: string | null
|
||||
originWarehouseId: string
|
||||
originWarehouseCode: string
|
||||
originWarehouseName: string
|
||||
destinationWarehouseCode: string
|
||||
packages: number | null
|
||||
receivedPackages: number | null
|
||||
shippedPackages: number | null
|
||||
sourcePalletCount: number | null
|
||||
volumeCbm: number | null
|
||||
estimatedArrivalAt: string | null
|
||||
estimatedWarehouseAt: string | null
|
||||
orderVersion: number
|
||||
standardNode: string
|
||||
shippingVersion: string | null
|
||||
shippingObservedAt: string | null
|
||||
shippingProvider: string | null
|
||||
hasInboundPlan: boolean
|
||||
hasActiveAppointment: boolean
|
||||
detained: boolean
|
||||
mixedDestinations: boolean
|
||||
}
|
||||
export interface PlanningCargoPage { records: PlanningCargo[]; page: number; size: number; total: number; pages: number }
|
||||
export async function listPlanningCargo(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const response = await http.get<ApiEnvelope<PlanningCargoPage>, ApiEnvelope<PlanningCargoPage>>(
|
||||
'/platform/v1/last-leg/appointment-planning/cargo', { params: query, signal },
|
||||
)
|
||||
const page = unwrapApiResponse(response)
|
||||
return { ...page, page: Number(page.page), size: Number(page.size), total: Number(page.total), pages: Number(page.pages) }
|
||||
}
|
||||
|
||||
export type FollowupStage = 'DRAYAGE_CONTACT' | 'TERMINAL_APPOINTMENT' | 'WAREHOUSE_RECEIVING' | 'FBA_RECEIVING'
|
||||
export interface FollowupRecord { stage: FollowupStage; version: number; contactedParty: string | null; result: string; nextAction: string | null; followupDate: string | null; operatorName: string | null; createdAt: string }
|
||||
export interface FollowupHistory { history: { records: FollowupRecord[]; page: number; size: number; total: number; pages: number }; versions: Record<FollowupStage, number> }
|
||||
export interface SaveFollowup { stage: FollowupStage; expectedVersion: number; idempotencyKey: string; contactedParty?: string; result: string; nextAction?: string; followupDate: string | null }
|
||||
export async function getFollowups(sourceKey: string, customerId: string, page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<FollowupHistory>, ApiEnvelope<FollowupHistory>>(
|
||||
`/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, { params: { customerId, page, size } },
|
||||
))
|
||||
return { ...result, history: { ...result.history, total: Number(result.history.total) }, versions: Object.fromEntries(Object.entries(result.versions).map(([stage, version]) => [stage, Number(version)])) as Record<FollowupStage, number> }
|
||||
}
|
||||
export async function saveFollowup(sourceKey: string, customerId: string, body: SaveFollowup) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<FollowupRecord>, ApiEnvelope<FollowupRecord>>(
|
||||
`/platform/v1/last-leg/appointment-planning/sources/${encodeURIComponent(sourceKey)}/follow-ups`, body, { params: { customerId } },
|
||||
))
|
||||
}
|
||||
|
||||
export interface PlanningSource {
|
||||
sourceKey: string; customerId: string; orderId: string | null; inboundCode: string | null; bookingNo: string | null;
|
||||
containerNo: string | null; originWarehouseId: string; originWarehouseName: string; destinationKey: string;
|
||||
destinationWarehouseCode: string; packages: number | null; sourceVersion: string | null; canFollowup: boolean; blockReasons: string[];
|
||||
}
|
||||
export interface AppointmentCoverage {
|
||||
status: string; missingFields: string[]; changeReviewRequired: boolean;
|
||||
appointments: Array<{ appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }>;
|
||||
}
|
||||
export interface ResolvedPlanningSource { source: PlanningSource; coverage: AppointmentCoverage }
|
||||
export interface ExternalAppointmentCandidate extends ResolvedPlanningSource { customerName: string | null; tripNo: string }
|
||||
export async function listExternalAppointmentCandidates(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>, ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>>(
|
||||
'/platform/v1/last-leg/appointment-planning/external-candidates', { params: query, signal },
|
||||
))
|
||||
return { ...result, total: Number(result.total) }
|
||||
}
|
||||
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 }
|
||||
originWarehouseName: string
|
||||
destinationWarehouseCode: string
|
||||
destinationTimezone: string | null
|
||||
sources: PlanningSource[]
|
||||
missingFields: string[]
|
||||
}
|
||||
export async function previewPlanningBatch(sources: Array<{sourceKey: string; customerId: string; destinationKey: string}>) {
|
||||
const result = unwrapApiResponse(await http.post<ApiEnvelope<PlanningBatchPreview>, ApiEnvelope<PlanningBatchPreview>>(
|
||||
'/platform/v1/last-leg/appointment-planning/batches/preview', { sources },
|
||||
))
|
||||
return { ...result, group: { ...result.group, packages: result.group.packages == null ? null : Number(result.group.packages), sourceCount: Number(result.group.sourceCount), customerCount: Number(result.group.customerCount) } }
|
||||
}
|
||||
export interface SourceAppointmentPayload { originWarehouseId: string; fbaWarehouseCode: string; sources: Array<{sourceKey: string; customerId: string; destinationKey: string}>; details: FbaAppointmentDetailsPayload; idempotencyKey: string }
|
||||
export async function createSourceAppointment(payload: SourceAppointmentPayload) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<FbaAppointmentDto>, ApiEnvelope<FbaAppointmentDto>>('/platform/v1/last-leg/appointment-planning/appointments', payload))
|
||||
}
|
||||
export async function listUnscheduledAppointments(page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: FbaAppointmentDto[]; total: number}>, ApiEnvelope<{records: FbaAppointmentDto[]; total: number}>>('/platform/v1/last-leg/appointment-planning/appointments/unscheduled', { params: { page, size } }))
|
||||
return { ...result, total: Number(result.total) }
|
||||
}
|
||||
export async function resolvePlanningSource(query: { sourceKey: string; customerId: string; destinationKey?: string }) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ResolvedPlanningSource>, ApiEnvelope<ResolvedPlanningSource>>(
|
||||
'/platform/v1/last-leg/appointment-planning/sources/resolve', { params: query },
|
||||
))
|
||||
}
|
||||
|
||||
export interface PlanningContainer {
|
||||
impact?: import('./impactApi').Impact | null
|
||||
containerBookingId: string; containerNo: string | null; orderCount: number; customerCount: number;
|
||||
packages: number | null; unknownPackageOrders: number; earliestArrivalAt: string | null; latestArrivalAt: string | null;
|
||||
standardNode: string; sourceVersion: string | null; incompleteScope: boolean;
|
||||
warehouses: Array<{ warehouseId: string; warehouseName: string; orderCount: number; customerCount: number }>;
|
||||
}
|
||||
export interface ContainerFollowupContext {
|
||||
containerBookingId: string; containerNo: string | null; warehouseId: string | null; warehouseName: string | null;
|
||||
canFollowup: boolean; blockReasons: string[];
|
||||
}
|
||||
const containerBase = '/platform/v1/last-leg/appointment-planning/containers'
|
||||
export async function listPlanningContainers(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: PlanningContainer[]; total: number }>, ApiEnvelope<{ records: PlanningContainer[]; total: number }>>(containerBase, { params: query, signal }))
|
||||
return { ...result, total: Number(result.total), records: result.records.map(row => ({
|
||||
...row, orderCount: Number(row.orderCount), customerCount: Number(row.customerCount),
|
||||
packages: row.packages == null ? null : Number(row.packages), unknownPackageOrders: Number(row.unknownPackageOrders),
|
||||
warehouses: row.warehouses.map(warehouse => ({ ...warehouse, orderCount: Number(warehouse.orderCount), customerCount: Number(warehouse.customerCount) })),
|
||||
})) }
|
||||
}
|
||||
|
||||
export async function resolvePlanningContainer(containerId: string, warehouseId?: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<ContainerFollowupContext>, ApiEnvelope<ContainerFollowupContext>>(`${containerBase}/${encodeURIComponent(containerId)}`, { params: { warehouseId } }))
|
||||
}
|
||||
export async function getContainerFollowups(context: ContainerFollowupContext, page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<FollowupHistory>, ApiEnvelope<FollowupHistory>>(`${containerBase}/${encodeURIComponent(context.containerBookingId)}/follow-ups`, { params: { warehouseId: context.warehouseId || undefined, page, size } }))
|
||||
return { ...result, history: { ...result.history, total: Number(result.history.total) }, versions: Object.fromEntries(Object.entries(result.versions).map(([stage, version]) => [stage, Number(version)])) as Record<FollowupStage, number> }
|
||||
}
|
||||
export async function saveContainerFollowup(context: ContainerFollowupContext, body: SaveFollowup) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<FollowupRecord>, ApiEnvelope<FollowupRecord>>(`${containerBase}/${encodeURIComponent(context.containerBookingId)}/follow-ups`, body, { params: { warehouseId: context.warehouseId || undefined } }))
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const appointmentCalendarRoutes = [{
|
||||
path: 'appointment-calendar',
|
||||
name: 'appointment-calendar',
|
||||
component: () => import('./AppointmentCalendarView.vue'),
|
||||
meta: { title: '预约列表', showPageHeading: false, permission: 'platform:appointment:list' },
|
||||
}] satisfies RouteRecordRaw[]
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalendarEntry } from './tripCalendar'
|
||||
import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
|
||||
const localRange = {from:'2026-09-28',to:'2026-11-09'}
|
||||
expect(tripQueryWindow(localRange)).toEqual({from:'2026-09-27T10:00:00.000Z',to:'2026-11-09T14:00:00.000Z'})
|
||||
vi.mocked(http.get).mockResolvedValue({data:{records:[],unresolvedRecords:[]}})
|
||||
await listTripCalendar(localRange)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/calendar',{params:tripQueryWindow(localRange),signal:undefined})
|
||||
expect(() => tripQueryWindow({from:'2026-10-01',to:'2026-11-20'})).toThrow()
|
||||
})
|
||||
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
|
||||
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
|
||||
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
|
||||
})
|
||||
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
|
||||
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
|
||||
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
})
|
||||
it('retains original instants through DST overlap and does not replace the plan date with actual departure', () => {
|
||||
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
expect(events.map(event=>event.time)).toEqual(['01:30','01:30'])
|
||||
expect(events[0]).toMatchObject({date:'2026-11-01',subtitle:'运输计划,不代表收货方预约已确认'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'TRIP_PLAN'
|
||||
tripNo: string
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
originWarehouseName: string | null
|
||||
originCountryCode: string | null
|
||||
originTimezone: string | null
|
||||
originWarehouseStatus: string | null
|
||||
plannedDepartureAt: string | null
|
||||
expectedDriverEndAt: string | null
|
||||
departedAt: string | null
|
||||
completedAt: string | null
|
||||
stops: Array<{ name: string; address?: string | null; destinationKey?: string | null }>
|
||||
incompleteReasons: string[]
|
||||
}
|
||||
export interface TripCalendarResult { records: TripCalendarEntry[]; unresolvedRecords: TripCalendarEntry[] }
|
||||
export const tripIssueLabels: Record<string, string> = {
|
||||
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
|
||||
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
|
||||
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
|
||||
}
|
||||
export function tripIssues(row: TripCalendarEntry) {
|
||||
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
|
||||
}
|
||||
export function tripQueryWindow(range: { from: string; to: string }) {
|
||||
const start = Date.parse(`${range.from}T00:00:00Z`), end = Date.parse(`${range.to}T00:00:00Z`)
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end - start > 42 * 86400000) throw new Error('日历日期范围无效')
|
||||
// Query all possible location dates, then place each record using its actual origin zone.
|
||||
return { from: new Date(start - 14 * 3600000).toISOString(), to: new Date(end + 14 * 3600000).toISOString() }
|
||||
}
|
||||
export async function listTripCalendar(range: { from: string; to: string }, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<TripCalendarResult>, ApiEnvelope<TripCalendarResult>>(
|
||||
'/platform/v1/last-leg/fleet/trips/calendar', { params: tripQueryWindow(range), signal },
|
||||
))
|
||||
}
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>()
|
||||
function localStamp(value: string | null, zone: string | null) {
|
||||
if (!value || !/(Z|[+-]\d{2}:\d{2})$/.test(value) || !zone?.includes('/') || /^(Etc|SystemV)\//.test(zone)) return null
|
||||
const instant = new Date(value)
|
||||
if (!Number.isFinite(instant.getTime())) return null
|
||||
try {
|
||||
let formatter = formatters.get(zone)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
formatters.set(zone, formatter)
|
||||
}
|
||||
const parts = Object.fromEntries(formatter.formatToParts(instant).map(part => [part.type, part.value]))
|
||||
return { date: `${parts.year}-${parts.month}-${parts.day}`, time: `${parts.hour}:${parts.minute}` }
|
||||
} catch { return null }
|
||||
}
|
||||
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
|
||||
const events: CalendarEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||||
for (const row of result.records) {
|
||||
const planned = localStamp(row.plannedDepartureAt, row.originTimezone)
|
||||
if (row.originCountryCode !== 'US' || !planned) {
|
||||
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'LOCAL_DATE_UNRESOLVED'] }); continue
|
||||
}
|
||||
if (planned.date < range.from || planned.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'trip', date: planned.date, time: planned.time,
|
||||
title: `${row.tripNo} 用车计划`, subtitle: '运输计划,不代表收货方预约已确认',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
|
||||
{ label: '路线', value: row.stops.slice(0, 3).map(stop => stop.name).join(' → ') + (row.stops.length > 3 ? ` 等${row.stops.length}站` : '') },
|
||||
{ label: '实际出发', value: actual ? `${actual.date} ${actual.time}` : row.departedAt ? '时刻待核实' : '尚未记录' },
|
||||
{ label: '预计司机结束', value: driverEnd ? `${driverEnd.date} ${driverEnd.time}` : '待核实' },
|
||||
{ label: '资料提示', value: tripIssues(row) },
|
||||
].filter(field => field.value),
|
||||
})
|
||||
}
|
||||
return { events, unresolved }
|
||||
}
|
||||
@@ -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 () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
|
||||
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' }],
|
||||
records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
|
||||
page: '1', size: '200', total: '1', pages: '1',
|
||||
} })
|
||||
|
||||
|
||||
@@ -116,8 +116,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
|
||||
const wrapper = await render('READY_TO_START')
|
||||
expect(wrapper.text()).toContain('开始报关')
|
||||
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
|
||||
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
|
||||
expect(wrapper.text()).toContain('实际箱数 12')
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('客户退税资料')
|
||||
expect(wrapper.text()).toContain('报关行回执')
|
||||
|
||||
@@ -291,7 +291,7 @@ onMounted(load)
|
||||
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<header class="customs-header">
|
||||
<div><h2>整柜报关</h2><p>资料、回执与完税文件按柜管理。</p></div>
|
||||
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
|
||||
<div v-if="customs" class="customs-actions">
|
||||
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
|
||||
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
|
||||
@@ -307,13 +307,8 @@ onMounted(load)
|
||||
<template v-else-if="customs">
|
||||
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
||||
<template v-else>
|
||||
<dl class="density-fields customs-summary" aria-label="报关概况">
|
||||
<div><dt>实际箱数</dt><dd>{{ customs.actualBoxCount }}</dd></div><div><dt>退税总票</dt><dd>{{ customs.taxRefundCount }}</dd></div><div><dt>已覆盖</dt><dd>{{ customs.coveredTaxRefundCount }}</dd></div><div><dt>缺失</dt><dd :class="{ 'coverage-missing': missingCoverageCount }">{{ missingCoverageCount }}</dd></div>
|
||||
<template v-if="brokerSubmission">
|
||||
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
|
||||
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
|
||||
</template>
|
||||
</dl>
|
||||
<div class="customs-metrics"><span>实际箱数 <strong>{{ customs.actualBoxCount }}</strong></span><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
|
||||
<div v-if="brokerSubmission" class="broker-submission-fact" aria-label="首次发送报关行批次"><span>首次发送批次 <strong>{{ brokerSubmission.batchNo }}</strong></span><span>状态 <strong>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</strong></span><span>发送人 <strong>{{ brokerSubmission.sentByName || '—' }}</strong></span><span>发送时间 <strong>{{ formatDateTime(brokerSubmission.sentAt) }}</strong></span><span>资料版本 <strong>v{{ brokerSubmission.sourceVersion }}</strong></span></div>
|
||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||
<h3>缺退税资料清单</h3>
|
||||
<table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
|
||||
@@ -331,7 +326,7 @@ onMounted(load)
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
|
||||
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>查看申报批次及海关处理结果。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
|
||||
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>回执确认并发送报关行执行后建立正式申报批次;完税文件、船期和 PDA 节点保持独立。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
|
||||
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
|
||||
<div v-else class="declaration-batch-list">
|
||||
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
|
||||
@@ -363,5 +358,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
|
||||
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
|
||||
|
||||
const overview: ContainerOverview = {
|
||||
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 },
|
||||
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
|
||||
loadingPlanId: '9',
|
||||
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
|
||||
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
|
||||
|
||||
@@ -152,7 +152,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
|
||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
|
||||
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
||||
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div><div><dt>任务明细</dt><dd>{{ overview.pda.itemCount }}</dd></div><div><dt>已处理</dt><dd>{{ overview.pda.completedItemCount }}</dd></div><div><dt>未解决</dt><dd>{{ overview.pda.unresolvedItemCount }}</dd></div><div><dt>有效照片</dt><dd>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -169,9 +169,11 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
|
||||
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{color:var(--cc-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)!important}
|
||||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
|
||||
.overview-panel .pda-summary { grid-template-columns: minmax(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
|
||||
.pda-progress dd { width: 100%; }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
|
||||
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:var(--admin-space-5);margin-bottom:var(--admin-space-4)}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2); margin:0; }
|
||||
.metric-grid > div { display: flex; align-items: baseline; gap: var(--admin-space-2); padding: 0; background: transparent; }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.metric-grid strong { font-size: var(--admin-font-body); }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.
|
||||
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
|
||||
|
||||
const container = {
|
||||
id: '9001', cabinetNo: 'CAB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
|
||||
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
|
||||
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null,
|
||||
oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
|
||||
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null,
|
||||
|
||||
@@ -170,8 +170,8 @@ const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.va
|
||||
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
||||
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
|
||||
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
|
||||
? '核对作业证据、报关资料与装柜货物。'
|
||||
: '核对预装货物与 PDA 作业进度。')
|
||||
? '装柜明细、PDA 证据与报关关联均以当前柜次为边界。'
|
||||
: '核对预装明细与 PDA 作业准备情况,装柜完成后再处理整柜报关。')
|
||||
const overseasDestinationText = computed(() => {
|
||||
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
|
||||
.filter(Boolean).join(' / ')
|
||||
@@ -601,10 +601,6 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||||
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.loading-list-heading ~ .container-customs, .loading-list-heading ~ .loading-detail-content { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
|
||||
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:var(--admin-space-3); white-space:nowrap; }
|
||||
.container-detail-page { display:grid; min-width:0; gap:var(--admin-space-5); }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-4) var(--admin-space-5); }.container-overview__header h1 { margin:0; font-size:var(--admin-font-title); }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary { border-top:1px solid var(--cc-border); display:flex; flex-wrap:wrap; }.container-overview__summary>div { display:flex; min-width:0; padding:var(--admin-space-3) var(--admin-space-5); border-right:1px solid var(--cc-border); align-items:baseline; gap:var(--admin-space-2); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary strong,dd { margin:0; white-space:normal; overflow-wrap:anywhere; }.detail-section{display:grid;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-5) var(--admin-space-5)}.detail-section>h2{margin:0;font-size:var(--admin-font-section)}.detail-fields { padding:0; }.outbound-panel{padding:var(--admin-space-5) 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.outbound-panel__header h2{margin:0;font-size:var(--admin-font-section)}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.outbound-panel__loading{padding:var(--admin-space-3) 0}.gate-summary{display:flex;align-items:center;gap:var(--admin-space-5);margin-top:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:var(--admin-space-4);border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-2) var(--admin-space-5);margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:var(--admin-space-2) var(--admin-space-3);min-width:0;padding:var(--admin-space-2) var(--admin-space-3);border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal;overflow-wrap:anywhere}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:var(--admin-space-1)}@media(max-width:1200px){.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
||||
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); }
|
||||
|
||||
@@ -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(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, 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, 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', 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(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
|
||||
})
|
||||
|
||||
it('loads the real paged contract and renders core container fields', async () => {
|
||||
@@ -296,7 +296,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it('uses stable view labels when cabinetNo is missing', async () => {
|
||||
const base = {
|
||||
id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
|
||||
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
||||
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
|
||||
@@ -305,9 +305,9 @@ describe('ContainersView', () => {
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValueOnce({
|
||||
records: [
|
||||
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2' },
|
||||
{ ...base, id: '9003', cabinetNo: '', containerNo: null },
|
||||
{ ...base, id: '9004', cabinetNo: null, containerNo: null },
|
||||
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' },
|
||||
{ ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' },
|
||||
{ ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' },
|
||||
],
|
||||
page: 1, size: 20, total: 3, pages: 1,
|
||||
} as never)
|
||||
@@ -319,13 +319,13 @@ describe('ContainersView', () => {
|
||||
const labels = wrapper.findAll('.el-table__body button')
|
||||
.map((button) => button.attributes('aria-label'))
|
||||
.filter((label): label is string => Boolean(label))
|
||||
expect(labels).toEqual(['查看 MSCU-2', '查看货柜详情', '查看货柜详情'])
|
||||
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
|
||||
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
|
||||
})
|
||||
|
||||
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
|
||||
const base = {
|
||||
id: '9001', planId: '9101', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
|
||||
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
||||
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
|
||||
@@ -632,7 +632,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{
|
||||
id: '215', planId: null, cabinetNo: 'CAB-260825-0215', containerNo: null,
|
||||
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
|
||||
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING',
|
||||
capacityCbm: 86, maxWeightKg: 29000, version: 7,
|
||||
@@ -679,7 +679,7 @@ describe('ContainersView', () => {
|
||||
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
|
||||
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{
|
||||
id: '315', planId: null, cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
|
||||
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
|
||||
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING',
|
||||
capacityCbm: 86, maxWeightKg: 29000, version: 7,
|
||||
|
||||
@@ -313,7 +313,7 @@ function localDateTimeValue() {
|
||||
return now.toISOString().slice(0, 19)
|
||||
}
|
||||
function containerViewLabel(row: ContainerBookingResource) {
|
||||
const reference = [row.cabinetNo, row.containerNo]
|
||||
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
|
||||
.find((value) => typeof value === 'string' && value.trim())
|
||||
return reference ? `查看 ${reference}` : '查看货柜详情'
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ vi.mock('@/modules/warehouses', () => ({
|
||||
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) }))
|
||||
|
||||
const container = {
|
||||
id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船',
|
||||
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
||||
@@ -101,7 +101,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records.
|
||||
function containerPage(records: ContainerBookingResource[] = [container]) {
|
||||
return {
|
||||
records: records.map((item) => ({
|
||||
bookingId: item.id, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
|
||||
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
|
||||
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelGroupCode: item.channelGroupCode,
|
||||
channelCode: item.channelCode, channelName: item.channelName,
|
||||
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 }) }))
|
||||
|
||||
const container = {
|
||||
id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface ContainerBookingResource {
|
||||
id: string
|
||||
planId: string | null
|
||||
planNo?: string | null
|
||||
bookingNo: string
|
||||
containerNo: string | null
|
||||
cabinetNo: string
|
||||
sourceType?: 'SIMULATION' | 'OCEAN_BOOKING'
|
||||
@@ -174,7 +175,7 @@ export interface ContainerTimelineEntry {
|
||||
}
|
||||
|
||||
export interface ContainerOverview {
|
||||
container: Pick<ContainerBookingResource, 'id' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
|
||||
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
|
||||
loadingPlanId: string | null
|
||||
pda: ContainerPdaSummary | null
|
||||
documents: ContainerOverviewDocument[]
|
||||
@@ -372,6 +373,7 @@ export interface SmartLoadingCandidatePage {
|
||||
|
||||
export interface SmartLoadingWorkbenchContainer {
|
||||
bookingId: string
|
||||
bookingNo: string
|
||||
cabinetNo: string
|
||||
containerNo: string | null
|
||||
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 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 container = { id: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
|
||||
const container = { id: '40001', bookingNo: 'BK-001', 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
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
@@ -41,7 +41,7 @@ let disposed = false
|
||||
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: '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.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.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
|
||||
])
|
||||
|
||||
const columns = [
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# 海外业务原型核对矩阵
|
||||
|
||||
采购样品履约的当前操作与页面承载以[采购订单样品履约流程变更需求第 10.6 节](../../../../../docs/海外仓业务/采购订单样品履约流程变更需求.md#106-平台页面实施核对矩阵)为准。下表采购旧五分区和通用编辑仅保留原型定位;当前详情使用履约聚合数据,独立编辑只维护内部备注,节点写入使用各自权限和命令。
|
||||
|
||||
本土散板列表、本土卡车新增/编辑共用`../overseas-master-data/index.ts`公开的`TransportRouteDialog`。字段来源、原型证据和缺值处理唯一见[运输路线地图](../overseas-master-data/PROTOTYPE_MATRIX.md#运输路线地图)。按已有起终点规划,不写订单、不使用固定演示、不采集GPS。
|
||||
|
||||
## 池来源本土订单承运安排
|
||||
|
||||
依据:[内部拼车需求池首版开发契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md) 第7节及本轮用户授权。
|
||||
|
||||
@@ -4,19 +4,18 @@ const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn(), post: vi.fn()
|
||||
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: overseas }))
|
||||
|
||||
import {
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder,
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
|
||||
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
|
||||
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
|
||||
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase,
|
||||
updateUsTruckOrder,
|
||||
} from './platformOverseasBusiness'
|
||||
import type { UpdateLocalTransportOrderRequest } from '../types'
|
||||
import { readPendingProcurement } from '../procurementPending'
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||
const purchaseSummary = {
|
||||
id: '9007199254740993', orderNo: 'OP-1', customerId: '10001', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
|
||||
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
|
||||
purchaseWarehouseName: '采购仓', status: 'WAIT_PACKING', version: 4,
|
||||
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 0, inventoryQuantity: 5, status: 'PENDING_OUTBOUND', version: 4,
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const truckSummary = {
|
||||
@@ -35,29 +34,6 @@ const looseboardSummary = {
|
||||
describe('platform overseas business API adapter', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('normalizes wire integer strings before building replayable procurement commands', async () => {
|
||||
overseas.get.mockResolvedValue(envelope({
|
||||
customerView: { order: { ...purchaseSummary, version: '0', purchaseQuantity: '1' }, boxes: [{ quantity: '1' }], shipments: [{ quantity: '1' }], fees: [{ version: '2' }] },
|
||||
purchaseFacts: { purchased_quantity: '1' }, physical: { packingVersion: '3', dispositions: [{ id: '901', quantity: '1' }], receipts: [{ id: '902', goodQuantity: '1', damagedQuantity: '0', extraQuantity: '0' }], cartons: [{ id: '903', quantity: '1', sources: [{ receiptId: '902', quantity: '1' }] }] },
|
||||
fees: [{ id: '904', feeVersion: '2' }], controls: { notes: [], remaining: [{ quantity: '1' }], closures: [], closureItems: [{ quantity: '1' }] }, files: [{ id: '905', fileId: '9007199254740995', sizeBytes: '123' }],
|
||||
transport: { overseasCargo: [{ cartonId: '903', quantity: '1', sources: [{ receiptId: '902', quantity: '1' }] }], domesticCargo: [], overseasLabels: { id: '906', manifestId: '907', version: '4', labels: [{ cartonId: '903', fileId: '9007199254740995', page: '1' }] }, domesticLabels: null, shipments: [] },
|
||||
}))
|
||||
const detail = await getOverseasPurchase('OP-1', '10001')
|
||||
const payload = { version: detail.customerView.order.version, idempotencyKey: 'review-wire', decision: 'APPROVE', purchaseWarehouseId: '9007199254740995' }
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'review', payload }))).not.toThrow()
|
||||
expect(detail.customerView.order.purchaseQuantity).toBe(1)
|
||||
expect(detail.physical.receipts[0].goodQuantity + detail.physical.receipts[0].damagedQuantity).toBe(1)
|
||||
expect(detail.physical.packingVersion).toBe(3)
|
||||
expect(detail.physical.cartons[0].sources[0].quantity).toBe(1)
|
||||
expect(detail.transport.overseasCargo[0].quantity).toBe(1)
|
||||
expect(detail.transport.overseasLabels?.version).toBe(4)
|
||||
expect(detail.transport.overseasLabels?.labels[0].page).toBe(1)
|
||||
expect(detail.files[0]).toMatchObject({ fileId: '9007199254740995', sizeBytes: 123 })
|
||||
expect(detail.fees[0].feeVersion).toBe(2)
|
||||
expect(detail.controls.remaining[0].quantity).toBe(1)
|
||||
expect(detail.controls.closureItems[0].quantity).toBe(1)
|
||||
})
|
||||
|
||||
it('loads procurement candidates and normalizes procurement pages', async () => {
|
||||
overseas.get
|
||||
.mockResolvedValueOnce(envelope([{ customerId: '10001', customerName: '客户一' }]))
|
||||
@@ -72,24 +48,23 @@ describe('platform overseas business API adapter', () => {
|
||||
})
|
||||
|
||||
it('loads and patches procurement detail with three-state null payloads', async () => {
|
||||
const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] }
|
||||
overseas.get.mockResolvedValue(envelope({ customerView: { order: purchaseSummary, boxes: [], shipments: [], fees: [] }, reviewHistory: detail.histories, physical: { dispositions: [], receipts: [], cartons: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] } }))
|
||||
expect((await getOverseasPurchase('OP/1', '10001')).reviewHistory[0]?.operationType).toBe('UPDATE')
|
||||
const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] }
|
||||
overseas.get.mockResolvedValue(envelope(detail))
|
||||
expect((await getOverseasPurchase('OP/1', '10001')).histories[0]?.operationType).toBe('UPDATE')
|
||||
overseas.patch.mockResolvedValue(envelope(detail))
|
||||
const payload = { version: 4, remark: null }
|
||||
const payload = { version: 4, productUrl: null, estimatedArrivalAt: null }
|
||||
await updateOverseasPurchase('OP-1', '10001', payload)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: '10001' } })
|
||||
})
|
||||
|
||||
it('reads the aggregate editor context and strips obsolete mutation fields', async () => {
|
||||
const detail = { customerView: { order: { orderNo: 'OP/1', customerId: '10001', productName: '外套', purchaseQuantity: 10, version: 4 } }, internalRemark: '内部' }
|
||||
overseas.get.mockResolvedValue(envelope(detail))
|
||||
expect(await getOverseasPurchaseEditor('OP/1', '10001')).toEqual(detail)
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: '10001' } })
|
||||
overseas.patch.mockResolvedValue(envelope({ summary: purchaseSummary }))
|
||||
const obsolete = { version: 4, remark: '内部', inboundQuantity: 200, purchaseWarehouseId: 'other', productName: 'changed' }
|
||||
await updateOverseasPurchase('OP/1', '10001', obsolete)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { version: 4, remark: '内部' }, { params: { customerId: '10001' } })
|
||||
it('submits procurement review with version and idempotency data', async () => {
|
||||
const detail = { summary: { ...purchaseSummary, status: 'APPROVED', version: 5 }, histories: [] }
|
||||
overseas.post.mockResolvedValue(envelope(detail))
|
||||
const payload = { version: 4, decision: 'APPROVE' as const, idempotencyKey: 'review-key', remark: '资料齐全' }
|
||||
const result = await reviewOverseasPurchase('OP/1', '10001', payload)
|
||||
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/review', payload, { params: { customerId: '10001' } })
|
||||
expect(result.status).toBe('APPROVED')
|
||||
expect(result.version).toBe(5)
|
||||
})
|
||||
|
||||
it('supports platform truck list/detail/create/update without status endpoints', async () => {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
import type {
|
||||
CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardDetail, LocalLooseboardOrder,
|
||||
LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery,
|
||||
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption,
|
||||
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
|
||||
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
|
||||
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest,
|
||||
OverseasPurchaseEditorDetail,
|
||||
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, OverseasPurchaseOutboundDetail,
|
||||
OverseasPurchaseFile, OverseasPurchaseFee, ReviewOverseasPurchaseRequest,
|
||||
} from '../types'
|
||||
|
||||
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
|
||||
@@ -16,7 +15,13 @@ const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders'
|
||||
type Numeric = number | string
|
||||
interface PagePayload<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null }
|
||||
interface ProcurementDetailPayload { summary: ProcurementSummaryPayload }
|
||||
interface ProcurementDetailPayload {
|
||||
summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null
|
||||
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
|
||||
histories?: OverseasBusinessHistory[]; outboundDetails?: OverseasPurchaseOutboundDetail[]; files?: any[]; fees?: any[]
|
||||
}
|
||||
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
|
||||
interface TruckDetailPayload {
|
||||
@@ -32,12 +37,11 @@ interface LooseboardDetailPayload {
|
||||
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
|
||||
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
|
||||
}
|
||||
function procurementInteger(value: unknown): number {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('采购数据中的数量或版本无效,请刷新后重试')
|
||||
return Number(value)
|
||||
}
|
||||
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
|
||||
return { ...value, version: procurementInteger(value.version), purchaseQuantity: procurementInteger(value.purchaseQuantity), id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
}
|
||||
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [], outboundDetails: (value.outboundDetails || []).map((item) => ({ ...item, id: String(item.id), quantity: Number(item.quantity) })), files: (value.files || []).map((item) => ({ ...item, id: String(item.id), fileId: String(item.fileId), sizeBytes: Number(item.sizeBytes) })), fees: (value.fees || []).map((item) => ({ ...item, id: String(item.id), voucherFileId: String(item.voucherFileId), amount: String(item.amount) })) }
|
||||
}
|
||||
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
@@ -65,54 +69,25 @@ export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
|
||||
return normalizePage(unwrapApiResponse(response), procurementSummary)
|
||||
}
|
||||
export async function getOverseasPurchase(orderNo: string, customerId: string) {
|
||||
const response = await http.get<ApiEnvelope<PlatformProcurement>, ApiEnvelope<PlatformProcurement>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
const detail = unwrapApiResponse(response)
|
||||
detail.customerView.order.version = procurementInteger(detail.customerView.order.version)
|
||||
detail.customerView.order.purchaseQuantity = procurementInteger(detail.customerView.order.purchaseQuantity)
|
||||
for (const row of [...detail.customerView.boxes, ...detail.customerView.shipments]) row.quantity = procurementInteger(row.quantity)
|
||||
for (const fee of detail.customerView.fees) if (fee.version != null) fee.version = procurementInteger(fee.version)
|
||||
if (detail.purchaseFacts?.purchased_quantity != null) detail.purchaseFacts.purchased_quantity = procurementInteger(detail.purchaseFacts.purchased_quantity)
|
||||
if (detail.physical.packingVersion != null) detail.physical.packingVersion = procurementInteger(detail.physical.packingVersion)
|
||||
for (const row of detail.physical.receipts) {
|
||||
row.goodQuantity = procurementInteger(row.goodQuantity); row.damagedQuantity = procurementInteger(row.damagedQuantity); row.extraQuantity = procurementInteger(row.extraQuantity)
|
||||
}
|
||||
for (const row of [...detail.physical.dispositions, ...detail.controls.remaining, ...detail.controls.closureItems]) row.quantity = procurementInteger(row.quantity)
|
||||
for (const row of [...detail.physical.cartons, ...detail.transport.overseasCargo, ...detail.transport.domesticCargo]) {
|
||||
row.quantity = procurementInteger(row.quantity)
|
||||
for (const source of row.sources) source.quantity = procurementInteger(source.quantity)
|
||||
}
|
||||
for (const fee of detail.fees) fee.feeVersion = procurementInteger(fee.feeVersion)
|
||||
for (const file of detail.files) file.sizeBytes = procurementInteger(file.sizeBytes)
|
||||
if (detail.purchaseFacts?.actual_warehouse_id != null) detail.purchaseFacts.actual_warehouse_id = String(detail.purchaseFacts.actual_warehouse_id)
|
||||
detail.physical.dispositions = detail.physical.dispositions.map(row => ({ ...row, id: String(row.id), source_carton_id: row.source_carton_id == null ? null : String(row.source_carton_id), receipt_id: row.receipt_id == null ? null : String(row.receipt_id), voucher_file_id: String(row.voucher_file_id) }))
|
||||
detail.physical.receipts = detail.physical.receipts.map(row => ({ ...row, id: String(row.id), sourceCartonId: row.sourceCartonId == null ? null : String(row.sourceCartonId) }))
|
||||
detail.physical.cartons = detail.physical.cartons.map(row => ({ ...row, id: String(row.id), sources: row.sources.map(source => ({ ...source, receiptId: String(source.receiptId) })) }))
|
||||
detail.fees = detail.fees.map(fee => ({ ...fee, id: String(fee.id), parentFeeId: fee.parentFeeId == null ? null : String(fee.parentFeeId), sourceFeeId: fee.sourceFeeId == null ? null : String(fee.sourceFeeId), voucherFileId: fee.voucherFileId == null ? null : String(fee.voucherFileId), walletLedgerId: fee.walletLedgerId == null ? null : String(fee.walletLedgerId) }))
|
||||
detail.transport.shipments = detail.transport.shipments.map(row => ({ ...row, id: String(row.id) }))
|
||||
detail.controls.notes = detail.controls.notes.map(row => ({ ...row, id: String(row.id), target_id: row.target_id == null ? null : String(row.target_id), voucher_file_id: String(row.voucher_file_id), resolution_voucher_id: row.resolution_voucher_id == null ? null : String(row.resolution_voucher_id) }))
|
||||
detail.controls.remaining = detail.controls.remaining.map(row => ({ ...row, sourceId: row.sourceId == null ? null : String(row.sourceId) }))
|
||||
detail.controls.closures = detail.controls.closures.map(row => ({ ...row, id: String(row.id) }))
|
||||
detail.controls.closureItems = detail.controls.closureItems.map(row => ({ ...row, id: String(row.id), closure_id: String(row.closure_id), source_id: row.source_id == null ? null : String(row.source_id), voucher_file_id: String(row.voucher_file_id) }))
|
||||
detail.files = detail.files.map(file => ({ ...file, id: String(file.id), fileId: String(file.fileId) }))
|
||||
for (const leg of ['overseasCargo', 'domesticCargo'] as const) detail.transport[leg] = detail.transport[leg].map(row => ({ ...row, cartonId: String(row.cartonId), packagingRecordId: row.packagingRecordId == null ? null : String(row.packagingRecordId), sources: row.sources.map(source => ({ ...source, receiptId: String(source.receiptId) })) }))
|
||||
for (const labels of [detail.transport.overseasLabels, detail.transport.domesticLabels]) if (labels) {
|
||||
labels.version = procurementInteger(labels.version)
|
||||
for (const label of labels.labels) label.page = procurementInteger(label.page)
|
||||
labels.id = String(labels.id); labels.manifestId = String(labels.manifestId)
|
||||
labels.labels = labels.labels.map(row => ({ ...row, cartonId: String(row.cartonId), fileId: String(row.fileId) }))
|
||||
}
|
||||
return detail
|
||||
}
|
||||
export async function getOverseasPurchaseEditor(orderNo: string, customerId: string) {
|
||||
const response = await http.get<ApiEnvelope<OverseasPurchaseEditorDetail>, ApiEnvelope<OverseasPurchaseEditorDetail>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
const detail = unwrapApiResponse(response)
|
||||
detail.customerView.order.version = procurementInteger(detail.customerView.order.version)
|
||||
detail.customerView.order.purchaseQuantity = procurementInteger(detail.customerView.order.purchaseQuantity)
|
||||
return detail
|
||||
const response = await http.get<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) {
|
||||
const response = await http.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { version: payload.version, remark: payload.remark }, { params: { customerId } })
|
||||
return procurementSummary(unwrapApiResponse(response).summary)
|
||||
const response = await http.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function reviewOverseasPurchase(orderNo: string, customerId: string, payload: ReviewOverseasPurchaseRequest) {
|
||||
const response = await http.post<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function uploadOverseasPurchaseFile(orderNo: string, customerId: string, category: 'RECEIPT' | 'VOUCHER', file: File) {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<OverseasPurchaseFile>, ApiEnvelope<OverseasPurchaseFile>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createOverseasPurchaseFee(orderNo: string, customerId: string, payload: { feeItemCode: string; feeItemName: string; amount: number; voucherFileId: string; remark?: string }) {
|
||||
const response = await http.post<ApiEnvelope<OverseasPurchaseFee>, ApiEnvelope<OverseasPurchaseFee>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: api }))
|
||||
import { submitProcurementCommand, uploadProcurementFile, downloadProcurementFile, type ProcurementCommand } from './procurementCommands'
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
describe('procurement node HTTP contract', () => {
|
||||
it.each<ProcurementCommand>(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'control-notes', 'actual-warehouse/correct', 'exception-close'])('sends %s to the scoped order endpoint without interpreting money success', async command => {
|
||||
api.post.mockResolvedValue(envelope({ status: 'WAIT_OVERSEAS_RECEIPT', purchase: { walletStatus: 'FREEZE_PENDING' } }))
|
||||
const payload = { version: 3, idempotencyKey: 'same-command', reason: '已核对', amount: '9007199254740993.01' }
|
||||
await submitProcurementCommand('OP/1', '10001', command, payload)
|
||||
expect(api.post).toHaveBeenCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/${command}`, payload, { params: { customerId: '10001' } })
|
||||
})
|
||||
it('posts additional expenses without a fee route id or inherited correction fields', async () => {
|
||||
api.post.mockResolvedValue(envelope({}))
|
||||
const payload = { version: 3, idempotencyKey: 'additional', reason: 'invoice', name: 'Storage', amount: '3.25', currency: 'USD' as const, businessDate: '2020-01-03', voucherFileId: '501' }
|
||||
await submitProcurementCommand('OP/1', '10001', 'fees/additional', { ...payload, feeId: '11', targetOriginalAmount: '99' })
|
||||
expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/additional', payload, { params: { customerId: '10001' } })
|
||||
})
|
||||
it('resolves the exact order-owned blocker and does not send the internal route reference as a mutable field', async () => {
|
||||
api.post.mockResolvedValue(envelope({}))
|
||||
await submitProcurementCommand('OP/1', '10001', 'control-notes/resolve', { version: 3, idempotencyKey: 'resolve-one', reason: '核对完成', noteId: '71', voucherFileId: '501' })
|
||||
expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/control-notes/71/resolve', { version: 3, idempotencyKey: 'resolve-one', reason: '核对完成', voucherFileId: '501' }, { params: { customerId: '10001' } })
|
||||
})
|
||||
it('uses the exact fee endpoint and sends only fields belonging to that financial operation', async () => {
|
||||
api.post.mockResolvedValue(envelope({}))
|
||||
const base = { version: 3, idempotencyKey: 'finance-one', reason: '核对', feeId: '11' }
|
||||
await submitProcurementCommand('OP/1', '10001', 'fees/correct-target', { ...base, targetOriginalAmount: '12.00', voucherFileId: '501', amount: 'unrelated' })
|
||||
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/correct-target', { version: 3, idempotencyKey: 'finance-one', reason: '核对', targetOriginalAmount: '12.00', voucherFileId: '501' }, { params: { customerId: '10001' } })
|
||||
await submitProcurementCommand('OP/1', '10001', 'fees/replace-failed', { ...base, targetOriginalAmount: '12.00', voucherFileId: '501' })
|
||||
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/replace-failed', { version: 3, idempotencyKey: 'finance-one', reason: '核对', targetOriginalAmount: '12.00', voucherFileId: '501' }, { params: { customerId: '10001' } })
|
||||
await submitProcurementCommand('OP/1', '10001', 'fees/refund', { ...base, amount: '1.01', voucherFileId: '501' })
|
||||
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/refund', { version: 3, idempotencyKey: 'finance-one', reason: '核对', amount: '1.01', voucherFileId: '501' }, { params: { customerId: '10001' } })
|
||||
for (const command of ['fees/retry', 'fees/terminate', 'fees/release-estimate'] as const) {
|
||||
await submitProcurementCommand('OP/1', '10001', command, base)
|
||||
expect(api.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/overseas-procurements/OP%2F1/fees/11/${command.slice(5)}`, { version: 3, idempotencyKey: 'finance-one', reason: '核对' }, { params: { customerId: '10001' } })
|
||||
}
|
||||
})
|
||||
it('targets an original receipt and nests only the receipt contract plus correction evidence', async () => {
|
||||
api.post.mockResolvedValue(envelope({}))
|
||||
const receipt = { version: 3, idempotencyKey: 'correct-one', reason: '实收复核', leg: 'OVERSEAS' as const, warehouseId: '20', goodQuantity: 5, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' }
|
||||
await submitProcurementCommand('OP/1', '10001', 'receipts/correct', { ...receipt, receiptId: '11', voucherFileId: '901' })
|
||||
expect(api.post).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/receipts/11/correct', { receipt: { ...receipt, sourceCartonId: undefined }, voucherFileId: '901' }, { params: { customerId: '10001' } })
|
||||
await submitProcurementCommand('OP/1', '10001', 'dispositions/revoke', { version: 3, idempotencyKey: 'revoke-one', reason: '撤销错误处置', dispositionId: '71', voucherFileId: '901' })
|
||||
expect(api.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/dispositions/71/revoke', { version: 3, idempotencyKey: 'revoke-one', reason: '撤销错误处置', voucherFileId: '901' }, { params: { customerId: '10001' } })
|
||||
})
|
||||
it('uses purchase-purpose multipart evidence and normalizes file references', async () => {
|
||||
api.post.mockResolvedValue(envelope({ id: 11, fileId: '9007199254740993', category: 'PURCHASE', fileName: 'purchase.pdf' }))
|
||||
const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' })
|
||||
expect(await uploadProcurementFile('OP/1', '10001', 'PURCHASE', file)).toMatchObject({ id: '11', fileId: '9007199254740993' })
|
||||
const call = api.post.mock.calls[0]; expect(call[0]).toBe('/platform/v1/last-leg/overseas-procurements/OP%2F1/files/PURCHASE'); expect(call[1].get('file')).toBe(file); expect(call[2]).toEqual({ params: { customerId: '10001' } })
|
||||
})
|
||||
it('downloads binary files through the authenticated order boundary', async () => {
|
||||
const blob = new Blob(['pdf'], { type: 'application/pdf' }); api.get.mockResolvedValue(blob)
|
||||
expect(await downloadProcurementFile('OP/1', '10001', '901')).toBe(blob)
|
||||
expect(api.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1/files/901/content', { params: { customerId: '10001' }, responseType: 'blob' })
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind } from '../procurementTypes'
|
||||
|
||||
export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close'
|
||||
export interface ProcurementCommandBody {
|
||||
name?: string
|
||||
targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string
|
||||
resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string
|
||||
feeId?: string; targetOriginalAmount?: string; internationalFreight?: ProcurementFeeSelection; duty?: ProcurementFeeSelection; domesticFreight?: ProcurementFeeSelection
|
||||
otherDetermination?: 'NOT_APPLICABLE' | 'ITEMIZED'; otherFees?: { code: string; name: string; selection: ProcurementFeeSelection }[]
|
||||
version: number; idempotencyKey: string; reason?: string; decision?: 'APPROVE' | 'REJECT' | 'ACCEPT_SHORTAGE' | 'DISPOSE_DAMAGED' | 'RETURN_EXTRA'; remark?: string
|
||||
purchaseWarehouseId?: string; warehouseId?: string; productName?: string; specification?: string
|
||||
leg?: 'OVERSEAS' | 'DOMESTIC'; sourceCartonId?: string; receiptId?: string; dispositionId?: string
|
||||
goodQuantity?: number; damagedQuantity?: number; extraQuantity?: number; receivedAt?: string
|
||||
labelVersion?: number; receiver?: { name: string; phone: string; address: string }; addressVoucherFileId?: string
|
||||
labels?: { cartonId: string; fileId: string; page: number; carrier: string; trackingNo: string }[]
|
||||
cartonIds?: string[]; businessDate?: string; appliedAt?: string; shippedAt?: string; cartonId?: string
|
||||
lengthCm?: string; widthCm?: string; heightCm?: string; grossWeightKg?: string
|
||||
kind?: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; packingVersion?: number; cartons?: ProcurementPackingCarton[]
|
||||
quantity?: number; amount?: string; currency?: 'CNY' | 'USD'; purchasedAt?: string; estimatedArrivalAt?: string; voucherFileId?: string
|
||||
}
|
||||
export interface ProcurementResolution { kind: ProcurementRemainingKind; sourceId: string | null; quantity: number; decision: string; destination: string; voucherFileId: string }
|
||||
export interface ProcurementChargeAmount { determination: 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId?: string; reason: string }
|
||||
export interface ProcurementFeeSelection { feeId?: string; value?: ProcurementChargeAmount }
|
||||
export interface ProcurementPackingCarton { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number }[] }
|
||||
const base = '/platform/v1/last-leg/overseas-procurements'
|
||||
export async function submitProcurementCommand(orderNo: string, customerId: string, command: ProcurementCommand, payload: ProcurementCommandBody) {
|
||||
let path: string = command; let body: unknown = payload
|
||||
if (command === 'receipts/correct') {
|
||||
if (!payload.receiptId || !/^[1-9]\d*$/.test(payload.receiptId)) throw new Error('缺少有效实收来源')
|
||||
path = `receipts/${payload.receiptId}/correct`
|
||||
body = { receipt: { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, leg: payload.leg, warehouseId: payload.warehouseId, sourceCartonId: payload.sourceCartonId, goodQuantity: payload.goodQuantity, damagedQuantity: payload.damagedQuantity, extraQuantity: payload.extraQuantity, receivedAt: payload.receivedAt }, voucherFileId: payload.voucherFileId }
|
||||
} else if (command === 'dispositions/revoke') {
|
||||
if (!payload.dispositionId || !/^[1-9]\d*$/.test(payload.dispositionId)) throw new Error('缺少有效差异处置来源')
|
||||
path = `dispositions/${payload.dispositionId}/revoke`
|
||||
body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, voucherFileId: payload.voucherFileId }
|
||||
}
|
||||
if (command === 'control-notes/resolve') {
|
||||
if (!payload.noteId || !/^[1-9]\d*$/.test(payload.noteId)) throw new Error('缺少有效阻断异常来源')
|
||||
path = `control-notes/${payload.noteId}/resolve`
|
||||
body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason, voucherFileId: payload.voucherFileId }
|
||||
}
|
||||
if (command === 'fees/additional') body = { version: payload.version, idempotencyKey: payload.idempotencyKey, name: payload.name, amount: payload.amount, currency: payload.currency, businessDate: payload.businessDate, voucherFileId: payload.voucherFileId, reason: payload.reason }
|
||||
if (command.startsWith('fees/') && command !== 'fees/additional') {
|
||||
if (!payload.feeId || !/^[1-9]\d*$/.test(payload.feeId)) throw new Error('缺少有效费用来源')
|
||||
path = `fees/${payload.feeId}/${command.slice(5)}`
|
||||
body = { version: payload.version, idempotencyKey: payload.idempotencyKey, reason: payload.reason,
|
||||
...(['fees/correct-target', 'fees/replace-failed'].includes(command) ? { targetOriginalAmount: payload.targetOriginalAmount, voucherFileId: payload.voucherFileId } : {}),
|
||||
...(command === 'fees/refund' ? { amount: payload.amount, voucherFileId: payload.voucherFileId } : {}) }
|
||||
}
|
||||
const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${base}/${encodeURIComponent(orderNo)}/${path}`, body, { params: { customerId } })
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
export async function uploadProcurementFile(orderNo: string, customerId: string, category: ProcurementFileCategory, file: File) {
|
||||
const data = new FormData(); data.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<ProcurementFile>, ApiEnvelope<ProcurementFile>>(`${base}/${encodeURIComponent(orderNo)}/files/${category}`, data, { params: { customerId } })
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, id: String(result.id), fileId: String(result.fileId) }
|
||||
}
|
||||
export async function downloadProcurementFile(orderNo: string, customerId: string, fileId: string) {
|
||||
return http.get<Blob, Blob>(`${base}/${encodeURIComponent(orderNo)}/files/${encodeURIComponent(fileId)}/content`, { params: { customerId }, responseType: 'blob' })
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
|
||||
import type { ChargeDraft } from '../procurementFinanceForm'
|
||||
import { money } from '../procurementPresentation'
|
||||
const props = defineProps<{ detail: PlatformProcurement; files: ProcurementFile[] }>()
|
||||
const form = defineModel<ChargeDraft>({ required: true })
|
||||
const existing = computed(() => props.detail.fees.filter(row => row.active && row.purpose === form.value.purpose && (row.purpose !== 'OTHER' || row.feeItemCode === form.value.code)))
|
||||
const selected = computed(() => existing.value.find(row => row.id === form.value.feeId))
|
||||
function determine() { form.value.amount = form.value.determination === 'POSITIVE' ? '' : form.value.determination ? '0.00' : '' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="admin-density charge-fields">
|
||||
<template v-if="form.mode === 'EXISTING'"><el-form-item label="引用已核定版本" required><el-select v-model="form.feeId" placeholder="选择本单该用途的有效费用"><el-option v-for="row in existing" :key="row.id" :value="row.id" :label="`${row.feeNo} · ${money(row.originalAmount, row.originalCurrency)}`" /></el-select></el-form-item><dl v-if="selected" class="density-fields"><div><dt>原费用版本</dt><dd>{{ selected.feeVersion }}</dd></div><div><dt>原业务日期</dt><dd>{{ selected.businessDate }}</dd></div></dl><p>本次仅核对并引用原费用,不重复扣款。金额更正请使用财务调整。</p></template>
|
||||
<el-alert v-else-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本项缺少有效版本,请先由财务完成替代核定。" type="warning" :closable="false" />
|
||||
<template v-else>
|
||||
<div class="charge-grid"><el-form-item label="核定结果" required><el-select v-model="form.determination" placeholder="明确正额、零元或不适用" @change="determine"><el-option value="POSITIVE" label="确认正额" /><el-option value="ZERO" label="确认零元" /><el-option value="NOT_APPLICABLE" label="不适用" /></el-select></el-form-item><el-form-item label="原币金额" required><el-input v-model="form.amount" :readonly="form.determination === 'ZERO' || form.determination === 'NOT_APPLICABLE'" placeholder="填写实际金额,最多两位小数" /></el-form-item><el-form-item label="币种" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
|
||||
<el-form-item label="本项费用凭证" :required="form.determination === 'POSITIVE'"><el-select v-model="form.voucherFileId" clearable placeholder="选择已上传的本单费用凭证"><el-option v-for="file in files.filter(row => row.category === 'VOUCHER')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
|
||||
<el-form-item label="本项核定说明" required><el-input v-model="form.reason" maxlength="500" placeholder="说明计费依据;零元或不适用也需说明" /></el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.charge-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.charge-fields p { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
@media (max-width: 760px) { .charge-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import type { ProcurementCommand } from '../api/procurementCommands'
|
||||
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
|
||||
import { allowedDecisions, decisionLabels, remainingLabels, type ControlForm } from '../procurementControlForm'
|
||||
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
|
||||
const form = defineModel<ControlForm>({ required: true })
|
||||
const proofs = computed(() => props.files.filter(file => file.category === 'EXCEPTION'))
|
||||
const blockers = computed(() => props.detail.controls.notes.filter(note => note.blocking && !note.resolved_at))
|
||||
</script>
|
||||
<template>
|
||||
<div class="admin-density">
|
||||
<template v-if="command === 'control-notes'">
|
||||
<el-alert title="追加说明保留原购买、仓库、实收及交寄事实。需要阻断时,正式装箱、预估核对、核费及交寄须先处理异常。" type="info" :closable="false" />
|
||||
<el-form-item label="关联事实类型" required><el-select v-model="form.targetType" placeholder="选择需要留痕的事实" @change="form.targetId = ''"><el-option value="PURCHASE" label="真实购买" /><el-option value="WAREHOUSE" label="实际收货仓" /><el-option value="RECEIPT" label="实收记录" /><el-option value="SHIPMENT" label="实际交寄记录" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.targetType === 'RECEIPT'" label="关联实收" required><el-select v-model="form.targetId" placeholder="选择本单实收记录"><el-option v-for="row in detail.physical.receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${row.active ? '' : '(已更正)'}`" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.targetType === 'SHIPMENT'" label="关联实际交寄" required><el-select v-model="form.targetId" placeholder="选择本单实际交寄记录"><el-option v-for="row in detail.transport.shipments" :key="row.id" :value="row.id" :label="`${row.shipmentNo} · ${row.leg === 'OVERSEAS' ? '国际段' : '寄客户段'}`" /></el-select></el-form-item>
|
||||
<dl v-if="form.targetType === 'WAREHOUSE'" class="density-fields"><div><dt>当前实际仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚无实际仓' }}</dd></div></dl>
|
||||
<el-form-item label="是否阻断后续正式作业" required><el-radio-group v-model="form.blocking"><el-radio :value="true">需要阻断</el-radio><el-radio :value="false">仅追加说明</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="对客摘要(客户可见,选填)"><el-input v-model="form.clientSummary" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="仅填写允许客户看到的处理摘要" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="command === 'control-notes/resolve'"><el-alert title="解除阻断须填写处理依据并关联本单凭证,原说明与处理记录均保留。" type="info" :closable="false" /><el-form-item label="待解除阻断" required><el-select v-model="form.noteId" placeholder="选择本单未处理的阻断异常"><el-option v-for="note in blockers" :key="note.id" :value="note.id" :label="`${note.note_no} · ${note.reason.slice(0, 60)}`" /></el-select></el-form-item></template>
|
||||
<template v-if="command === 'actual-warehouse/correct'"><el-alert title="已有箱明细、交寄记录或订单已结束时,本操作仅追加更正说明和阻断,不重新归属货物。是否修改有效仓以服务端历史记录为准。" type="warning" :closable="false" /><dl class="density-fields"><div><dt>原实际收货仓</dt><dd>{{ detail.customerView.order.actualWarehouseName || '尚未记录' }}</dd></div></dl></template>
|
||||
<template v-if="command === 'exception-close'">
|
||||
<el-alert title="确认全部实物去向和资金处理后关闭。此操作不自动退款、不生成退货物流,不覆盖原履约事实。" type="warning" :closable="false" />
|
||||
<section v-for="row in form.resolutions" :key="`${row.kind}:${row.sourceId || ''}`" class="resolution-row"><h3>{{ remainingLabels[row.kind] }} · {{ row.businessNo || '—' }} · {{ row.quantity }} 件</h3><div class="resolution-grid"><el-form-item label="实际处置" required><el-select v-model="row.decision" placeholder="明确实际处置结果"><el-option v-for="value in allowedDecisions[row.kind]" :key="value" :value="value" :label="decisionLabels[value]" /></el-select></el-form-item><el-form-item label="本项处置凭证" required><el-select v-model="row.voucherFileId" placeholder="选择本单对应凭证"><el-option v-for="file in proofs" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item></div><el-form-item label="实际去向及接收方" required><el-input v-model="row.destination" maxlength="500" placeholder="填写货物实际去向、接收方或确认丢失的依据" /></el-form-item></section>
|
||||
<p v-if="!form.resolutions.length">服务端当前无待处置的剩余货物,仍需核对客户停止履约凭证和资金处理结论。</p>
|
||||
<el-form-item label="客户同意停止履约凭证" required><el-select v-model="form.customerVoucherFileId" placeholder="选择本单客户确认凭证"><el-option v-for="file in proofs" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
|
||||
<el-form-item label="财务处理结论(仅平台可见)" required><el-input v-model="form.financialConclusion" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="说明扣款、调整、退款及冻结释放的最终处理结果" /></el-form-item>
|
||||
<el-form-item label="对客关闭摘要(客户可见)" required><el-input v-model="form.clientSummary" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="填写允许客户看到的最终处理结论" /></el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.resolution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }
|
||||
.resolution-row { padding-block: var(--admin-space-3); border-bottom: 1px solid var(--cc-border); }
|
||||
.resolution-row h3 { font-size: var(--admin-font-section); }
|
||||
:deep(.el-select) { width: 100%; }
|
||||
@media (max-width: 760px) { .resolution-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, watch } from 'vue'
|
||||
import type { ProcurementCommand } from '../api/procurementCommands'
|
||||
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
|
||||
import { chargeDraft, feeRetryPermission, refundAvailable, targetPreview, replacementCandidates, replacementPreview, type FinanceForm } from '../procurementFinanceForm'
|
||||
import { money, decimal, feePurposes } from '../procurementPresentation'
|
||||
import { commandKey } from '../procurementPending'
|
||||
import ProcurementChargeFields from './ProcurementChargeFields.vue'
|
||||
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[]; can: (permission: string) => boolean }>()
|
||||
const form = defineModel<FinanceForm>({ required: true })
|
||||
const candidates = computed(() => props.detail.fees.filter(fee => {
|
||||
if (props.command === 'fees/replace-failed') return replacementCandidates(props.detail).some(row => row.id === fee.id)
|
||||
if (props.command === 'fees/retry') return fee.active && fee.walletStatus.endsWith('_FAILED') && props.can(feeRetryPermission(fee))
|
||||
if (props.command === 'fees/terminate') return fee.active && fee.walletStatus.endsWith('_FAILED')
|
||||
if (props.command === 'fees/release-estimate') return fee.active && fee.purpose === 'INTERNATIONAL_ESTIMATE' && ['FROZEN', 'ZERO'].includes(fee.walletStatus)
|
||||
if (props.command === 'fees/correct-target') return fee.active && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
|
||||
return fee.walletStatus === 'SETTLED' && !!fee.walletLedgerId && fee.purpose !== 'INTERNATIONAL_ESTIMATE'
|
||||
}))
|
||||
watch(candidates, rows => { if (form.value.feeId && !rows.some(row => row.id === form.value.feeId)) form.value.feeId = '' })
|
||||
const selected = computed(() => props.detail.fees.find(row => row.id === form.value.feeId))
|
||||
const preview = computed(() => {
|
||||
if (!selected.value) return null
|
||||
try {
|
||||
if (props.command === 'fees/replace-failed' && form.value.targetOriginalAmount) return { ...replacementPreview(props.detail, selected.value, form.value.targetOriginalAmount), error: '' }
|
||||
if (props.command === 'fees/correct-target' && form.value.targetOriginalAmount) return { ...targetPreview(props.detail, selected.value, form.value.targetOriginalAmount), error: '' }
|
||||
if (props.command === 'fees/refund') return { refundable: decimal(refundAvailable(props.detail, selected.value)), error: '' }
|
||||
} catch (error) { return { error: error instanceof Error ? error.message : '请核对原费用' } }
|
||||
return null
|
||||
})
|
||||
function addOther() { form.value.otherDetermination = 'ITEMIZED'; const row = chargeDraft('OTHER', undefined, `OTHER_${commandKey().slice(0, 20).toUpperCase()}`); row.name = ''; form.value.others.push(row) }
|
||||
</script>
|
||||
<template>
|
||||
<div class="admin-density">
|
||||
<template v-if="command === 'charges/confirm'">
|
||||
<el-alert v-if="detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" title="本单已完成过核费,本次只引用已有费用版本;新增费用或重新核定请使用财务操作。" type="info" :closable="false" />
|
||||
<el-alert title="一次确认实际国际运费、关税、国内运费和其他费用。正额确认后发起资金处理;确认成功不代表已扣款或可交寄。" type="info" :closable="false" />
|
||||
<section v-for="(row, index) in form.fixed" :key="row.purpose" class="charge-section"><h3>{{ row.name }}</h3><ProcurementChargeFields v-model="form.fixed[index]" :detail="detail" :files="files" /></section>
|
||||
<section class="charge-section"><h3>其他费用</h3><el-form-item label="其他费用适用情况" required><el-radio-group v-model="form.otherDetermination"><el-radio value="NOT_APPLICABLE" :disabled="detail.fees.some(row => row.active && row.purpose === 'OTHER')">不适用</el-radio><el-radio value="ITEMIZED">逐项核定</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="form.otherDetermination === 'ITEMIZED'"><section v-for="(row, index) in form.others" :key="row.code" class="other-charge"><div class="charge-heading"><el-form-item label="费用名称" required><el-input v-model="row.name" :readonly="row.mode === 'EXISTING'" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item><el-button v-if="row.mode === 'NEW'" class="density-action" data-tone="danger" @click="form.others.splice(index, 1)">移除新费用</el-button></div><ProcurementChargeFields v-model="form.others[index]" :detail="detail" :files="files" /></section><el-button v-if="!detail.operations.some(row => row.action === 'CONFIRM_CHARGES')" class="density-action" @click="addOther">增加其他费用</el-button></template>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else-if="command === 'fees/additional'">
|
||||
<el-alert title="登记独立新发生的正额费用,使用该业务日期的汇率。与原费用金额更正分开处理;已完成订单保留原履约状态。" type="info" :closable="false" />
|
||||
<el-form-item label="独立费用名称" required><el-input v-model="form.additional.name" maxlength="128" placeholder="填写独立发生的费用名称" /></el-form-item>
|
||||
<el-form-item label="新增费用原币金额" required><el-input v-model="form.additional.amount" placeholder="填写正额金额,最多两位小数" /></el-form-item>
|
||||
<el-form-item label="新增费用币种" required><el-select v-model="form.additional.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item>
|
||||
<el-form-item label="新增费用业务日期" required><el-date-picker v-model="form.additional.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="新增费用凭证" required><el-select v-model="form.additional.voucherFileId" placeholder="选择本单已上传的费用凭证"><el-option v-for="file in files.filter(row => row.category === 'VOUCHER')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert v-if="command === 'fees/replace-failed'" title="原费用须已明确失败并完成终止及冻结释放。本次按核定总额创建新的实际费用版本,沿用原汇率和业务日期;货款仍受客户预算限制。" type="warning" :closable="false" />
|
||||
<el-alert v-if="command === 'fees/correct-target'" title="输入这条原费用链正确的原币总额。沿用原汇率,按目标与净实扣差额生成追加或逐笔退款;历史扣款不覆盖。" type="info" :closable="false" />
|
||||
<el-alert v-if="command === 'fees/refund'" title="退款回到客户资金账户,关联选中的原成功扣款;不以普通充值替代,不自动退款其他费用。" type="warning" :closable="false" />
|
||||
<el-alert v-if="command === 'fees/terminate'" title="仅终止明确失败的费用版本;仍有有效冻结时先释放,释放成功后才可重新核定,不能将未决结果当作失败。" type="warning" :closable="false" />
|
||||
<el-alert v-if="command === 'fees/release-estimate'" title="释放有效预估冻结不产生退款或实际收费,后续交寄须重新满足预估核对条件。" type="warning" :closable="false" />
|
||||
<el-alert v-if="command === 'fees/retry'" title="仅重试已明确失败的原资金请求,金额与费用版本不改变;处理中请刷新结果。" type="info" :closable="false" />
|
||||
<el-form-item label="原费用" required><el-select v-model="form.feeId" placeholder="选择本单符合操作条件的费用"><el-option v-for="fee in candidates" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${feePurposes[fee.purpose] || fee.feeItemName} · ${money(fee.amount, fee.currency)}`" /></el-select></el-form-item>
|
||||
<dl v-if="selected" class="density-fields"><div><dt>原币金额</dt><dd>{{ money(selected.originalAmount, selected.originalCurrency) }}</dd></div><div><dt>原汇率及月份</dt><dd>{{ selected.exchangeRate }} / {{ selected.exchangeRateMonth }}</dd></div></dl>
|
||||
<el-form-item v-if="['fees/correct-target', 'fees/replace-failed'].includes(command)" :label="`正确原币总额(${selected?.originalCurrency || '先选择费用'})`" required><el-input v-model="form.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item>
|
||||
<el-form-item v-if="command === 'fees/refund'" label="本次退款金额(CNY)" required><el-input v-model="form.refundAmount" placeholder="填写本次正额退款金额" /></el-form-item>
|
||||
<el-alert v-if="preview?.error" :title="preview.error" type="warning" :closable="false" />
|
||||
<dl v-else-if="preview" class="density-fields"><template v-if="'target' in preview"><div><dt>目标人民币金额</dt><dd>{{ money(preview.target, 'CNY') }}</dd></div><div><dt>当前净实扣</dt><dd>{{ money(preview.net, 'CNY') }}</dd></div><div><dt>本次差额</dt><dd>{{ money(preview.difference, 'CNY') }}</dd></div></template><div v-if="'refundable' in preview"><dt>该笔剩余可退</dt><dd>{{ money(preview.refundable, 'CNY') }}</dd></div></dl>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.charge-section { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
|
||||
.charge-section h3 { font-size: var(--admin-font-section); margin: 0 0 var(--admin-space-3); }
|
||||
.charge-heading { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.charge-heading :deep(.el-form-item) { flex: 1; }
|
||||
.other-charge { padding-bottom: var(--admin-space-3); }
|
||||
:deep(.el-select) { width: 100%; }
|
||||
</style>
|
||||
@@ -1,308 +0,0 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ProcurementNodeActions from './ProcurementNodeActions.vue'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { readPendingProcurement } from '../procurementPending'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
const storageKey = 'procurement-command:7:10001:OP-100'
|
||||
function storage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: key => values.get(key) ?? null, key: index => [...values.keys()][index] ?? null, removeItem: key => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
function fixture(key = 'REVIEW', allowed = true): PlatformProcurement {
|
||||
const keys: Record<string, [string, string]> = { REVIEW: ['审核采购', 'review'], PURCHASE: ['登记购买完成', 'buy'], AUTHORIZE_BUDGET: ['核验预算上限', 'review'], PLAN_WAREHOUSE: ['修改计划仓', 'review'], CANCEL: ['购买前取消', 'cancel'], RECEIVE_OVERSEAS: ['登记海外实收', 'receive-overseas'], FINISH_DOMESTIC: ['确认国内收货完成', 'receive-domestic'], PACK: ['保存装箱草稿', 'pack'], MANAGE: ['事实更正及异常处置', 'manage'], LABELS: ['维护两段面单', 'label'], ESTIMATE: ['核对预估运费', 'estimate'], SHIP_OVERSEAS: ['确认海外交寄', 'ship-overseas'], CONFIRM_CHARGES: ['确认国内费用', 'charge'], FINANCE_ADJUST: ['费用更正与退款', 'finance-adjust'], EXCEPTION_CLOSE: ['异常关闭', 'manage'] }
|
||||
const [label, suffix] = keys[key]
|
||||
return {
|
||||
customerView: { order: { id: '1', orderNo: 'OP-100', customerId: '10001', customerName: '测试客户', purchaseCategory: '家居', productName: '样品商品', productSpecification: '黑色', productUrl: null, purchaseQuantity: 2, budgetUnitPrice: '10.00', currency: 'USD', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseCode: null, actualWarehouseName: null, status: 'PENDING_REVIEW', version: 3, createdAt: '', updatedAt: '' }, budget: { totalAmount: '20.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] },
|
||||
purchaseFacts: {}, physical: { dispositions: [], receipts: [], cartons: [], packingVersion: null, packingStatus: null }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key, label, permission: `platform:overseas-business:purchase:${suffix}`, allowed, reason: allowed ? null : '业务条件不满足' }], internalRemark: null,
|
||||
}
|
||||
}
|
||||
function render(detail = fixture(), permissions = ['review', 'buy', 'cancel', 'receive-overseas', 'receive-domestic', 'pack', 'manage', 'label', 'estimate', 'ship-overseas', 'charge', 'finance-adjust']) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.token = 'test-token'; auth.permissions = permissions.map(item => `platform:overseas-business:purchase:${item}`); useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(ProcurementNodeActions, { attachTo: document.body, props: { detail, customerId: '10001' }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
async function open(wrapper: ReturnType<typeof render>) { await wrapper.find('.purchase-actions button').trigger('click'); await flushPromises(); return wrapper.findComponent(AppDialog) }
|
||||
async function explain(wrapper: ReturnType<typeof render>, value = '已核对客户资料') { await wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '操作说明')!.find('textarea').setValue(value) }
|
||||
async function confirm(wrapper: ReturnType<typeof render>) { wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises() }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('sessionStorage', storage()); vi.stubGlobal('localStorage', storage()); document.body.innerHTML = ''
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '海外收货仓', countryCode: 'US' }])
|
||||
vi.mocked(submitProcurementCommand).mockReset().mockResolvedValue(undefined)
|
||||
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', () => {
|
||||
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 () => {
|
||||
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 }]
|
||||
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
|
||||
sessionStorage.setItem(storageKey, original)
|
||||
let wrapper = render(value)
|
||||
const click = async (label: string) => { await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click'); await flushPromises() }
|
||||
await click('准备费用更正草稿')
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '原费用')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
|
||||
await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00')
|
||||
await explain(wrapper, '准备下一版本')
|
||||
await click('保存本地草稿')
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(original)
|
||||
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount(); wrapper = render(value)
|
||||
await click('恢复费用更正草稿')
|
||||
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
|
||||
await click('确认上次提交结果')
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
|
||||
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
|
||||
const latest = structuredClone(value)
|
||||
latest.actions[0].allowed = true; latest.customerView.order.version = 9
|
||||
latest.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
|
||||
await wrapper.setProps({ detail: latest })
|
||||
await click('恢复费用更正草稿'); await click('核对并继续确认')
|
||||
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
|
||||
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
|
||||
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
|
||||
await wrapper.setProps({ detail: structuredClone(latest) })
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenLastCalledWith('OP-100', '10001', 'fees/correct-target', expect.objectContaining({ version: 9, targetOriginalAmount: '12.00', feeId: '11' }))
|
||||
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).toBeNull()
|
||||
// Restore a saved draft before changing the scope to ensure isolation is meaningful.
|
||||
sessionStorage.setItem(`${storageKey}:correction-draft`, JSON.stringify({ feeId: '11', targetOriginalAmount: '13.00', reason: '下一目标', voucherFileId: '501', orderVersion: 9 }))
|
||||
await wrapper.setProps({ customerId: 'OTHER' }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
|
||||
await wrapper.setProps({ customerId: '10001', detail: { ...value, customerView: { ...value.customerView, order: { ...value.customerView.order, orderNo: 'OTHER' } } } }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
|
||||
useAuthStore().user = { id: '8', name: '另一操作员', account: 'other', roles: [] }
|
||||
await wrapper.setProps({ detail: value }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
|
||||
})
|
||||
it('requires explicit remaining-goods outcomes and keeps private closure reasons separate', async () => {
|
||||
const value = fixture('EXCEPTION_CLOSE'); value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
|
||||
value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-100', quantity: 2 }]
|
||||
value.files = [{ id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
|
||||
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '内部客户沟通及供应商处理依据')
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
|
||||
field('实际处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'SUPPLIER_RETAINED')
|
||||
field('本项处置凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
|
||||
await field('实际去向及接收方').find('input').setValue('供应商原仓留存')
|
||||
field('客户同意停止履约凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
|
||||
await field('财务处理结论(仅平台可见)').find('textarea').setValue('全部退款及冻结释放已处理')
|
||||
await field('对客关闭摘要(客户可见)').find('textarea').setValue('货物由供应商保留,停止后续寄送')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'exception-close', expect.objectContaining({ resolutions: [{ kind: 'PURCHASE_REMAINDER', sourceId: null, quantity: 2, decision: 'SUPPLIER_RETAINED', destination: '供应商原仓留存', voucherFileId: '501' }], customerVoucherFileId: '501', financialConclusion: '全部退款及冻结释放已处理', clientSummary: '货物由供应商保留,停止后续寄送' }))
|
||||
expect(dialog.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
|
||||
})
|
||||
it('confirms three explicit zero determinations and marks other charges not applicable in one request', async () => {
|
||||
const value = fixture('CONFIRM_CHARGES'); value.purchaseFacts.domestic_received_at = '2020-01-04T10:00:00'
|
||||
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '整单费用核对完成')
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
const groups = dialog.findAllComponents({ name: 'ProcurementChargeFields' }); expect(groups).toHaveLength(3)
|
||||
for (const group of groups) {
|
||||
const determination = group.findAllComponents({ name: 'ElSelect' })[0]; determination.vm.$emit('update:modelValue', 'ZERO'); determination.vm.$emit('change', 'ZERO')
|
||||
group.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-04')
|
||||
await group.find('input[placeholder="说明计费依据;零元或不适用也需说明"]').setValue('本项费用免收')
|
||||
}
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
const [number, customer, command, payload] = vi.mocked(submitProcurementCommand).mock.calls[0]
|
||||
expect([number, customer, command]).toEqual(['OP-100', '10001', 'charges/confirm'])
|
||||
for (const selection of [payload.internationalFreight, payload.duty, payload.domesticFreight]) expect(selection).toEqual({ value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '本项费用免收' } })
|
||||
expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([])
|
||||
})
|
||||
it('lets finance replace a terminated unpaid sample without modifying its original purchase fact', async () => {
|
||||
const value = fixture('FINANCE_ADJUST')
|
||||
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: false, 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: 'TERMINATED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
|
||||
value.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
|
||||
const wrapper = render(value, ['finance-adjust']); const dialog = await open(wrapper)
|
||||
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
|
||||
field('财务处理方式').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'fees/replace-failed'); await flushPromises()
|
||||
field('原费用').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await flushPromises()
|
||||
await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00')
|
||||
field('本单凭证').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await explain(wrapper, '原资金已终止,按真实凭证重新核定')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/replace-failed', expect.objectContaining({ feeId: '11', targetOriginalAmount: '12.00', voucherFileId: '501' }))
|
||||
expect(value.fees[0].originalAmount).toBe('10.00'); expect(value.fees[0].active).toBe(false)
|
||||
})
|
||||
it('lets a buyer retry a failed sample payment without granting ordinary charge or finance-adjust access', async () => {
|
||||
const value = fixture('PURCHASE')
|
||||
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: 'FREEZE_FAILED', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
|
||||
const wrapper = render(value, ['buy']); await wrapper.findAll('button').find(button => button.text() === '重试失败资金请求')!.trigger('click'); await flushPromises(); await explain(wrapper, '客户已补足余额,重试原资金请求')
|
||||
wrapper.findComponent(AppDialog).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11'); await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/retry', expect.objectContaining({ feeId: '11', version: 3 }))
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('amount')
|
||||
wrapper.unmount(); const restricted = render(value, ['charge']); expect(restricted.text()).not.toContain('重试失败资金请求')
|
||||
})
|
||||
it('uploads an external label and submits a per-carton association with receiving details', async () => {
|
||||
const value = fixture('LABELS'); value.physical.packingStatus = 'CONFIRMED'; value.physical.packingVersion = 2
|
||||
value.transport.overseasCargo = [{ cartonId: '31', cartonNo: 'BOX-31', quantity: 2, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 2 }] }]
|
||||
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '51', fileId: '501', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
|
||||
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '国内客服制作国际面单')
|
||||
const field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
|
||||
await field('国内接收人').find('input').setValue('国内收货人'); await field('联系电话').find('input').setValue('13800000001'); await field('完整收件地址').find('input').setValue('上海国内收货地址')
|
||||
const input = dialog.find('input[aria-label="上传快递面单"]'); const file = new File(['pdf'], 'label.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises()
|
||||
field('本单面单文件').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501'); await field('承运商').find('input').setValue('UPS'); await field('本箱运单号').find('input').setValue('TRACK-31')
|
||||
await confirm(wrapper)
|
||||
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'LABEL', file)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'labels', expect.objectContaining({ leg: 'OVERSEAS', packingVersion: 2, receiver: { name: '国内收货人', phone: '13800000001', address: '上海国内收货地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'UPS', trackingNo: 'TRACK-31' }] }))
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('records a partial overseas receipt separately from completing receipt', async () => {
|
||||
const value = fixture('RECEIVE_OVERSEAS'); value.purchaseFacts = { purchased_at: '2020-01-01T10:00:00', purchased_quantity: 2 }
|
||||
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '首批样品到仓')
|
||||
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001')
|
||||
dialog.findAllComponents({ name: 'ElInputNumber' })[0].vm.$emit('update:modelValue', 1)
|
||||
dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2020-01-02T10:00:00')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'overseas-receipts', expect.objectContaining({ version: 3, leg: 'OVERSEAS', warehouseId: '20001', goodQuantity: 1, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00' }))
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('sourceCartonId')
|
||||
})
|
||||
it('saves structured packing sources without invoking packing confirmation', async () => {
|
||||
const value = fixture('PACK')
|
||||
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }]
|
||||
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '核对装箱草稿')
|
||||
const values = ['BOX-ONE', '10.12', '20', '30', '1.234']
|
||||
for (const [index, input] of dialog.findAll('.packing-grid input').entries()) await input.setValue(values[index])
|
||||
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
|
||||
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'packing', expect.objectContaining({ cartons: [{ cartonNo: 'BOX-ONE', lengthCm: '10.12', widthCm: '20', heightCm: '30', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 2 }] }] }))
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('uses exception evidence and source quantities for damaged goods disposition', async () => {
|
||||
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 }]
|
||||
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 field = (label: string) => dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)!
|
||||
field('差异及处置').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DAMAGED'); await flushPromises()
|
||||
field('对应实收').findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
|
||||
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1)
|
||||
const input = dialog.find('input[type="file"]'); const file = new File(['img'], 'damage.png', { type: 'image/png' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises()
|
||||
await confirm(wrapper)
|
||||
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'EXCEPTION', file)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'dispositions', expect.objectContaining({ receiptId: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucherFileId: '102' }))
|
||||
})
|
||||
it('requires an authoritative warehouse on approval and sends the current order version', async () => {
|
||||
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper)
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('请选择有效采购仓')
|
||||
dialog.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '20001'); await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', { version: 3, idempotencyKey: expect.any(String), decision: 'APPROVE', remark: '已核对客户资料', purchaseWarehouseId: '20001' })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1); expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
})
|
||||
it('rejects without a warehouse and never substitutes an invented candidate', async () => {
|
||||
vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('unavailable'))
|
||||
const wrapper = render(); const dialog = await open(wrapper); await explain(wrapper, '购买对象不符合要求')
|
||||
dialog.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT'); await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ decision: 'REJECT' }))
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[0][3]).not.toHaveProperty('purchaseWarehouseId')
|
||||
})
|
||||
it('records explicit zero purchase with the exact product snapshot and purchase-purpose evidence', async () => {
|
||||
const wrapper = render(fixture('PURCHASE')); const dialog = await open(wrapper); await explain(wrapper, '供应商免费提供样品')
|
||||
await confirm(wrapper); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
const amount = dialog.findAll('.el-form-item').find(item => item.text().startsWith('供应商实付总额'))!.find('input'); await amount.setValue('0.00')
|
||||
const dates = dialog.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-08T10:00:00'); dates[1].vm.$emit('update:modelValue', '2026-10-15T10:00:00')
|
||||
const input = dialog.find('input[type="file"]'); const file = new File(['pdf'], 'purchase.pdf', { type: 'application/pdf' }); Object.defineProperty(input.element, 'files', { value: [file] }); await input.trigger('change'); await flushPromises()
|
||||
expect(uploadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', 'PURCHASE', file)
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' }))
|
||||
})
|
||||
it('recovers an uncertain request after reload without changing its body or key', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout'))
|
||||
let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount()
|
||||
wrapper = render(fixture('CANCEL')); await flushPromises()
|
||||
const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
})
|
||||
it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409'))
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
|
||||
const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull()
|
||||
await confirm(wrapper)
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(saved)
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3])
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
it('keeps a version conflict visible, releases the failed command and does not report success', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409'))
|
||||
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()
|
||||
})
|
||||
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()
|
||||
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 () => {
|
||||
let complete!: () => void; vi.mocked(submitProcurementCommand).mockImplementation(() => new Promise(resolve => { complete = resolve }))
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises()
|
||||
})
|
||||
it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => {
|
||||
sessionStorage.setItem(storageKey, '{bad')
|
||||
const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,245 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
|
||||
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
|
||||
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
|
||||
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
|
||||
import type { OverseasPurchaseWarehouseOption } from '../types'
|
||||
import ProcurementControlFields from './ProcurementControlFields.vue'
|
||||
import { controlCommands, controlForm, buildControl } from '../procurementControlForm'
|
||||
import ProcurementFinanceFields from './ProcurementFinanceFields.vue'
|
||||
import { financeCommands, financeForm, buildFinance, feeRetryPermission } from '../procurementFinanceForm'
|
||||
import ProcurementTransportFields from './ProcurementTransportFields.vue'
|
||||
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
|
||||
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
|
||||
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
|
||||
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
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 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 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 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 warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
|
||||
const extraFiles = ref<ProcurementFile[]>([])
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
const control = ref(controlForm(props.detail))
|
||||
const finance = ref(financeForm(props.detail))
|
||||
const financialCommand = ref<ProcurementCommand>('fees/correct-target')
|
||||
const transport = ref(transportForm(props.detail))
|
||||
const physical = ref(physicalForm())
|
||||
const manageCommand = ref<ProcurementCommand>('dispositions')
|
||||
const key = computed(() => auth.user?.id ? `procurement-command:${auth.user.id}:${props.customerId}:${props.detail.customerView.order.orderNo}` : '')
|
||||
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 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 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 isFinance = computed(() => !!command.value && financeCommands.has(command.value))
|
||||
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
|
||||
const isPhysical = computed(() => !!command.value && physicalCommands.has(command.value))
|
||||
const needsEvidence = computed(() => ['dispositions', 'receipts/correct', 'dispositions/revoke', 'domestic-repack', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(command.value || ''))
|
||||
const needsWarehouse = computed(() => command.value === 'actual-warehouse/correct' || command.value === 'overseas-receipts' || (command.value === 'receipts/correct' && physical.value.leg === 'OVERSEAS') || command.value === 'planned-warehouse' || (command.value === 'review' && form.decision === 'APPROVE'))
|
||||
const needsAmount = computed(() => ['purchase', 'budget-authorizations'].includes(command.value || ''))
|
||||
const fileCategory = computed(() => isControl.value ? 'EXCEPTION' : isFinance.value ? 'VOUCHER' : command.value === 'labels' ? 'LABEL' : command.value === 'domestic-repack' ? 'RECEIPT' : needsEvidence.value ? 'EXCEPTION' : command.value === 'purchase' ? 'PURCHASE' : 'BUDGET')
|
||||
const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].filter((file, index, all) => all.findIndex(other => other.fileId === file.fileId) === index))
|
||||
const files = computed(() => allFiles.value.filter(file => file.category === fileCategory.value))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purchase-action', label: computed(() => active.value?.label || '采购节点操作'), isOpen: opened, model: reactive({ purchase: form, physical, transport, finance, control }), requiredPermission: computed(() => active.value?.permission ?? null) })
|
||||
watch(key, () => {
|
||||
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
|
||||
if (!key.value) return
|
||||
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
|
||||
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
|
||||
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
|
||||
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
|
||||
}, { immediate: true })
|
||||
async function loadWarehouses() {
|
||||
if (warehouseLoading.value) return
|
||||
warehouseLoading.value = true; warehouseError.value = ''
|
||||
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
|
||||
catch { warehouseError.value = '采购仓候选加载失败,请重试'; warehouses.value = [] }
|
||||
finally { warehouseLoading.value = false }
|
||||
}
|
||||
function open(action: ProcurementAction) {
|
||||
if (!action.allowed || pending.value || storageError.value || saving.value) return
|
||||
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)
|
||||
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')
|
||||
if (action.key.includes('DOMESTIC')) physical.value.leg = 'DOMESTIC'
|
||||
Object.assign(form, { decision: 'APPROVE', warehouseId: props.detail.purchaseFacts.actual_warehouse_id || '', reason: '', amount: '', currency: props.detail.customerView.budget.currency === 'USD' ? 'USD' : 'CNY', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
|
||||
opened.value = true; captureBaseline()
|
||||
if (needsWarehouse.value) void loadWarehouses()
|
||||
}
|
||||
function openCorrectionDraft() {
|
||||
if (!canDraft.value || saving.value || uploading.value) return
|
||||
editingDraft.value = true
|
||||
active.value = { key: 'CORRECTION_DRAFT', label: '费用更正草稿', permission: 'platform:overseas-business:purchase:finance-adjust', allowed: true, reason: null }
|
||||
finance.value = financeForm(props.detail)
|
||||
const draft = correctionDraft.value
|
||||
finance.value.feeId = draft?.feeId || ''; finance.value.targetOriginalAmount = draft?.targetOriginalAmount || ''
|
||||
form.reason = draft?.reason || ''; form.voucherFileId = draft?.voucherFileId || ''
|
||||
error.value = null; opened.value = true; captureBaseline()
|
||||
}
|
||||
function saveCorrectionDraft() {
|
||||
if (!editingDraft.value || !canDraft.value || saving.value || uploading.value) return
|
||||
try {
|
||||
const draft = { feeId: finance.value.feeId, targetOriginalAmount: finance.value.targetOriginalAmount, reason: form.reason, voucherFileId: form.voucherFileId, orderVersion: props.detail.customerView.order.version }
|
||||
sessionStorage.setItem(draftKey.value, JSON.stringify(draft)); correctionDraft.value = draft; draftError.value = ''; captureBaseline()
|
||||
ElMessage.success('更正草稿已保存在本浏览器标签页,尚未确认费用或发起资金请求')
|
||||
} catch { draftError.value = '草稿保存失败,请保留当前输入并重试' }
|
||||
}
|
||||
function useCorrectionDraft() {
|
||||
if (!editingDraft.value || pending.value || storageError.value || !canDraft.value) return
|
||||
const action = actions.value.find(item => item.key === 'FINANCE_ADJUST' && item.allowed)
|
||||
if (!action) { error.value = { message: '当前业务状态不允许财务确认,请刷新订单后核对' }; return }
|
||||
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
|
||||
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
|
||||
}
|
||||
async function retryPending() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
|
||||
editingDraft.value = false; opened.value = false
|
||||
await submit()
|
||||
}
|
||||
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
|
||||
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
|
||||
function build(): ProcurementCommandBody {
|
||||
const order = props.detail.customerView.order
|
||||
if (!form.reason.trim()) throw new Error('请填写操作说明')
|
||||
const base = { version: order.version, idempotencyKey: commandKey(), reason: form.reason.trim() }
|
||||
if (needsWarehouse.value && !warehouses.value.some(row => row.warehouseId === form.warehouseId)) throw new Error('请选择有效采购仓')
|
||||
if (isControl.value && command.value) return buildControl(command.value, control.value, props.detail, allFiles.value, base, form.warehouseId, form.voucherFileId)
|
||||
if (isFinance.value && command.value) return buildFinance(command.value, finance.value, props.detail, allFiles.value, base, form.voucherFileId)
|
||||
if (isTransport.value && command.value) return buildTransport(command.value, transport.value, props.detail, allFiles.value, base, form.voucherFileId)
|
||||
if (isPhysical.value && command.value) {
|
||||
if (needsEvidence.value && !files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单异常处置凭证')
|
||||
return buildPhysical(command.value, physical.value, props.detail, base, form.warehouseId, form.voucherFileId)
|
||||
}
|
||||
if (command.value === 'review') return { version: base.version, idempotencyKey: base.idempotencyKey, decision: form.decision, remark: base.reason, ...(form.decision === 'APPROVE' ? { purchaseWarehouseId: form.warehouseId } : {}) }
|
||||
if (command.value === 'planned-warehouse') return { ...base, warehouseId: form.warehouseId }
|
||||
if (needsAmount.value) {
|
||||
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(form.amount.trim())) throw new Error('请明确填写非负金额,最多两位小数;免费样品请填 0')
|
||||
if (!files.value.some(file => file.fileId === form.voucherFileId)) throw new Error('请选择本单对应用途的凭证')
|
||||
const amount = { ...base, amount: form.amount.trim(), currency: form.currency, voucherFileId: form.voucherFileId }
|
||||
if (command.value === 'budget-authorizations') return amount
|
||||
if (!form.purchasedAt || !form.estimatedArrivalAt) throw new Error('请填写真实购买时间及预计到仓时间')
|
||||
return { ...amount, productName: order.productName, specification: order.productSpecification ?? '', quantity: order.purchaseQuantity, purchasedAt: form.purchasedAt, estimatedArrivalAt: form.estimatedArrivalAt }
|
||||
}
|
||||
return base
|
||||
}
|
||||
async function submit() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); return }
|
||||
if (saving.value || uploading.value || storageError.value || !key.value) return
|
||||
const resuming = !!pending.value
|
||||
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
|
||||
if (!pending.value) {
|
||||
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
|
||||
try {
|
||||
if (managementBlock.value) throw new Error(managementBlock.value)
|
||||
const request = { command: command.value, payload: build() }
|
||||
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
|
||||
sessionStorage.setItem(scope, serialized); pending.value = request
|
||||
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
|
||||
}
|
||||
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
|
||||
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
saving.value = true; error.value = null
|
||||
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
try {
|
||||
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
|
||||
sessionStorage.removeItem(scope)
|
||||
if (key.value !== scope) return
|
||||
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
|
||||
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
|
||||
} catch (issue) {
|
||||
if (key.value !== scope) return
|
||||
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
|
||||
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
if (!file || uploading.value || (pending.value && !editingDraft.value)) return
|
||||
if (file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) { ElMessage.warning('请上传不超过 10MB 的 PDF、JPG 或 PNG'); return }
|
||||
const scope = key.value; uploading.value = true
|
||||
try {
|
||||
const saved = await uploadProcurementFile(props.detail.customerView.order.orderNo, props.customerId, fileCategory.value, file)
|
||||
if (scope !== key.value) return
|
||||
extraFiles.value.push(saved); form.voucherFileId = saved.fileId
|
||||
} catch (issue) { if (scope === key.value) error.value = getApiErrorDetails(issue, '凭证上传失败') }
|
||||
finally { uploading.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="admin-density" aria-label="采购履约节点操作">
|
||||
<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>
|
||||
<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>
|
||||
<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="!!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" />
|
||||
<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-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>
|
||||
<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>
|
||||
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
|
||||
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
|
||||
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
|
||||
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
|
||||
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
|
||||
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
|
||||
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" placeholder="填写本次操作的事实、依据或原因" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.purchase-actions { flex-wrap: wrap; }
|
||||
.purchase-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.purchase-form-grid :deep(.el-date-editor), :deep(.el-select) { width: 100%; }
|
||||
</style>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import type { ProcurementCommand } from '../api/procurementCommands'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
|
||||
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
|
||||
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 sources = computed(() => props.detail.physical.receipts.filter(row => row.active && row.leg === 'OVERSEAS' && row.goodQuantity > 0))
|
||||
const domesticBoxes = computed(() => shippedCartons(props.detail))
|
||||
const showReceipt = computed(() => ['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(props.command))
|
||||
function selectReceipt(id: string) {
|
||||
if (props.command !== 'receipts/correct') return
|
||||
const row = props.detail.physical.receipts.find(item => item.id === id && item.active)
|
||||
if (row) Object.assign(form.value, { leg: row.leg, sourceCartonId: row.sourceCartonId || '', goodQuantity: row.goodQuantity, damagedQuantity: row.damagedQuantity, extraQuantity: row.extraQuantity, receivedAt: row.receivedAt })
|
||||
}
|
||||
const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' }
|
||||
</script>
|
||||
<template>
|
||||
<div class="admin-density">
|
||||
<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/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" :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" :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>
|
||||
<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>
|
||||
<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'">
|
||||
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
|
||||
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
|
||||
<div class="packing-heading"><strong>第 {{ index + 1 }} 箱</strong><el-button class="density-action" data-tone="danger" @click="form.cartons.splice(index, 1)">移除本箱</el-button></div>
|
||||
<div class="packing-grid"><el-form-item label="箱号" required><el-input v-model="box.cartonNo" maxlength="64" placeholder="填写本单唯一箱号" /></el-form-item><el-form-item label="长(cm)" required><el-input v-model="box.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="宽(cm)" required><el-input v-model="box.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="高(cm)" required><el-input v-model="box.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="毛重(kg)" required><el-input v-model="box.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
|
||||
<div v-for="(source, sourceIndex) in box.sources" :key="sourceIndex" class="packing-source"><el-form-item label="海外实收来源" required><el-select v-model="source.receiptId" placeholder="选择有效合格实收"><el-option v-for="receipt in sources" :key="receipt.id" :value="receipt.id" :label="`${receipt.receiptNo} · 合格 ${receipt.goodQuantity} 件`" /></el-select></el-form-item><el-form-item label="本箱分配件数" required><el-input-number v-model="source.quantity" :min="1" :precision="0" placeholder="分配到此箱的件数" controls-position="right" /></el-form-item><el-button class="density-action" data-tone="danger" @click="box.sources.splice(sourceIndex, 1)">移除来源</el-button></div>
|
||||
<el-button class="density-action" @click="box.sources.push({ receiptId: '', quantity: undefined })">增加来源</el-button>
|
||||
</section><el-button class="density-action" data-tone="primary" @click="form.cartons.push(newCarton())">增加装货箱</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.physical-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3); }
|
||||
.packing-grid { display: grid; grid-template-columns: 2fr repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
|
||||
.packing-source { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto; gap: var(--admin-space-3); align-items: center; }
|
||||
.packing-box { padding-block: var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
|
||||
.packing-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); }
|
||||
:deep(.el-select), :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 760px) { .physical-grid, .packing-grid { grid-template-columns: 1fr; } .packing-source { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import type { PlatformProcurement, ProcurementFile } from '../procurementTypes'
|
||||
import type { ProcurementCommand } from '../api/procurementCommands'
|
||||
import { transportForm, transportCargo, type TransportForm } from '../procurementTransportForm'
|
||||
import { dateTime, measurement, money } from '../procurementPresentation'
|
||||
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement; files: ProcurementFile[] }>()
|
||||
const form = defineModel<TransportForm>({ required: true })
|
||||
const cargo = computed(() => transportCargo(props.detail, form.value.leg))
|
||||
const labelSet = computed(() => form.value.leg === 'OVERSEAS' ? props.detail.transport.overseasLabels : props.detail.transport.domesticLabels)
|
||||
const sender = computed(() => form.value.leg === 'DOMESTIC' ? props.detail.transport.overseasLabels?.receiver : labelSet.value?.sender)
|
||||
const estimate = computed(() => props.detail.customerView.fees.find(row => row.active && row.purpose === 'INTERNATIONAL_ESTIMATE'))
|
||||
const changedReceiver = computed(() => form.value.leg === 'DOMESTIC' && (form.value.receiver.name.trim() !== props.detail.customerView.order.contactName || form.value.receiver.phone.trim() !== props.detail.customerView.order.contactPhone || form.value.receiver.address.trim() !== props.detail.customerView.order.receivingAddress))
|
||||
function changeLeg() { form.value = transportForm(props.detail, form.value.leg) }
|
||||
function selectCarton(id: string) { const box = props.detail.transport.domesticCargo.find(row => row.cartonId === id); if (box) Object.assign(form.value, { lengthCm: String(box.lengthCm), widthCm: String(box.widthCm), heightCm: String(box.heightCm), grossWeightKg: String(box.grossWeightKg) }) }
|
||||
</script>
|
||||
<template>
|
||||
<div class="admin-density">
|
||||
<template v-if="command === 'labels'">
|
||||
<el-form-item label="面单运输段" required><el-radio-group v-model="form.leg" @change="changeLeg"><el-radio value="OVERSEAS" :disabled="!!detail.purchaseFacts.overseas_shipped_at">国际段</el-radio><el-radio value="DOMESTIC" :disabled="!detail.purchaseFacts.domestic_received_at">寄客户段</el-radio></el-radio-group></el-form-item>
|
||||
<p>寄件资料:{{ sender ? `${sender.name} · ${sender.phone} · ${sender.address}` : '由实际海外收货仓主档提供,保存时核对。' }}</p>
|
||||
<div class="transport-grid"><el-form-item :label="form.leg === 'OVERSEAS' ? '国内接收人' : '最终收件人'" required><el-input v-model="form.receiver.name" maxlength="128" placeholder="填写快递收件人" /></el-form-item><el-form-item label="联系电话" required><el-input v-model="form.receiver.phone" maxlength="64" placeholder="填写真实快递联系电话" /></el-form-item></div>
|
||||
<el-form-item label="完整收件地址" required><el-input v-model="form.receiver.address" maxlength="1000" placeholder="填写完整快递收件地址" /></el-form-item>
|
||||
<el-form-item v-if="changedReceiver" label="客户确认收件信息变更凭证" required><el-select v-model="form.addressVoucherFileId" placeholder="选择本单已上传的客户确认凭证"><el-option v-for="file in files.filter(item => item.category === 'EXCEPTION')" :key="file.fileId" :label="file.fileName" :value="file.fileId" /></el-select></el-form-item>
|
||||
<section v-for="row in form.labels" :key="row.cartonId" class="label-row">
|
||||
<strong>{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }}</strong>
|
||||
<div class="label-grid"><el-form-item label="本单面单文件" required><el-select v-model="row.fileId" placeholder="选择 PDF、JPG 或 PNG"><el-option v-for="file in files.filter(item => item.category === 'LABEL')" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select></el-form-item><el-form-item label="对应页码" required><el-input-number v-model="row.page" :min="1" :precision="0" controls-position="right" placeholder="图片填 1" /></el-form-item><el-form-item label="承运商" required><el-input v-model="row.carrier" maxlength="128" placeholder="填写实际承运商" /></el-form-item><el-form-item label="本箱运单号" required><el-input v-model="row.trackingNo" maxlength="128" placeholder="填写本箱主/子运单号" /></el-form-item></div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-if="command === 'international-estimate'">
|
||||
<el-alert title="本次只冻结预估国际运费,不直接扣款。变价时先释放原冻结,释放完成后再次核对并提交新预估;同价也需核对当前面单版本。" type="info" :closable="false" />
|
||||
<p>当前面单:第 {{ labelSet?.version || '—' }} 版;有效原预估:{{ estimate ? money(estimate.amount, estimate.currency) : '尚未核定' }}</p>
|
||||
<div class="transport-grid"><el-form-item label="预估国际运费(CNY)" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
|
||||
</template>
|
||||
<template v-if="command === 'labels/apply'">
|
||||
<p>国际面单第 {{ labelSet?.version || '—' }} 版,逐箱记录实际贴单。</p>
|
||||
<el-form-item label="本次已贴面单的箱" required><el-checkbox-group v-model="form.cartonIds"><el-checkbox v-for="row in labelSet?.labels || []" :key="row.cartonId" :value="row.cartonId" :disabled="!!row.appliedAt">{{ cargo.find(box => box.cartonId === row.cartonId)?.cartonNo || '未关联箱' }} · {{ row.appliedAt ? `已贴单 ${dateTime(row.appliedAt)}` : '待贴单' }}</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="实际贴单时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="command === 'domestic-repack'">
|
||||
<el-alert title="只允许原箱一对一换包装,不改变货物来源和件数。保存后旧国内面单失效,须重新制单并核对国内费用。" type="warning" :closable="false" />
|
||||
<el-form-item label="换包装原箱" required><el-select v-model="form.cartonId" placeholder="选择国内可寄原箱" @change="selectCarton"><el-option v-for="box in cargo" :key="box.cartonId" :value="box.cartonId" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
|
||||
<div class="measure-grid"><el-form-item label="新长度(cm)" required><el-input v-model="form.lengthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新宽度(cm)" required><el-input v-model="form.widthCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新高度(cm)" required><el-input v-model="form.heightCm" placeholder="最多两位小数" /></el-form-item><el-form-item label="新毛重(kg)" required><el-input v-model="form.grossWeightKg" placeholder="最多三位小数" /></el-form-item></div>
|
||||
</template>
|
||||
<template v-if="['overseas-shipment', 'customer-shipment'].includes(command)">
|
||||
<el-alert title="仅在货物实际交给承运方后确认。本次将整批交寄全部可寄原箱,不能拆批或重复交寄。" type="warning" :closable="false" />
|
||||
<p>面单第 {{ labelSet?.version || '—' }} 版;收件信息:{{ labelSet ? `${labelSet.receiver.name} · ${labelSet.receiver.phone} · ${labelSet.receiver.address}` : '未建立' }}</p>
|
||||
<el-form-item label="实际交承运方时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
</template>
|
||||
<table class="density-table"><thead><tr><th>原箱</th><th>当前可寄件数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th></tr></thead><tbody><tr v-for="box in cargo" :key="box.cartonId"><td>{{ box.cartonNo }}</td><td>{{ box.quantity }}</td><td>{{ [box.lengthCm, box.widthCm, box.heightCm].map(value => measurement(value, 2)).join(' × ') }}</td><td>{{ measurement(box.grossWeightKg, 3) }}</td></tr><tr v-if="!cargo.length"><td colspan="4">当前没有可寄货物</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.transport-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }
|
||||
.label-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.8fr); gap: var(--admin-space-3); }
|
||||
.measure-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3); }
|
||||
.label-row { padding-top: var(--admin-space-3); border-top: 1px solid var(--cc-border); }
|
||||
:deep(.el-select), :deep(.el-input-number), :deep(.el-date-editor) { width: 100%; }
|
||||
@media (max-width: 760px) { .transport-grid, .label-grid, .measure-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -14,14 +14,14 @@ describe('overseas business real contract pages', () => {
|
||||
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
|
||||
})
|
||||
|
||||
it('keeps procurement general editing limited to internal remarks', () => {
|
||||
it('loads procurement candidates, operations and three-state PATCH fields', () => {
|
||||
const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue')
|
||||
expect(list).toContain('listOverseasPurchaseCustomerOptions')
|
||||
expect(edit).toContain('getOverseasPurchaseEditor')
|
||||
expect(detail).toContain('detail.reviewHistory')
|
||||
expect(edit).toContain('listOverseasPurchaseWarehouseOptions')
|
||||
expect(detail).toContain('detail?.histories || []')
|
||||
expect(detail).not.toContain('outbounds')
|
||||
expect(edit).toContain('remark: form.remark.trim() || null')
|
||||
expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/)
|
||||
expect(edit).toContain('estimatedArrivalAt: form.estimatedArrivalAt || null')
|
||||
expect(edit).toContain('purchaseWarehouseId: form.purchaseWarehouseId || null')
|
||||
expect(edit).not.toContain('入库量不能小于 0')
|
||||
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -9,14 +8,13 @@ import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
|
||||
import {
|
||||
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getOverseasPurchaseEditor, getUsTruckOrder,
|
||||
createOverseasPurchaseFee, uploadOverseasPurchaseFile, createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
|
||||
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
|
||||
listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
|
||||
listOverseasPurchases, listUsTruckOrders, reviewOverseasPurchase, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
|
||||
} from '../api/platformOverseasBusiness'
|
||||
import PurchaseOrdersView from './PurchaseOrdersView.vue'
|
||||
import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue'
|
||||
@@ -30,12 +28,8 @@ const purchaseDetailSource = readFileSync(resolve(process.cwd(), 'src/modules/ov
|
||||
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('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/procurementCommands')>(),
|
||||
downloadProcurementFile: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({
|
||||
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
|
||||
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), reviewOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
|
||||
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
|
||||
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
|
||||
}))
|
||||
@@ -44,24 +38,20 @@ vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWor
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
|
||||
const purchase = {
|
||||
const purchase: OverseasPurchaseDetail = {
|
||||
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', status: 'WAIT_PACKING' as const, version: 3,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086',
|
||||
estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'WAIT_PURCHASE', toStatus: 'WAIT_PURCHASE', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, outboundQuantity: 0, inventoryQuantity: 10, status: 'PENDING_OUTBOUND', version: 3,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null,
|
||||
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
}
|
||||
const procurementDetail = (): PlatformProcurement => ({
|
||||
customerView: { order: { ...purchase, status: 'WAIT_PURCHASE', plannedWarehouseName: '洛杉矶采购仓', actualWarehouseCode: null, actualWarehouseName: null, receivingAddress: '深圳收件地址', contactName: 'Alice', contactPhone: '13800000000' }, budget: { totalAmount: '250.00', currency: 'USD', authorizedAt: null }, purchased: false, purchaseConfirmed: false, purchaseNeedsAttention: false, progress: [{ key: 'CREATED', title: '客户下单', completedAt: purchase.createdAt }], boxes: [], shipments: [], fees: [], exceptionSummaries: [] },
|
||||
purchaseFacts: {}, physical: { dispositions: [], receipts: [], packingVersion: null, packingStatus: null, cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: purchase.histories, operations: [], actions: [], internalRemark: '采购备注',
|
||||
})
|
||||
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
|
||||
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
|
||||
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
|
||||
|
||||
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
|
||||
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
|
||||
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
|
||||
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
@@ -69,9 +59,7 @@ describe('overseas business mounted pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
|
||||
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(downloadProcurementFile).mockReset()
|
||||
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
|
||||
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
|
||||
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 }); vi.mocked(reviewOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, status: 'APPROVED', version: 4 })
|
||||
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
|
||||
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
|
||||
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
|
||||
@@ -79,40 +67,6 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
|
||||
it.each(['查看路线'])('opens the shared route map from looseboard %s without requerying orders', async label => {
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(false)
|
||||
const requests = vi.mocked(listLocalLooseboardOrders).mock.calls.length
|
||||
await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
|
||||
expect(document.body.textContent).toContain('LA → ONT')
|
||||
expect(listLocalLooseboardOrders).toHaveBeenCalledTimes(requests)
|
||||
})
|
||||
|
||||
it.each([false, true])('opens truck route map on create/edit without saving (%s)', async editing => {
|
||||
if (editing) { routing.route.params = { orderId: truck.id }; routing.route.query = { customerId: truck.customerId } }
|
||||
const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
|
||||
expect(createUsTruckOrder).not.toHaveBeenCalled()
|
||||
expect(updateUsTruckOrder).not.toHaveBeenCalled()
|
||||
const dialog = wrapper.findComponent({ name: 'TransportRouteDialog' })
|
||||
expect(dialog.props('origin')).toEqual({ kind: 'address', label: editing ? 'Long Beach' : '', address: editing ? 'Long Beach Pier A' : '' })
|
||||
expect(dialog.props('destinations')).toEqual([{ kind: 'address', label: editing ? 'Ontario' : '', address: editing ? 'Ontario Warehouse B' : '' }])
|
||||
})
|
||||
|
||||
it('opens the associated commercial order using a server-side order number filter', async () => {
|
||||
routing.route.query = { orderNo: 'LB-LINKED' }
|
||||
const wrapper = mount(TransportOrdersView, { props: { domain: 'looseboard' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listLocalLooseboardOrders).toHaveBeenCalledWith(expect.objectContaining({ orderNo: 'LB-LINKED' }))
|
||||
expect(wrapper.find('input[placeholder="请输入商业单号"]').element).toHaveProperty('value', 'LB-LINKED')
|
||||
})
|
||||
|
||||
it('opens pool arrangements from the list without another request or exposing edit actions', async () => {
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
@@ -162,136 +116,55 @@ describe('overseas business mounted pages', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.density-detail-title').text()).toBe('OP-100')
|
||||
expect(wrapper.get('[aria-label="采购订单概览"]').text()).toContain('洛杉矶采购仓')
|
||||
expect(wrapper.findAll('.density-fields')).toHaveLength(3)
|
||||
expect(wrapper.text()).toContain('预算单价')
|
||||
expect(wrapper.text()).toContain('两段实际交寄')
|
||||
expect(wrapper.text()).not.toContain('库存信息')
|
||||
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.density-fields')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.density-fields dt')).toHaveLength(23)
|
||||
expect(wrapper.findAll('.density-fields .wide')).toHaveLength(4)
|
||||
expect(wrapper.text()).not.toContain('internal-purchase-route')
|
||||
await wrapper.get('.density-back').trigger('click')
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchases' })
|
||||
})
|
||||
|
||||
it('downloads procurement files with list access and prevents duplicate pending downloads', async () => {
|
||||
it('retains the voucher upload and fee dialog workflow with teleported density', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
const result = procurementDetail()
|
||||
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
let rejectDownload!: (error: Error) => void
|
||||
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
|
||||
const voucher = { id: 'voucher-1', fileId: 'file-1', fileName: 'voucher.pdf', category: 'VOUCHER' as const, contentType: 'application/pdf', sizeBytes: 3, createdAt: '2026-09-14T10:00:00' }
|
||||
vi.mocked(uploadOverseasPurchaseFile).mockReset().mockResolvedValue(voucher)
|
||||
vi.mocked(createOverseasPurchaseFee).mockReset().mockResolvedValue({ id: 'fee-1', feeNo: 'FEE-1', feeItemCode: 'COURIER', feeItemName: '快递费', amount: '25', currency: 'CNY', voucherFileId: 'file-1', voucherFileName: 'voucher.pdf', remark: null, walletStatus: 'FREEZE_PENDING', walletErrorMessage: null, createdAt: '2026-09-14T10:00:00' })
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
useAuthStore().permissions = ['platform:overseas-business:purchase:list']
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text() === '下载')
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click'); await flushPromises()
|
||||
expect(downloadProcurementFile).toHaveBeenCalledWith('OP-100', '10001', '9007199254741001')
|
||||
expect(button!.attributes('disabled')).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
expect(downloadProcurementFile).toHaveBeenCalledTimes(1)
|
||||
rejectDownload(new Error('TEST download unavailable')); await flushPromises()
|
||||
expect(button!.attributes('disabled')).toBeUndefined()
|
||||
expect(document.body.textContent).toContain('文件下载失败')
|
||||
const input = wrapper.findAll('input[type="file"]')[1]!
|
||||
const file = new File(['pdf'], 'voucher.pdf', { type: 'application/pdf' })
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change'); await flushPromises()
|
||||
expect(uploadOverseasPurchaseFile).toHaveBeenCalledWith('OP-100', '10001', 'VOUCHER', file)
|
||||
const dialog = wrapper.findAllComponents(AppDialog)[1]!
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(document.querySelector('.admin-density-dialog .density-form')).not.toBeNull()
|
||||
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25)
|
||||
await wrapper.vm.$nextTick()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createOverseasPurchaseFee).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ amount: 25, voucherFileId: 'file-1' }))
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('renders estimate holds separately from actual payment without claiming inbound settlement', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
const result = procurementDetail()
|
||||
result.customerView.fees = [{ feeNo: 'FEE-1', purpose: 'INTERNATIONAL_ESTIMATE', name: '国际运费预估', version: 1, active: true, determination: 'POSITIVE', amount: '25.00', currency: 'CNY', originalAmount: '25.00', originalCurrency: 'CNY', exchangeRate: '1', exchangeRateMonth: '2026-10', businessDate: '2026-10-09', parentFeeNo: null, walletStatus: 'FROZEN', sourceLinked: false }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已冻结,未扣款')
|
||||
expect(wrapper.text()).toContain('预估不入池')
|
||||
expect(wrapper.text()).not.toContain('订单全部入库后结算')
|
||||
})
|
||||
|
||||
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('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: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
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('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('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
|
||||
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
|
||||
|
||||
it('submits only the internal remark and preserves input after a version conflict', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409))
|
||||
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
|
||||
await wrapper.find('textarea').setValue('新备注')
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: '新备注' })
|
||||
expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试')
|
||||
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('新备注')
|
||||
expect(routing.push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('reviews a pending procurement from its detail page', async () => { const pending = { ...purchase, status: 'PENDING_REVIEW' as const, inboundQuantity: 0, inventoryQuantity: 0, version: 2 }; routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(getOverseasPurchase).mockResolvedValue(pending); vi.mocked(reviewOverseasPurchase).mockResolvedValue({ ...pending, status: 'APPROVED', version: 3 }); const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('通过'))?.trigger('click'); await wrapper.vm.$nextTick(); const reviewDialog = wrapper.findAllComponents(AppDialog)[0]; expect(reviewDialog.props('modelValue')).toBe(true); reviewDialog.vm.$emit('confirm'); await flushPromises(); expect(reviewOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 2, decision: 'APPROVE', idempotencyKey: expect.any(String) })); expect(wrapper.text()).toContain('已通过'); expect(wrapper.find('.header-actions').exists()).toBe(false); expect(wrapper.get('.density-back').text()).toBe('返回列表') })
|
||||
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
|
||||
it('blocks saving until the aggregate context has loaded successfully', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
vi.mocked(getOverseasPurchaseEditor).mockRejectedValue(new Error('unavailable'))
|
||||
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
|
||||
expect(wrapper.text()).toContain('采购订单暂时无法编辑')
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(updateOverseasPurchase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends only one remark mutation while a save is pending', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
let complete!: (result: Awaited<ReturnType<typeof updateOverseasPurchase>>) => void
|
||||
vi.mocked(updateOverseasPurchase).mockImplementation(() => new Promise(resolve => { complete = resolve }))
|
||||
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
|
||||
const button = wrapper.findAll('button').find((item) => item.text().trim() === '保存')!
|
||||
await button.trigger('click'); await button.trigger('click')
|
||||
expect(updateOverseasPurchase).toHaveBeenCalledTimes(1)
|
||||
complete({ ...purchase, version: 4 }); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('loads one aggregate editor request without warehouse queries and explicitly clears a remark', async () => {
|
||||
routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }
|
||||
const wrapper = mountPage(PurchaseOrderEditView); await flushPromises()
|
||||
expect(getOverseasPurchaseEditor).toHaveBeenCalledTimes(1)
|
||||
expect(listOverseasPurchaseWarehouseOptions).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('OP-100')
|
||||
expect(wrapper.text()).not.toContain('出库明细')
|
||||
await wrapper.find('textarea').setValue('')
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null })
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } })
|
||||
})
|
||||
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
|
||||
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
|
||||
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
|
||||
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); 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('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); 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(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).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(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
|
||||
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
|
||||
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
|
||||
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('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()
|
||||
})()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,187 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onActivated, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import ProcurementNodeActions from '../components/ProcurementNodeActions.vue'
|
||||
import { remainingLabels, decisionLabels } from '../procurementControlForm'
|
||||
import { createOverseasPurchaseFee, getOverseasPurchase, reviewOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import { statusLabel, money, dateTime, text, measurement, feeStatus, feePurposes } from '../procurementPresentation'
|
||||
import type { PlatformProcurement, ProcurementFile, ProcurementFee, ProcurementReceipt, ProcurementCarton, ProcurementShipment } from '../procurementTypes'
|
||||
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<PlatformProcurement | null>(null); const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const operationError = ref<ApiErrorDetails | null>(null)
|
||||
const downloading = ref<string | null>(null)
|
||||
const order = computed(() => detail.value?.customerView.order)
|
||||
const fields = computed(() => [
|
||||
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['商品规格', order.value?.productSpecification], ['采购数量', order.value?.purchaseQuantity],
|
||||
['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['有效预算总额', money(detail.value?.customerView.budget.totalAmount, detail.value?.customerView.budget.currency)],
|
||||
['预计到海外仓', dateTime(order.value?.estimatedArrivalAt)], ['实际收货仓', order.value?.actualWarehouseName],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['下单收货地址', order.value?.receivingAddress], ['内部备注', detail.value?.internalRemark],
|
||||
] as const)
|
||||
const purchaseFields = computed(() => [
|
||||
['实际购买时间', dateTime(detail.value?.purchaseFacts.purchased_at)], ['实购商品', detail.value?.purchaseFacts.purchased_product_name],
|
||||
['实购数量', detail.value?.purchaseFacts.purchased_quantity], ['原登记实付总额', money(detail.value?.purchaseFacts.purchase_amount, detail.value?.purchaseFacts.purchase_currency)],
|
||||
['货款核定时间', dateTime(detail.value?.purchaseFacts.purchase_confirmed_at)], ['待处理事项', detail.value?.purchaseFacts.purchase_issue],
|
||||
] as const)
|
||||
const receiptColumns: DataTableColumn[] = [
|
||||
{ key: 'receiptNo', prop: 'receiptNo', label: '实收编号', minWidth: 180 },
|
||||
{ key: 'leg', label: '收货段', width: 100, formatter: (row: ProcurementReceipt) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
|
||||
{ key: 'source', label: '来源箱', minWidth: 160, formatter: (row: ProcurementReceipt) => detail.value?.physical.cartons.find(box => box.id === row.sourceCartonId)?.cartonNo || '—' },
|
||||
{ key: 'goodQuantity', prop: 'goodQuantity', label: '合格件数', width: 100 }, { key: 'damagedQuantity', prop: 'damagedQuantity', label: '破损件数', width: 100 }, { key: 'extraQuantity', prop: 'extraQuantity', label: '多货件数', width: 100 },
|
||||
{ key: 'receivedAt', label: '实收时间', minWidth: 160, formatter: (row: ProcurementReceipt) => dateTime(row.receivedAt) },
|
||||
{ key: 'active', label: '记录状态', width: 100, formatter: (row: ProcurementReceipt) => row.active ? '有效' : '已更正' },
|
||||
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
|
||||
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
|
||||
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
|
||||
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
|
||||
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
|
||||
const outboundDetails = computed(() => detail.value?.outboundDetails || [])
|
||||
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
|
||||
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
|
||||
const reviewDialogOpen = ref(false); const reviewing = ref(false); const reviewDecision = ref<'APPROVE' | 'REJECT'>('APPROVE'); const reviewRemark = ref(''); const reviewKey = ref('')
|
||||
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
|
||||
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
|
||||
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
|
||||
{ key: 'operator', label: '操作人', minWidth: 120, formatter: () => '—' },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const dispositionColumns: DataTableColumn[] = [
|
||||
{ key: 'leg', label: '发生段', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.leg === 'OVERSEAS' ? '海外' : '国内' },
|
||||
{ key: 'source', label: '实收或原箱来源', minWidth: 180, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => (row.receipt_id ? detail.value?.physical.receipts.find(receipt => receipt.id === row.receipt_id)?.receiptNo : row.source_carton_id ? detail.value?.physical.cartons.find(box => box.id === row.source_carton_id)?.cartonNo : order.value?.orderNo) || '—' },
|
||||
{ key: 'kind', label: '差异', width: 100, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => ({ SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货' })[row.kind] },
|
||||
{ key: 'quantity', prop: 'quantity', label: '处置件数', width: 100 }, { key: 'reason', prop: 'reason', label: '处置依据', minWidth: 220 },
|
||||
{ key: 'active', label: '状态', width: 110, formatter: (row: PlatformProcurement['physical']['dispositions'][number]) => row.active ? '有效' : '已撤销或更正' },
|
||||
]
|
||||
const cartonColumns: DataTableColumn[] = [
|
||||
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 160 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 },
|
||||
{ key: 'measurements', label: '长 × 宽 × 高(cm)', minWidth: 220, formatter: (row: ProcurementCarton) => [row.lengthCm, row.widthCm, row.heightCm].map(value => measurement(value, 2)).join(' × ') },
|
||||
{ key: 'weight', label: '毛重(kg)', width: 120, formatter: (row: ProcurementCarton) => measurement(row.grossWeightKg, 3) },
|
||||
{ key: 'sources', label: '装货来源', minWidth: 240, formatter: (row: ProcurementCarton) => row.sources.map(source => `${detail.value?.physical.receipts.find(receipt => receipt.id === source.receiptId)?.receiptNo || '未关联实收'}:${source.quantity} 件`).join(';') },
|
||||
]
|
||||
const shipmentColumns: DataTableColumn[] = [
|
||||
{ key: 'shipmentNo', prop: 'shipmentNo', label: '交寄编号', minWidth: 180 }, { key: 'leg', label: '运输段', width: 110, formatter: (row: ProcurementShipment) => row.leg === 'OVERSEAS' ? '国际交寄' : '寄客户' },
|
||||
{ key: 'cartonNo', prop: 'cartonNo', label: '箱号', minWidth: 150 }, { key: 'quantity', prop: 'quantity', label: '件数', width: 85 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 100 }, { key: 'trackingNo', prop: 'trackingNo', label: '实际运单', minWidth: 180 },
|
||||
{ key: 'shippedAt', label: '交寄时间', minWidth: 160, formatter: (row: ProcurementShipment) => dateTime(row.shippedAt) },
|
||||
]
|
||||
const feeColumns: DataTableColumn[] = [
|
||||
{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'purpose', label: '用途', minWidth: 140, formatter: (row: ProcurementFee) => feePurposes[row.purpose] || '待核对' },
|
||||
{ key: 'name', prop: 'name', label: '费用项目', minWidth: 120 }, { key: 'original', label: '原币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.originalAmount, row.originalCurrency) },
|
||||
{ key: 'amount', label: '人民币金额', minWidth: 140, formatter: (row: ProcurementFee) => money(row.amount, row.currency) },
|
||||
{ key: 'rate', label: '汇率 / 月份', minWidth: 150, formatter: (row: ProcurementFee) => `${text(row.exchangeRate)} / ${text(row.exchangeRateMonth)}` },
|
||||
{ key: 'walletStatus', label: '资金状态', minWidth: 150, formatter: (row: ProcurementFee) => feeStatus(row) },
|
||||
{ key: 'sourceLinked', label: '费用来源关联', width: 130, formatter: (row: ProcurementFee) => row.purpose === 'INTERNATIONAL_ESTIMATE' ? '预估不入池' : row.purpose === 'REFUND' ? '退款不入应收' : ['ZERO', 'NOT_APPLICABLE'].includes(row.determination) ? '无需入池' : row.sourceLinked ? '已完成' : '待完成' },
|
||||
{ key: 'active', label: '版本状态', width: 100, formatter: (row: ProcurementFee) => row.active ? '有效' : '历史版本' },
|
||||
]
|
||||
const labelGroups = computed(() => detail.value ? [
|
||||
{ leg: 'OVERSEAS', title: '国际段', value: detail.value.transport.overseasLabels },
|
||||
{ leg: 'DOMESTIC', title: '寄客户段', value: detail.value.transport.domesticLabels },
|
||||
].filter(group => group.value) : [])
|
||||
const labelColumns: DataTableColumn[] = [
|
||||
{ key: 'cartonId', label: '原箱', minWidth: 150, formatter: (row: { cartonId: string }) => detail.value?.physical.cartons.find(box => box.id === row.cartonId)?.cartonNo || '未关联箱' },
|
||||
{ key: 'fileId', label: '面单文件', minWidth: 200, formatter: (row: { fileId: string }) => detail.value?.files.find(file => file.fileId === row.fileId)?.fileName || '未关联文件' },
|
||||
{ key: 'page', prop: 'page', label: '页码', width: 75 }, { key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 }, { key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 180 },
|
||||
{ key: 'appliedAt', label: '实际贴单时间', minWidth: 160, formatter: (row: { appliedAt: string | null }) => dateTime(row.appliedAt) },
|
||||
]
|
||||
const fileLabels: Record<string, string> = { PURCHASE: '购买凭证', BUDGET: '预算授权', LABEL: '快递面单', EXCEPTION: '异常证据', RECEIPT: '收货凭证', VOUCHER: '费用凭证' }
|
||||
const noteColumns: DataTableColumn[] = [
|
||||
{ key: 'note_no', prop: 'note_no', label: '说明编号', minWidth: 180 },
|
||||
{ key: 'target', label: '关联事实', minWidth: 180, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.target_type === 'PURCHASE' ? order.value?.orderNo || '—' : row.target_type === 'WAREHOUSE' ? row.original_value_json?.name || '实际仓记录' : row.target_type === 'RECEIPT' ? detail.value?.physical.receipts.find(item => item.id === row.target_id)?.receiptNo || '未关联实收' : detail.value?.transport.shipments.find(item => item.id === row.target_id)?.shipmentNo || '未关联交寄' },
|
||||
{ key: 'reason', prop: 'reason', label: '内部事实说明', minWidth: 260 },
|
||||
{ key: 'warehouseCorrection', label: '仓库更正留痕', minWidth: 190, formatter: (row: PlatformProcurement['controls']['notes'][number]) => row.corrected_value_json ? `${row.original_value_json?.name || '—'} → ${row.corrected_value_json.warehouseName || '—'}` : '—' },
|
||||
{ key: 'block', label: '阻断状态', width: 140, formatter: (row: PlatformProcurement['controls']['notes'][number]) => !row.blocking ? '仅说明' : row.resolved_at ? '已解除阻断' : '待处理阻断' },
|
||||
{ key: 'resolution_reason', prop: 'resolution_reason', label: '解除依据', minWidth: 220 },
|
||||
{ key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 220 },
|
||||
]
|
||||
const remainingColumns: DataTableColumn[] = [{ key: 'businessNo', prop: 'businessNo', label: '货物来源', minWidth: 180 }, { key: 'kind', label: '剩余类型', minWidth: 130, formatter: (row: PlatformProcurement['controls']['remaining'][number]) => remainingLabels[row.kind] }, { key: 'quantity', prop: 'quantity', label: '待处理件数', width: 130 }]
|
||||
const closureColumns: DataTableColumn[] = [{ key: 'closure_no', prop: 'closure_no', label: '关闭编号', minWidth: 180 }, { key: 'financial_conclusion', prop: 'financial_conclusion', label: '内部财务结论', minWidth: 250 }, { key: 'reason', prop: 'reason', label: '内部关闭原因', minWidth: 220 }, { key: 'client_summary', prop: 'client_summary', label: '客户可见摘要', minWidth: 250 }, { key: 'created_at', label: '关闭时间', minWidth: 170, formatter: (row: PlatformProcurement['controls']['closures'][number]) => dateTime(row.created_at) }]
|
||||
const closureItemColumns: DataTableColumn[] = [
|
||||
{ key: 'source', label: '处置来源', minWidth: 180, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => row.source_kind === 'PURCHASE_REMAINDER' ? order.value?.orderNo || '—' : row.source_kind === 'TRANSIT_REMAINDER' ? detail.value?.physical.cartons.find(item => item.id === row.source_id)?.cartonNo || '未关联原箱' : detail.value?.physical.receipts.find(item => item.id === row.source_id)?.receiptNo || '未关联实收' },
|
||||
{ key: 'source_kind', label: '来源类型', width: 130, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => remainingLabels[row.source_kind] },
|
||||
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90 }, { key: 'decision', label: '实际处置', minWidth: 150, formatter: (row: PlatformProcurement['controls']['closureItems'][number]) => decisionLabels[row.decision] || '待核对' }, { key: 'destination', prop: 'destination', label: '实际去向与接收方', minWidth: 260 },
|
||||
]
|
||||
const correctionColumns: DataTableColumn[] = [
|
||||
{ key: 'correctionNo', prop: 'correctionNo', label: '更正编号', minWidth: 190 }, { key: 'rootFeeNo', prop: 'rootFeeNo', label: '原费用编号', minWidth: 170 },
|
||||
{ key: 'targetOriginalAmount', label: '正确原币总额', minWidth: 150, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetOriginalAmount, row.originalCurrency) },
|
||||
{ key: 'targetAmount', label: '目标人民币', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.targetAmount, 'CNY') },
|
||||
{ key: 'previousNetAmount', label: '更正前净实扣', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.previousNetAmount, 'CNY') },
|
||||
{ key: 'differenceAmount', label: '本次差额', minWidth: 140, formatter: (row: PlatformProcurement['feeCorrections'][number]) => money(row.differenceAmount, 'CNY') },
|
||||
{ key: 'feeNos', label: '关联实际费用或调整', minWidth: 240, formatter: (row: PlatformProcurement['feeCorrections'][number]) => row.feeNos.join(';') || '无资金差额' },
|
||||
]
|
||||
const fileColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', slotName: 'actions', label: '操作', width: 90, fixed: 'left' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 250 },
|
||||
{ key: 'category', label: '用途', width: 120, formatter: (row: ProcurementFile) => fileLabels[row.category] || '待核对' },
|
||||
{ key: 'createdAt', label: '上传时间', minWidth: 160, formatter: (row: ProcurementFile) => dateTime(row.createdAt) },
|
||||
]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'createdAt', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['reviewHistory'][number]) => dateTime(row.createdAt) }, { key: 'operation', label: '操作', width: 120, formatter: (row: PlatformProcurement['reviewHistory'][number]) => ({ CREATE: '创建', REVIEW: '审核', UPDATE: '编辑备注' } as Record<string, string>)[row.operationType || ''] || '采购变更' }, { key: 'remark', prop: 'remark', label: '说明', minWidth: 250 }]
|
||||
const commandColumns: DataTableColumn[] = [{ key: 'created_at', label: '时间', minWidth: 160, formatter: (row: PlatformProcurement['operations'][number]) => dateTime(row.created_at) }, { key: 'reason', prop: 'reason', label: '履约操作说明', minWidth: 400 }]
|
||||
let generation = 0; let inFlight: Promise<void> | null = null; let inFlightScope = ''
|
||||
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
function load() {
|
||||
const currentScope = scope.value
|
||||
if (inFlight && inFlightScope === currentScope) return inFlight
|
||||
const current = ++generation; const number = purchaseId.value; const customer = customerId.value
|
||||
detail.value = null; loadError.value = null; loading.value = false
|
||||
if (!number || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return Promise.resolve() }
|
||||
loading.value = true; inFlightScope = currentScope
|
||||
const promise = getOverseasPurchase(number, customer).then(result => { if (current === generation) detail.value = result }).catch(error => { if (current === generation) loadError.value = getApiErrorDetails(error, '采购详情加载失败') }).finally(() => {
|
||||
if (current === generation) loading.value = false
|
||||
if (inFlight === promise) inFlight = null
|
||||
})
|
||||
inFlight = promise; return promise
|
||||
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
|
||||
function statusLabel(status?: string | null) { return status ? (statusLabels[status as OverseasPurchaseStatus] || status) : '—' }
|
||||
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
|
||||
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
|
||||
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `purchase-review-${Date.now()}-${Math.random().toString(16).slice(2)}` }
|
||||
function openReview(decision: 'APPROVE' | 'REJECT') {
|
||||
reviewDecision.value = decision; reviewRemark.value = ''; reviewKey.value = idempotencyKey(); reviewDialogOpen.value = true
|
||||
}
|
||||
async function download(fileId: unknown) {
|
||||
const file = detail.value?.files.find(item => item.fileId === fileId)
|
||||
if (!file || !order.value || downloading.value) return
|
||||
downloading.value = file.fileId; operationError.value = null; const current = scope.value
|
||||
function closeReview() {
|
||||
if (reviewing.value) return
|
||||
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!detail.value) return
|
||||
reviewing.value = true
|
||||
try {
|
||||
const blob = await downloadProcurementFile(order.value.orderNo, customerId.value, file.fileId)
|
||||
if (scope.value !== current) return
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch (error) { if (scope.value === current) operationError.value = getApiErrorDetails(error, '文件下载失败') }
|
||||
finally { downloading.value = null }
|
||||
detail.value = await reviewOverseasPurchase(detail.value.orderNo, customerId.value, {
|
||||
version: detail.value.version,
|
||||
decision: reviewDecision.value,
|
||||
idempotencyKey: reviewKey.value || (reviewKey.value = idempotencyKey()),
|
||||
remark: reviewRemark.value.trim() || undefined,
|
||||
})
|
||||
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
|
||||
ElMessage.success(reviewDecision.value === 'APPROVE' ? '采购订单审核已通过' : '采购订单已拒绝')
|
||||
} catch (error) { ElMessage.error(getApiErrorDetails(error, '采购订单审核失败').message) }
|
||||
finally { reviewing.value = false }
|
||||
}
|
||||
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
if (!file || !detail.value) return
|
||||
uploading.value = true
|
||||
try { const saved = await uploadOverseasPurchaseFile(detail.value.orderNo, customerId.value, category, file); if (category === 'VOUCHER') { voucher.value = saved; feeDialogOpen.value = true } else { ElMessage.success('签收文件已上传'); await load() } }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '文件上传失败').message) } finally { uploading.value = false }
|
||||
}
|
||||
async function saveFee() {
|
||||
if (!detail.value || !voucher.value || feeForm.amount <= 0 || !feeForm.feeItemName.trim()) { ElMessage.warning('请填写正确的费用名称和金额'); return }
|
||||
savingFee.value = true
|
||||
try { await createOverseasPurchaseFee(detail.value.orderNo, customerId.value, { feeItemCode: feeForm.feeItemCode.trim(), feeItemName: feeForm.feeItemName.trim(), amount: feeForm.amount, voucherFileId: voucher.value.fileId, remark: feeForm.remark.trim() || undefined }); feeDialogOpen.value = false; voucher.value = null; ElMessage.success('费用已登记并提交资金冻结'); await load() }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '费用登记失败').message) } finally { savingFee.value = false }
|
||||
}
|
||||
async function load() {
|
||||
detail.value = null; loadError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
|
||||
loading.value = true
|
||||
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
|
||||
}
|
||||
let activatedOnce = false
|
||||
watch(scope, () => { void load() }, { immediate: true })
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density"><OperationErrorToast :details="operationError" />
|
||||
<DetailPageLayout class="purchase-detail density-detail-layout" 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-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="order" :status="statusLabel(order.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑内部备注</el-button></div></div></template>
|
||||
<template v-if="order" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ text(order.customerName) }}</dd></div><div><dt>计划采购仓</dt><dd>{{ text(order.plannedWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ order.purchaseQuantity }}</dd></div></dl></template>
|
||||
<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="detail-grid">
|
||||
<template v-if="detail">
|
||||
<ProcurementNodeActions :detail="detail" :customer-id="customerId" @changed="load" />
|
||||
<section><h3>订单及预算</h3><dl class="density-fields"><div v-for="field in fields" :key="field[0]" :class="{ wide: ['下单收货地址', '内部备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>购买事实</h3><dl class="density-fields"><div v-for="field in purchaseFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>履约进度</h3><dl class="density-fields"><div v-for="step in detail.customerView.progress" :key="step.key"><dt>{{ step.title }}</dt><dd>{{ step.completedAt ? dateTime(step.completedAt) : '未完成' }}</dd></div></dl></section>
|
||||
<section><h3>分次实收记录</h3><DataTable :data="detail.physical.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无实收记录" /></section>
|
||||
<section><h3>差异处置记录</h3><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></section>
|
||||
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></section>
|
||||
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></section>
|
||||
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版</h3><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
|
||||
<section><h3>两段实际交寄</h3><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></section>
|
||||
<section><h3>费用与资金结果</h3><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
|
||||
<section><h3>费用更正记录</h3><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
|
||||
<section><h3>事实说明与阻断</h3><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></section>
|
||||
<section><h3>剩余货物去向核对</h3><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></section>
|
||||
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></section>
|
||||
<section><h3>本单文件</h3><DataTable :data="detail.files" :columns="fileColumns" row-key="fileId" empty-text="暂无文件"><template #actions="{ row }"><el-button class="density-action" :loading="downloading === row.fileId" :disabled="!!downloading" @click="download(row.fileId)">下载</el-button></template></DataTable></section>
|
||||
<section><h3>操作流水</h3><DataTable :data="detail.reviewHistory" :columns="historyColumns" empty-text="暂无基础操作记录" /><DataTable :data="detail.operations" :columns="commandColumns" empty-text="暂无履约操作记录" /></section>
|
||||
</template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="purchase-detail" 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-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.orderNo || '—' }}</h1><StatusBadge v-if="detail" :status="statusLabels[detail.status]" /></div>
|
||||
<div v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="header-actions"><el-button type="danger" plain @click="openReview('REJECT')"><X :size="16" />拒绝</el-button><el-button type="primary" @click="openReview('APPROVE')"><Check :size="16" />通过</el-button></div>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<template v-if="detail" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ display(detail.customerName) }}</dd></div><div><dt>采购仓库</dt><dd>{{ display(detail.purchaseWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ display(detail.purchaseQuantity) }}</dd></div><div><dt>入库量</dt><dd>{{ display(detail.inboundQuantity) }}</dd></div><div><dt>出库量</dt><dd>{{ display(detail.outboundQuantity) }}</dd></div><div><dt>库存量</dt><dd>{{ display(detail.inventoryQuantity) }}</dd></div></dl></template>
|
||||
<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="detail-surface">
|
||||
<div class="detail-grid">
|
||||
<section><h3>商品信息</h3><dl class="density-fields"><div v-for="field in productFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>库存信息</h3><dl class="density-fields"><div v-for="field in stockFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>订单信息</h3><dl class="density-fields"><div v-for="field in orderFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl class="density-fields"><div v-for="field in courierFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>签收文件与费用凭证</h3><div v-if="access.can(overseasBusinessPermissions.purchaseEdit)" class="section-actions"><input ref="receiptInput" hidden type="file" @change="upload('RECEIPT', $event)"><input ref="voucherInput" hidden type="file" @change="upload('VOUCHER', $event)"><el-button :loading="uploading" @click="receiptInput?.click()"><Upload :size="15" />上传签收文件</el-button><el-button type="primary" :loading="uploading" :disabled="!detail || ['PENDING_REVIEW', 'REJECTED'].includes(detail.status)" @click="voucherInput?.click()"><Upload :size="15" />登记费用</el-button></div></div><DataTable :data="detail?.files || []" :columns="fileColumns" row-key="id" empty-text="暂无签收文件或费用凭证" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>费用明细</h3><span>每条费用独立冻结,订单全部入库后结算。</span></div><DataTable :data="detail?.fees || []" :columns="feeColumns" row-key="id" empty-text="暂无费用明细" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
|
||||
</div>
|
||||
</div>
|
||||
</DetailPageLayout><AppDialog class="admin-density-dialog" v-model="reviewDialogOpen" :title="reviewDecision === 'APPROVE' ? '通过采购订单' : '拒绝采购订单'" width="min(520px, calc(100vw - 28px))" :confirm-text="reviewDecision === 'APPROVE' ? '确认通过' : '确认拒绝'" :confirm-loading="reviewing" @confirm="submitReview" @cancel="closeReview"><el-alert :title="reviewDecision === 'APPROVE' ? '通过后平台可维护采购入库、出库和费用信息。' : '拒绝后该采购订单将结束审核流程。'" :type="reviewDecision === 'APPROVE' ? 'success' : 'warning'" show-icon :closable="false" /><p class="review-reference">订单编号:{{ detail?.orderNo || '—' }}</p><el-form class="density-form review-form" label-position="top"><el-form-item label="审核备注"><el-input v-model="reviewRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="reviewDecision === 'APPROVE' ? '请输入通过说明(选填)' : '请输入拒绝原因(选填)'" /></el-form-item></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form class="density-form" label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.purchase-detail { border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
|
||||
.purchase-detail { gap: 0; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
|
||||
.header-actions, .section-actions { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||
.detail-surface { min-width: 0; padding: var(--admin-space-5); }
|
||||
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.detail-grid section { min-width: 0; }
|
||||
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
|
||||
.detail-grid p { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.density-fields > .wide { grid-column: span 2; }
|
||||
.detail-grid .density-fields > .wide { grid-column: span 2; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
|
||||
.section-heading h3 { margin: 0; }
|
||||
.section-heading span, .review-reference { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.review-reference { margin: var(--admin-space-3) 0; }
|
||||
:deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../overseasBusinessDensity.css'
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -13,78 +13,87 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getOverseasPurchaseEditor, updateOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { getOverseasPurchase, listOverseasPurchaseWarehouseOptions, updateOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseOutboundDetail, OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
interface PurchaseEditModel {
|
||||
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
|
||||
inboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; version: number
|
||||
outboundDetails: OverseasPurchaseOutboundDetail[]
|
||||
}
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const loading = ref(false); const saving = ref(false); const loaded = ref(false)
|
||||
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = () => ({ orderNo: '', productName: '', purchaseQuantity: 0, remark: '', version: 0 })
|
||||
const form = reactive(initial())
|
||||
const canSave = computed(() => loaded.value && !loading.value && !saving.value && access.can(overseasBusinessPermissions.purchaseEdit))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑采购内部备注'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
let generation = 0
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
function back() { return router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: purchaseId.value }, query: { customerId: customerId.value } }) }
|
||||
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehousesLoading = ref(false); const warehousesError = ref('')
|
||||
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0, outboundDetails: [] })
|
||||
const form = reactive<PurchaseEditModel>(initial())
|
||||
const rules: FormRules<PurchaseEditModel> = {
|
||||
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
}
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
|
||||
const inboundEditable = computed(() => ['APPROVED', 'PENDING_OUTBOUND', 'PARTIALLY_OUTBOUND', 'OUTBOUNDED'].includes(form.status))
|
||||
const outboundQuantity = computed(() => form.outboundDetails.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0))
|
||||
function addOutbound() { form.outboundDetails.push({ id: `new-${Date.now()}-${form.outboundDetails.length}`, quantity: 0, carrier: null, trackingNo: null, shippedAt: null, remark: null }) }
|
||||
function removeOutbound(index: number) { form.outboundDetails.splice(index, 1) }
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
async function loadWarehouses() {
|
||||
warehousesLoading.value = true; warehousesError.value = ''
|
||||
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
|
||||
catch { warehouses.value = []; warehousesError.value = '采购仓候选加载失败,请稍后重试' }
|
||||
finally { warehousesLoading.value = false }
|
||||
}
|
||||
async function load() {
|
||||
const current = ++generation; const orderNo = purchaseId.value; const customer = customerId.value
|
||||
loaded.value = false; loading.value = false; Object.assign(form, initial()); captureBaseline()
|
||||
loadError.value = null; submitError.value = null
|
||||
if (!orderNo || !customer) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getOverseasPurchaseEditor(orderNo, customer)
|
||||
if (current !== generation) return
|
||||
const order = detail.customerView.order
|
||||
Object.assign(form, { orderNo: order.orderNo, productName: order.productName, purchaseQuantity: order.purchaseQuantity, version: order.version, remark: detail.internalRemark ?? '' })
|
||||
loaded.value = true; captureBaseline()
|
||||
} catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '采购订单加载失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version, outboundDetails: (detail.outboundDetails || []).map((item) => ({ ...item })) })
|
||||
if (detail.purchaseWarehouseId && !warehouses.value.some((item) => item.warehouseId === detail.purchaseWarehouseId)) warehouses.value.push({ warehouseId: detail.purchaseWarehouseId, warehouseCode: '', warehouseName: detail.purchaseWarehouseName || '当前采购仓', countryCode: '' })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
if (outboundQuantity.value > form.inboundQuantity) { ElMessage.error('出库总量不能超过入库量'); return }
|
||||
saving.value = true; submitError.value = null
|
||||
const current = generation; const orderNo = purchaseId.value; const customer = customerId.value
|
||||
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null, outboundDetails: form.outboundDetails.map((item) => ({ ...item, quantity: Number(item.quantity) || 0, carrier: item.carrier?.trim() || null, trackingNo: item.trackingNo?.trim() || null, remark: item.remark?.trim() || null })) }
|
||||
try {
|
||||
const updated = await updateOverseasPurchase(orderNo, customer, { version: form.version, remark: form.remark.trim() || null })
|
||||
if (current !== generation) return
|
||||
form.version = updated.version; captureBaseline(); ElMessage.success('内部备注已保存')
|
||||
await back()
|
||||
} catch (error) { if (current === generation) submitError.value = getApiErrorDetails(error, '内部备注保存失败') }
|
||||
finally { saving.value = false }
|
||||
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
|
||||
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
|
||||
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
|
||||
}
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density overseas-business-editor">
|
||||
<OperationErrorToast :details="submitError" />
|
||||
<DetailPageLayout class="overseas-business-form-layout" aria-label="编辑采购内部备注">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading">
|
||||
<el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回详情</el-button>
|
||||
<h1 class="density-detail-title">编辑采购内部备注</h1><span class="form-reference">{{ form.orderNo || '—' }}</span>
|
||||
</div></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="form-panel">
|
||||
<el-form class="density-form" :model="form" label-position="top" :disabled="!canSave">
|
||||
<FormSection class="density-form-section" title="采购订单"><div class="form-grid">
|
||||
<el-form-item label="订单编号"><el-input :model-value="form.orderNo || '—'" readonly /></el-form-item>
|
||||
<el-form-item label="商品名称"><el-input :model-value="form.productName || '—'" readonly /></el-form-item>
|
||||
<el-form-item label="采购数量"><el-input :model-value="loaded ? form.purchaseQuantity : '—'" readonly /></el-form-item>
|
||||
<el-form-item class="span-3" label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="仅供平台内部使用" /></el-form-item>
|
||||
</div></FormSection>
|
||||
</el-form>
|
||||
<footer class="form-actions"><el-button type="primary" :disabled="!canSave" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="back">取消</el-button></footer>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
<AdminPage class="admin-density overseas-business-editor"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout" 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-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回详情</el-button><h1 class="density-detail-title">编辑海外采购订单</h1><span class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<FormSection class="density-form-section" title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" placeholder="请输入商品规格" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" placeholder="请输入入库数量" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input :model-value="outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" placeholder="请输入国内承运商" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" placeholder="请输入国内物流单号" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" placeholder="请输入海外承运商" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" placeholder="请输入海外物流单号" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="出库明细"><div class="outbound-head"><span>出库总量:<b>{{ outboundQuantity }}</b> / {{ form.inboundQuantity }}</span><el-button class="density-action" data-tone="primary" size="small" @click="addOutbound">添加出库明细</el-button></div><div v-if="!form.outboundDetails.length" class="empty-outbound">暂无出库明细,可添加多条出库记录</div><div v-for="(item, index) in form.outboundDetails" :key="item.id" class="outbound-row"><el-form-item label="出库数量"><el-input-number v-model="item.quantity" :min="0" :max="form.inboundQuantity" controls-position="right" placeholder="请输入出库数量" /></el-form-item><el-form-item label="承运商"><el-input v-model="item.carrier" maxlength="128" placeholder="请输入快递或物流名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="item.trackingNo" maxlength="128" placeholder="请输入物流单号" /></el-form-item><el-form-item label="出库时间"><el-date-picker v-model="item.shippedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item class="outbound-remark" label="备注"><el-input v-model="item.remark" maxlength="500" placeholder="请输入本次出库备注(选填)" /></el-form-item><el-button class="remove-outbound density-action" data-tone="danger" @click="removeOutbound(index)" :aria-label="`删除第 ${index + 1} 条出库明细`">删除</el-button></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.span-2 { grid-column: span 2; }
|
||||
.span-3 { grid-column: 1 / -1; }
|
||||
@media (max-width: 760px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
.outbound-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-body); }
|
||||
.outbound-head b { color: var(--cc-text); }
|
||||
.empty-outbound { padding: var(--admin-space-5); color: var(--cc-muted); text-align: center; }
|
||||
.outbound-row { display: grid; grid-template-columns: 110px minmax(0,1fr) minmax(0,1fr) 190px minmax(0,1fr) 60px; gap: var(--admin-space-3); align-items: start; padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.outbound-row :deep(.el-form-item) { margin-bottom: 0; }
|
||||
.outbound-row :deep(.el-input-number), .outbound-row :deep(.el-date-editor) { width: 100%; }
|
||||
.remove-outbound { align-self: end; }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { procurementStatuses, statusLabel, money } from '../procurementPresentation'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -16,50 +14,43 @@ import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../a
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
|
||||
|
||||
const router = useRouter(); const access = useAccessPreviewStore(); const auth = useAuthStore()
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value })
|
||||
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
|
||||
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130, singleLine: false },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 220, singleLine: false },
|
||||
{ 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: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
|
||||
{ key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
|
||||
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
|
||||
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
|
||||
function purchaseStatusLabel(status: OverseasPurchaseStatus) { return statusLabels[status] || status }
|
||||
function formatAmount(value: string | null, currency: string) { if (value == null) return '—'; const amount = Number(value); return `${Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value} ${currency}` }
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
let generation = 0; let inFlight: Promise<void> | null = null; let requestKey = ''
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
function load() {
|
||||
const query: OverseasPurchaseQuery = { page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || ''), status: String(applied.value.status || '') as OverseasPurchaseStatus | '' }
|
||||
const key = JSON.stringify([auth.user?.id, query])
|
||||
if (inFlight && key === requestKey) return inFlight
|
||||
requestKey = key; const current = ++generation
|
||||
async function load() {
|
||||
loading.value = true; loadError.value = null
|
||||
const promise = listOverseasPurchases(query).then(result => {
|
||||
if (current !== generation) return
|
||||
try {
|
||||
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
}).catch(error => { if (current === generation) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '采购订单加载失败,请稍后重试') } }).finally(() => {
|
||||
if (current === generation) loading.value = false
|
||||
if (inFlight === promise) inFlight = null
|
||||
})
|
||||
inFlight = promise; return promise
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
}
|
||||
watch(() => auth.user?.id, () => { rows.value = []; total.value = 0; void load() })
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-data'
|
||||
import '../overseasBusinessDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -23,7 +22,6 @@ import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const routeOpen = ref(false)
|
||||
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const routeCustomerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
@@ -126,8 +124,8 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
|
||||
<FormSection class="density-form-section" title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" placeholder="请输入托盘数" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
|
||||
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
|
||||
</el-form><FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { inquiryReturnTarget, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -20,14 +19,7 @@ import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const routeOpen = ref(false)
|
||||
const routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
function openRoute(row: LocalTransportOrder | LocalLooseboardOrder) {
|
||||
routeOrigin.value = routeLocation(row.origin)
|
||||
routeDestinations.value = [routeLocation(row.destination)]
|
||||
routeOpen.value = true
|
||||
}
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
|
||||
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
|
||||
@@ -36,13 +28,12 @@ const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const l
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
|
||||
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
|
||||
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
|
||||
])
|
||||
@@ -67,38 +58,25 @@ const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const columns = computed(() => {
|
||||
if (!isLooseboard.value) return truckColumns
|
||||
const visible = [...looseboardColumns]
|
||||
visible.splice(visible.findIndex(column => column.key === 'status'), 0,
|
||||
{ key: 'routeMap', label: '路线地图', width: 120, slotName: 'routeMap' })
|
||||
return visible
|
||||
})
|
||||
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
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
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
|
||||
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
} else {
|
||||
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, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
|
||||
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 }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route.query.orderNo], () => { page.value = 1; applied.value = { orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -106,7 +84,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}订单`">
|
||||
<template #toolbar-start><el-button v-if="inquiryReturnTarget(route.query)" link @click="router.push(inquiryReturnTarget(route.query)!)">返回原拼车登记</el-button><h1>{{ domainLabel }}</h1></template>
|
||||
<template #toolbar-start><h1>{{ domainLabel }}</h1></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>
|
||||
|
||||
@@ -114,9 +92,8 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog><TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" /></AdminPage>
|
||||
</ListPageLayout><AppDialog v-model="arrangementOpen" class="admin-density-dialog" title="承运安排" width="1080px" :show-confirm="false" cancel-text="关闭"><FleetArrangementSummary v-if="arrangement" :arrangement="arrangement" /></AppDialog></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>.table-panel { overflow:hidden; }</style>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildControl, controlForm } from './procurementControlForm'
|
||||
import { readPendingProcurement } from './procurementPending'
|
||||
import type { PlatformProcurement, ProcurementControlNote, ProcurementFile } from './procurementTypes'
|
||||
const base = { version: 7, idempotencyKey: 'control-one', reason: '内部处理依据' }
|
||||
const proof: ProcurementFile = { id: '51', fileId: '501', category: 'EXCEPTION', fileName: 'agreement.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }
|
||||
function detail(): PlatformProcurement {
|
||||
return {
|
||||
customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '10001', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'OVERSEAS_RECEIVING', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [{ shipmentNo: 'PS-1', leg: 'OVERSEAS', cartonNo: 'C1', quantity: 5, shippedAt: '2020-01-03T10:00:00', carrier: 'UPS', trackingNo: 'TRACK-1' }], fees: [], exceptionSummaries: [] },
|
||||
purchaseFacts: { actual_warehouse_id: '20', purchased_quantity: 10, purchased_at: '2020-01-01T10:00:00', overseas_shipped_at: '2020-01-03T10:00:00' },
|
||||
physical: { receipts: [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 6, damagedQuantity: 1, extraQuantity: 2, receivedAt: '2020-01-02T10:00:00', active: true }, { id: '12', receiptNo: 'PR-12', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T12:00:00', active: true }], dispositions: [], packingVersion: 2, packingStatus: 'DRAFT', cartons: [{ id: '31', cartonNo: 'C1', quantity: 5, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 5 }] }, { id: '32', cartonNo: 'C2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }] }] },
|
||||
transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [{ key: 'EXCEPTION_CLOSE', label: '异常关闭', permission: 'platform:overseas-business:purchase:manage', allowed: true, reason: null }], internalRemark: null,
|
||||
}
|
||||
}
|
||||
function note(): ProcurementControlNote { return { id: '71', note_no: 'PN-71', target_type: 'PURCHASE', target_id: null, blocking: true, reason: '待处理', client_summary: null, created_at: '', resolved_at: null, resolution_reason: null, voucher_file_id: '501', resolution_voucher_id: null, original_value_json: null, corrected_value_json: null } }
|
||||
function closure() { const value = detail(); value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-1', quantity: 3 }, { kind: 'GOOD', sourceId: '11', businessNo: 'PR-11', quantity: 6 }]; const form = controlForm(value); form.customerVoucherFileId = '501'; form.clientSummary = '客户同意停止寄送'; form.financialConclusion = '所有资金处理已结束'; for (const row of form.resolutions) Object.assign(row, { decision: row.kind === 'PURCHASE_REMAINDER' ? 'SUPPLIER_RETAINED' : 'CUSTOMER_AUTHORIZED_RETENTION', destination: '明确的接收方与留存地点', voucherFileId: '501' }); return { value, form } }
|
||||
describe('procurement exception control requests', () => {
|
||||
it('adds a purchase note without leaking the internal reason into a customer summary', () => {
|
||||
const value = detail(); const form = controlForm(value); form.targetId = 'stale-receipt'
|
||||
const payload = buildControl('control-notes', form, value, [proof], base, '', '501')
|
||||
expect(payload).toEqual({ ...base, targetType: 'PURCHASE', blocking: true, voucherFileId: '501' })
|
||||
expect(payload).not.toHaveProperty('clientSummary'); expect(payload).not.toHaveProperty('targetId')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'control-notes', payload }))).not.toBeNull()
|
||||
})
|
||||
it('requires actual order-owned facts and allows notes against historical receipts', () => {
|
||||
const value = detail(); const form = controlForm(value); form.targetType = 'RECEIPT'; form.targetId = 'other'
|
||||
expect(() => buildControl('control-notes', form, value, [proof], base, '', '501')).toThrow('本单实收')
|
||||
form.targetId = '11'; value.physical.receipts[0].active = false; expect(buildControl('control-notes', form, value, [proof], base, '', '501').targetId).toBe('11')
|
||||
value.transport.shipments = [{ id: '41', shipmentNo: 'PS-41', leg: 'OVERSEAS', shippedAt: '2020-01-03T10:00:00' }]; form.targetType = 'SHIPMENT'; form.targetId = '41'
|
||||
expect(buildControl('control-notes', form, value, [proof], base, '', '501').targetId).toBe('41')
|
||||
form.targetId = '42'; expect(() => buildControl('control-notes', form, value, [proof], base, '', '501')).toThrow('本单实际交寄')
|
||||
})
|
||||
it('only resolves a still-active blocker with order evidence', () => {
|
||||
const value = detail(); value.controls.notes = [note()]; const form = controlForm(value); form.noteId = '71'
|
||||
expect(buildControl('control-notes/resolve', form, value, [proof], base, '', '501')).toEqual({ ...base, noteId: '71', voucherFileId: '501' })
|
||||
value.controls.notes[0].resolved_at = '2020-01-03T10:00:00'; expect(() => buildControl('control-notes/resolve', form, value, [proof], base, '', '501')).toThrow('尚未处理')
|
||||
})
|
||||
it('requires a distinct actual warehouse and sends no direct inventory mutation', () => {
|
||||
const value = detail(); const form = controlForm(value)
|
||||
expect(() => buildControl('actual-warehouse/correct', form, value, [proof], base, '20', '501')).toThrow('不同')
|
||||
expect(buildControl('actual-warehouse/correct', form, value, [proof], base, '30', '501')).toEqual({ ...base, warehouseId: '30', voucherFileId: '501' })
|
||||
})
|
||||
it('closes with exact remaining sources, explicit destinations and separate private/public conclusions', () => {
|
||||
const { value, form } = closure(); const payload = buildControl('exception-close', form, value, [proof], base, '', '')
|
||||
expect(payload.resolutions?.map(row => [row.kind, row.sourceId, row.quantity])).toEqual([['PURCHASE_REMAINDER', null, 3], ['GOOD', '11', 6]])
|
||||
expect(payload).toMatchObject({ customerVoucherFileId: '501', financialConclusion: '所有资金处理已结束', clientSummary: '客户同意停止寄送' })
|
||||
expect(payload).not.toHaveProperty('amount'); expect(payload).not.toHaveProperty('status')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'exception-close', payload }))).toEqual({ command: 'exception-close', payload })
|
||||
})
|
||||
it('rejects omitted, duplicated or changed remaining quantities instead of forcing closure', () => {
|
||||
let { value, form } = closure(); form.resolutions.pop(); expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('去向未明确')
|
||||
;({ value, form } = closure()); form.resolutions.push({ ...form.resolutions[0] }); expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('不可重复')
|
||||
;({ value, form } = closure()); form.resolutions[0].quantity = 2; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('改数量')
|
||||
})
|
||||
it('rejects wrong source dispositions, missing evidence and financial blockers', () => {
|
||||
let { value, form } = closure(); form.resolutions[0].decision = 'DISPOSED'; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('来源不匹配')
|
||||
;({ value, form } = closure()); form.customerVoucherFileId = ''; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('客户确认凭证')
|
||||
;({ value, form } = closure()); value.actions[0].allowed = false; value.actions[0].reason = '未决资金请求'; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('未决资金请求')
|
||||
;({ value, form } = closure()); value.controls.notes = [note()]; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('未解除')
|
||||
})
|
||||
it('allows an empty remaining list only with the required customer and financial conclusions', () => {
|
||||
const value = detail(); const form = controlForm(value); form.customerVoucherFileId = '501'; form.financialConclusion = '无未决费用或冻结'; form.clientSummary = '履约已停止'
|
||||
expect(buildControl('exception-close', form, value, [proof], base, '', '').resolutions).toEqual([])
|
||||
form.financialConclusion = ''; expect(() => buildControl('exception-close', form, value, [proof], base, '', '')).toThrow('财务处理结论')
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { PlatformProcurement, ProcurementFile, ProcurementRemaining, ProcurementRemainingKind } from './procurementTypes'
|
||||
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
|
||||
export const controlCommands = new Set<ProcurementCommand>(['control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
|
||||
export const remainingLabels: Record<ProcurementRemainingKind, string> = { PURCHASE_REMAINDER: '采购未到货', TRANSIT_REMAINDER: '国际段未到货', GOOD: '合格货物', DAMAGED: '破损货物', EXTRA: '多到货物' }
|
||||
export const decisionLabels: Record<string, string> = { SUPPLIER_RETAINED: '供应商保留', RETURNED_TO_SUPPLIER: '已退回供应商', RETURNED_TO_SENDER: '已退回寄件方', LOSS_CONFIRMED: '已确认丢失', CUSTOMER_AUTHORIZED_RETENTION: '客户授权留存', DISPOSED: '已处置' }
|
||||
export const allowedDecisions: Record<ProcurementRemainingKind, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
|
||||
export interface ResolutionDraft extends ProcurementRemaining { decision: string; destination: string; voucherFileId: string }
|
||||
export interface ControlForm { targetType: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId: string; blocking: boolean; clientSummary: string; noteId: string; customerVoucherFileId: string; financialConclusion: string; resolutions: ResolutionDraft[] }
|
||||
export function controlForm(detail: PlatformProcurement): ControlForm { return { targetType: 'PURCHASE', targetId: '', blocking: true, clientSummary: '', noteId: '', customerVoucherFileId: '', financialConclusion: '', resolutions: detail.controls.remaining.map(row => ({ ...row, decision: '', destination: '', voucherFileId: '' })) } }
|
||||
function proof(files: ProcurementFile[], id: string) { if (!files.some(row => row.fileId === id && row.category === 'EXCEPTION')) throw new Error('请选择本单异常或客户确认凭证') }
|
||||
function sourceKey(row: { kind: string; sourceId: string | null }) { return `${row.kind}:${row.sourceId ?? ''}` }
|
||||
export function buildControl(command: ProcurementCommand, form: ControlForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
|
||||
if (command === 'control-notes') {
|
||||
let targetId: string | undefined
|
||||
if (form.targetType === 'PURCHASE') { if (!detail.purchaseFacts.purchased_at) throw new Error('尚无真实购买事实,不能关联购买说明') }
|
||||
else if (form.targetType === 'WAREHOUSE') { if (!detail.purchaseFacts.actual_warehouse_id) throw new Error('尚无实际收货仓'); targetId = detail.purchaseFacts.actual_warehouse_id }
|
||||
else if (form.targetType === 'RECEIPT') { if (!detail.physical.receipts.some(row => row.id === form.targetId)) throw new Error('请选择本单实收记录'); targetId = form.targetId }
|
||||
else { if (!detail.transport.shipments.some(row => row.id === form.targetId)) throw new Error('请选择本单实际交寄记录'); targetId = form.targetId }
|
||||
proof(files, voucherFileId)
|
||||
if (form.clientSummary.trim().length > 500) throw new Error('对客摘要最多 500 字')
|
||||
return { ...base, targetType: form.targetType, ...(targetId ? { targetId } : {}), blocking: form.blocking, voucherFileId, ...(form.clientSummary.trim() ? { clientSummary: form.clientSummary.trim() } : {}) }
|
||||
}
|
||||
if (command === 'control-notes/resolve') {
|
||||
if (!detail.controls.notes.some(note => note.id === form.noteId && note.blocking && !note.resolved_at)) throw new Error('请选择本单尚未处理的阻断异常')
|
||||
proof(files, voucherFileId); return { ...base, noteId: form.noteId, voucherFileId }
|
||||
}
|
||||
if (command === 'actual-warehouse/correct') {
|
||||
if (!detail.purchaseFacts.actual_warehouse_id) throw new Error('尚无实际仓,请使用计划仓调整')
|
||||
if (!warehouseId || warehouseId === detail.purchaseFacts.actual_warehouse_id) throw new Error('请选择与原实际仓不同的有效海外收货仓')
|
||||
proof(files, voucherFileId); return { ...base, warehouseId, voucherFileId }
|
||||
}
|
||||
const action = detail.actions.find(row => row.key === 'EXCEPTION_CLOSE')
|
||||
if (!action?.allowed) throw new Error(action?.reason || '尚不满足异常关闭条件')
|
||||
if (!detail.purchaseFacts.purchased_at) throw new Error('购买前请使用取消订单')
|
||||
if (detail.controls.notes.some(note => note.blocking && !note.resolved_at)) throw new Error('请先处理本单未解除的阻断异常')
|
||||
proof(files, form.customerVoucherFileId)
|
||||
const expected = new Map(detail.controls.remaining.map(row => [sourceKey(row), row]))
|
||||
const resolutions = form.resolutions.map(row => {
|
||||
const source = expected.get(sourceKey(row))
|
||||
if (!source || !Number.isSafeInteger(row.quantity) || row.quantity <= 0 || source.quantity !== row.quantity) throw new Error('处置必须恰好覆盖每份剩余来源,不可重复、跨单或改数量')
|
||||
expected.delete(sourceKey(row))
|
||||
if (!allowedDecisions[row.kind].includes(row.decision)) throw new Error('处置方式与剩余货物来源不匹配')
|
||||
if (!row.destination.trim() || row.destination.trim().length > 500) throw new Error('请填写每份货物的实际去向及接收方,最多 500 字')
|
||||
proof(files, row.voucherFileId)
|
||||
return { kind: row.kind, sourceId: row.sourceId, quantity: row.quantity, decision: row.decision, destination: row.destination.trim(), voucherFileId: row.voucherFileId }
|
||||
})
|
||||
if (expected.size) throw new Error('仍有剩余货物去向未明确')
|
||||
if (!form.financialConclusion.trim() || form.financialConclusion.trim().length > 1000) throw new Error('请填写财务处理结论,最多 1000 字')
|
||||
if (!form.clientSummary.trim() || form.clientSummary.trim().length > 500) throw new Error('请填写对客关闭摘要,最多 500 字')
|
||||
return { ...base, resolutions, customerVoucherFileId: form.customerVoucherFileId, financialConclusion: form.financialConclusion.trim(), clientSummary: form.clientSummary.trim() }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface ProcurementCorrectionDraft {
|
||||
feeId: string
|
||||
targetOriginalAmount: string
|
||||
reason: string
|
||||
voucherFileId: string
|
||||
orderVersion: number
|
||||
}
|
||||
|
||||
export function readCorrectionDraft(raw: string | null): ProcurementCorrectionDraft | null {
|
||||
if (!raw) return null
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!value || typeof value !== 'object') throw new Error('费用更正草稿无法读取')
|
||||
const draft = value as Record<string, unknown>
|
||||
if (!['feeId', 'targetOriginalAmount', 'reason', 'voucherFileId'].every(key => typeof draft[key] === 'string') || !Number.isSafeInteger(draft.orderVersion)) throw new Error('费用更正草稿无法读取')
|
||||
return { feeId: draft.feeId as string, targetOriginalAmount: draft.targetOriginalAmount as string, reason: draft.reason as string, voucherFileId: draft.voucherFileId as string, orderVersion: draft.orderVersion as number }
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildFinance, financeForm, feeRetryPermission, refundAvailable, targetPreview, replacementCandidates, replacementPreview } from './procurementFinanceForm'
|
||||
import { readPendingProcurement } from './procurementPending'
|
||||
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
|
||||
const base = { version: 7, idempotencyKey: 'finance-one', reason: '凭证核对完成' }
|
||||
const proof: ProcurementFile = { id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }
|
||||
function fee(id = '1', purpose = 'DUTY'): ProcurementInternalFee { return { id, feeNo: `PF-${id}`, purpose, confirmationOrigin: 'BUSINESS', feeItemCode: purpose, feeItemName: purpose, feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7.000000', exchangeRateMonth: '2020-01', businessDate: '2020-01-02', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'SETTLED', walletLedgerId: `900${id}`, walletErrorCode: null, walletErrorMessage: null, remark: null } }
|
||||
function detail(fees: ProcurementInternalFee[] = []): PlatformProcurement {
|
||||
return { customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '9', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: null, actualWarehouseName: null, actualWarehouseCode: null, status: 'WAIT_CHARGES', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: fees.map(row => ({ feeNo: row.feeNo, purpose: row.purpose, name: row.feeItemName, version: row.feeVersion, active: row.active, determination: row.determination, amount: row.amount, currency: row.currency, originalAmount: row.originalAmount, originalCurrency: row.originalCurrency, exchangeRate: row.exchangeRate, exchangeRateMonth: row.exchangeRateMonth, businessDate: row.businessDate, parentFeeNo: null, walletStatus: row.walletStatus, sourceLinked: ['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE'].includes(row.walletStatus) })), exceptionSummaries: [] }, purchaseFacts: { domestic_received_at: '2020-01-04T10:00:00' }, physical: { receipts: [], dispositions: [], packingVersion: 1, packingStatus: 'CONFIRMED', cartons: [] }, transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, files: [proof], fees, feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, reviewHistory: [], operations: [], actions: [], internalRemark: null }
|
||||
}
|
||||
function chain() { const root = fee(); const extra = { ...fee('2', 'ADJUSTMENT'), amount: '20.00', originalAmount: '20.00', originalCurrency: 'CNY', exchangeRate: '1', parentFeeId: '1' }; const refund = { ...fee('3', 'REFUND'), amount: '10.00', originalAmount: '10.00', originalCurrency: 'CNY', exchangeRate: '1', parentFeeId: '1', walletStatus: 'REFUNDED' }; return detail([root, extra, refund]) }
|
||||
describe('procurement fee determination and correction rules', () => {
|
||||
it('builds independent additional fees with a scoped voucher and recoverable payload', () => {
|
||||
const value = detail(); value.purchaseFacts.purchased_at = '2020-01-02T10:00:00'
|
||||
const form = financeForm(value)
|
||||
Object.assign(form.additional, { name: '保管费', amount: '3.25', currency: 'USD', businessDate: '2020-01-03', voucherFileId: '501' })
|
||||
const payload = buildFinance('fees/additional', form, value, [proof], base, '')
|
||||
expect(payload).toEqual({ ...base, name: '保管费', amount: '3.25', currency: 'USD', businessDate: '2020-01-03', voucherFileId: '501' })
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'fees/additional', payload }))).toEqual({ command: 'fees/additional', payload })
|
||||
expect(() => buildFinance('fees/additional', form, value, [], base, '')).toThrow('凭证')
|
||||
form.additional.businessDate = '2999-01-01'
|
||||
expect(() => buildFinance('fees/additional', form, value, [proof], base, '')).toThrow('真实业务日期')
|
||||
delete value.purchaseFacts.purchased_at
|
||||
expect(() => buildFinance('fees/additional', form, value, [proof], base, '')).toThrow('已发生购买')
|
||||
})
|
||||
it('prevents ordinary creation after initial confirmation', () => {
|
||||
const value = detail(); value.operations.push({ action: 'CONFIRM_CHARGES' } as PlatformProcurement['operations'][number])
|
||||
expect(() => buildFinance('charges/confirm', financeForm(value), value, [proof], base, '')).toThrow('首次核费后')
|
||||
expect(feeRetryPermission({ ...fee(), confirmationOrigin: 'FINANCIAL' })).toContain('finance-adjust')
|
||||
})
|
||||
|
||||
it('requires every fixed determination and does not treat missing values as zero', () => {
|
||||
const value = detail(); const form = financeForm(value)
|
||||
expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('未知不能视为零元')
|
||||
for (const row of form.fixed) Object.assign(row, { determination: 'ZERO', amount: '0.00', businessDate: '2020-01-04', reason: '明确免收' })
|
||||
const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
|
||||
expect(payload.internationalFreight?.value?.determination).toBe('ZERO'); expect(payload.otherDetermination).toBe('NOT_APPLICABLE'); expect(payload.otherFees).toEqual([])
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'charges/confirm', payload }))).toEqual({ command: 'charges/confirm', payload })
|
||||
})
|
||||
it('requires evidence for a positive amount and validates zero and not-applicable amounts', () => {
|
||||
const value = detail(); const form = financeForm(value)
|
||||
for (const row of form.fixed) Object.assign(row, { determination: 'NOT_APPLICABLE', amount: '0.00', businessDate: '2020-01-04', reason: '不适用' })
|
||||
Object.assign(form.fixed[0], { determination: 'POSITIVE', amount: '10.01' })
|
||||
expect(() => buildFinance('charges/confirm', form, value, [], base, '')).toThrow('费用凭证')
|
||||
form.fixed[0].voucherFileId = '501'; const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
|
||||
expect(payload.internationalFreight?.value).toMatchObject({ amount: '10.01', voucherFileId: '501' })
|
||||
form.fixed[1].amount = '2.00'; expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('0.00')
|
||||
})
|
||||
it('reuses immutable existing fee versions without sending new charge values', () => {
|
||||
const value = detail([fee('1', 'INTERNATIONAL_FREIGHT'), fee('2', 'DUTY'), fee('3', 'DOMESTIC_FREIGHT'), fee('4', 'OTHER')]); const form = financeForm(value)
|
||||
const payload = buildFinance('charges/confirm', form, value, [proof], base, '')
|
||||
expect(payload.internationalFreight).toEqual({ feeId: '1' }); expect(payload.duty).toEqual({ feeId: '2' }); expect(payload.domesticFreight).toEqual({ feeId: '3' }); expect(payload.otherFees?.[0].selection).toEqual({ feeId: '4' })
|
||||
form.others = []; form.otherDetermination = 'NOT_APPLICABLE'; expect(() => buildFinance('charges/confirm', form, value, [proof], base, '')).toThrow('省略有效费用')
|
||||
})
|
||||
it('calculates a target using the original frozen rate and net successful chain only', () => {
|
||||
const value = chain(); expect(targetPreview(value, value.fees[0], '12.00')).toEqual({ target: '84.00', net: '80.00', difference: '4.00' })
|
||||
const form = financeForm(value); form.feeId = '1'; form.targetOriginalAmount = '12.00'
|
||||
expect(buildFinance('fees/correct-target', form, value, [proof], base, '501')).toEqual({ ...base, feeId: '1', targetOriginalAmount: '12.00', voucherFileId: '501' })
|
||||
expect(() => targetPreview(value, value.fees[1], '12.00')).toThrow('原实际费用')
|
||||
})
|
||||
it('treats actual international freight as the correction root even when it references the original estimate', () => {
|
||||
const actual = { ...fee('2', 'INTERNATIONAL_FREIGHT'), parentFeeId: '1' }
|
||||
const estimate = { ...fee('1', 'INTERNATIONAL_ESTIMATE'), amount: '100.00', walletStatus: 'SETTLED' }
|
||||
const value = detail([estimate, actual]); const form = financeForm(value); form.feeId = '2'; form.targetOriginalAmount = '12.00'
|
||||
expect(targetPreview(value, actual, '12.00')).toEqual({ target: '84.00', net: '70.00', difference: '14.00' })
|
||||
expect(buildFinance('fees/correct-target', form, value, [proof], base, '501').feeId).toBe('2')
|
||||
})
|
||||
it('replaces only the latest terminated unpaid version using its frozen rate and preserves original amounts', () => {
|
||||
const original = { ...fee('1', 'INTERNATIONAL_FREIGHT'), active: false, walletStatus: 'TERMINATED', walletLedgerId: null }
|
||||
const value = detail([original]); const form = financeForm(value); form.feeId = '1'; form.targetOriginalAmount = '12.00'
|
||||
expect(replacementPreview(value, original, '12.00')).toEqual({ target: '84.00', net: '0.00', difference: '84.00' })
|
||||
const payload = buildFinance('fees/replace-failed', form, value, [proof], base, '501')
|
||||
expect(payload).toEqual({ ...base, feeId: '1', targetOriginalAmount: '12.00', voucherFileId: '501' }); expect(readPendingProcurement(JSON.stringify({ command: 'fees/replace-failed', payload }))).not.toBeNull()
|
||||
value.fees.push({ ...fee('2', 'INTERNATIONAL_FREIGHT'), feeVersion: 2, walletStatus: 'FREEZE_PENDING' })
|
||||
expect(replacementCandidates(value)).toEqual([]); expect(original.amount).toBe('70.00')
|
||||
})
|
||||
it('keeps sample replacement within customer authorization and makes financial replacements retry under finance permission', () => {
|
||||
const original = { ...fee('1', 'SAMPLE_PAYMENT'), active: false, walletStatus: 'RELEASED' }; const value = detail([original])
|
||||
expect(() => replacementPreview(value, original, '12.00')).toThrow('客户授权预算')
|
||||
value.customerView.budget.totalAmount = '100.00'; expect(replacementPreview(value, original, '12.00').target).toBe('84.00')
|
||||
const replacement = { ...fee('2', 'SAMPLE_PAYMENT'), confirmationOrigin: 'FINANCIAL' as const, walletStatus: 'FREEZE_FAILED', feeVersion: 2 }; value.fees.push(replacement)
|
||||
value.feeCorrections = [{ correctionNo: 'PC-1', rootFeeNo: 'PF-1', targetOriginalAmount: '12.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', targetAmount: '84.00', previousNetAmount: '0.00', differenceAmount: '84.00', feeNos: ['PF-2'] }]
|
||||
expect(feeRetryPermission(replacement)).toBe('platform:overseas-business:purchase:finance-adjust')
|
||||
})
|
||||
it('preserves cent accuracy beyond JavaScript safe integers and rounds half up', () => {
|
||||
const value = detail([fee()]); value.fees[0].exchangeRate = '1.000000'
|
||||
expect(targetPreview(value, value.fees[0], '90071992547409.93')).toEqual({ target: '90071992547409.93', net: '70.00', difference: '90071992547339.93' })
|
||||
value.fees[0].exchangeRate = '7.000000'; expect(() => targetPreview(value, value.fees[0], '90071992547409.93')).toThrow('超出支持范围')
|
||||
value.fees[0].exchangeRate = '0.500000'; expect(targetPreview(value, value.fees[0], '0.01').target).toBe('0.01')
|
||||
})
|
||||
it('blocks a new target while any related adjustment remains unsettled or unlinked', () => {
|
||||
const value = chain(); value.customerView.fees[1].sourceLinked = false
|
||||
expect(() => targetPreview(value, value.fees[0], '12.00')).toThrow('未完成的调整')
|
||||
})
|
||||
it('caps refunds against the selected original consumption without borrowing another charge balance', () => {
|
||||
const value = chain(); expect(refundAvailable(value, value.fees[0])).toBe(6000n); expect(refundAvailable(value, value.fees[1])).toBe(2000n)
|
||||
const form = financeForm(value); form.feeId = '1'; form.refundAmount = '60.00'
|
||||
const payload = buildFinance('fees/refund', form, value, [proof], base, '501'); expect(payload.amount).toBe('60.00')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'fees/refund', payload }))).not.toBeNull()
|
||||
form.refundAmount = '60.01'; expect(() => buildFinance('fees/refund', form, value, [proof], base, '501')).toThrow('剩余可退')
|
||||
form.refundAmount = '0.00'; expect(() => buildFinance('fees/refund', form, value, [proof], base, '501')).toThrow('正额')
|
||||
})
|
||||
it('selects retry permission by fee purpose and rejects unknown outcomes', () => {
|
||||
const value = detail([{ ...fee('1', 'SAMPLE_PAYMENT'), walletStatus: 'FREEZE_FAILED' }]); const form = financeForm(value); form.feeId = '1'
|
||||
expect(feeRetryPermission(value.fees[0])).toBe('platform:overseas-business:purchase:buy')
|
||||
expect(buildFinance('fees/retry', form, value, [], base, '')).toEqual({ ...base, feeId: '1' })
|
||||
value.fees[0].walletStatus = 'FREEZE_PENDING'; expect(() => buildFinance('fees/retry', form, value, [], base, '')).toThrow('未知结果')
|
||||
expect(feeRetryPermission(fee('2', 'INTERNATIONAL_ESTIMATE'))).toContain(':estimate'); expect(feeRetryPermission(fee('3', 'REFUND'))).toContain(':finance-adjust')
|
||||
})
|
||||
it('only releases an effective estimate and does not construct a refund request', () => {
|
||||
const value = detail([{ ...fee('1', 'INTERNATIONAL_ESTIMATE'), walletStatus: 'FROZEN' }]); const form = financeForm(value); form.feeId = '1'
|
||||
expect(buildFinance('fees/release-estimate', form, value, [], base, '')).toEqual({ ...base, feeId: '1' })
|
||||
value.fees[0].walletStatus = 'SETTLED'; expect(() => buildFinance('fees/release-estimate', form, value, [], base, '')).toThrow('有效预估')
|
||||
})
|
||||
it('rejects a persisted selection that mixes an existing reference and a new amount', () => {
|
||||
const selection = { feeId: '1', value: { determination: 'ZERO', amount: '0.00', currency: 'CNY', businessDate: '2020-01-04', reason: '免费' } }
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'charges/confirm', payload: { ...base, internationalFreight: selection, duty: selection, domesticFreight: selection, otherDetermination: 'NOT_APPLICABLE', otherFees: [] } }))).toThrow('ambiguous')
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { PlatformProcurement, ProcurementInternalFee, ProcurementFile } from './procurementTypes'
|
||||
import type { ProcurementCommand, ProcurementCommandBody, ProcurementFeeSelection } from './api/procurementCommands'
|
||||
import { cents, decimal } from './procurementPresentation'
|
||||
export const financeCommands = new Set<ProcurementCommand>(['charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional'])
|
||||
export type ChargePurpose = 'INTERNATIONAL_FREIGHT' | 'DUTY' | 'DOMESTIC_FREIGHT' | 'OTHER'
|
||||
export interface ChargeDraft { purpose: ChargePurpose; code: string; name: string; mode: 'NEW' | 'EXISTING'; feeId: string; determination: '' | 'POSITIVE' | 'ZERO' | 'NOT_APPLICABLE'; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string; reason: string }
|
||||
export interface AdditionalDraft { name: string; amount: string; currency: 'CNY' | 'USD'; businessDate: string; voucherFileId: string }
|
||||
export interface FinanceForm { additional: AdditionalDraft; fixed: ChargeDraft[]; otherDetermination: 'NOT_APPLICABLE' | 'ITEMIZED'; others: ChargeDraft[]; feeId: string; targetOriginalAmount: string; refundAmount: string }
|
||||
const titles: Record<ChargePurpose, string> = { INTERNATIONAL_FREIGHT: '实际国际运费', DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用' }
|
||||
export function chargeDraft(purpose: ChargePurpose, fee?: ProcurementInternalFee, code = ''): ChargeDraft { return { purpose, code: fee?.feeItemCode || code || purpose, name: fee?.feeItemName || titles[purpose], mode: fee ? 'EXISTING' : 'NEW', feeId: fee?.id || '', determination: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '', reason: '' } }
|
||||
export function financeForm(detail: PlatformProcurement): FinanceForm { const fees = detail.fees.filter(row => row.active); const others = fees.filter(row => row.purpose === 'OTHER').map(row => chargeDraft('OTHER', row)); return { additional: { name: '', amount: '', currency: 'CNY', businessDate: '', voucherFileId: '' }, fixed: (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => chargeDraft(purpose, fees.find(row => row.purpose === purpose))), otherDetermination: others.length ? 'ITEMIZED' : 'NOT_APPLICABLE', others, feeId: '', targetOriginalAmount: '', refundAmount: '' } }
|
||||
export function feeRetryPermission(fee: ProcurementInternalFee) { return `platform:overseas-business:purchase:${fee.confirmationOrigin === 'FINANCIAL' ? 'finance-adjust' : fee.purpose === 'SAMPLE_PAYMENT' ? 'buy' : fee.purpose === 'INTERNATIONAL_ESTIMATE' ? 'estimate' : ['ADJUSTMENT', 'REFUND'].includes(fee.purpose) ? 'finance-adjust' : 'charge'}` }
|
||||
export function linked(detail: PlatformProcurement, fee: ProcurementInternalFee) { return detail.customerView.fees.some(row => row.feeNo === fee.feeNo && row.sourceLinked) }
|
||||
function feeAmount(fee: ProcurementInternalFee) { const amount = cents(fee.amount); if (amount === null || amount < 0n || fee.currency !== 'CNY') throw new Error('原费用人民币金额缺失,请先核对'); return amount }
|
||||
export function feeChain(detail: PlatformProcurement, selected: ProcurementInternalFee) {
|
||||
const root = selected.purpose === 'ADJUSTMENT' ? detail.fees.find(row => row.id === selected.parentFeeId) : selected
|
||||
if (!root || ['REFUND', 'ADJUSTMENT', 'INTERNATIONAL_ESTIMATE'].includes(root.purpose) || !root.active || !linked(detail, root)) throw new Error('原实际费用及来源关联尚未完成')
|
||||
const originals = new Set([root.id, ...detail.fees.filter(row => row.purpose === 'ADJUSTMENT' && row.parentFeeId === root.id).map(row => row.id)])
|
||||
const chain = detail.fees.filter(row => originals.has(row.id) || (row.purpose === 'REFUND' && !!row.parentFeeId && originals.has(row.parentFeeId)))
|
||||
if (chain.some(row => row.active && !linked(detail, row))) throw new Error('该费用链仍有未完成的调整或退款,请先完成或终止原请求')
|
||||
return chain
|
||||
}
|
||||
export function refundAvailable(detail: PlatformProcurement, fee: ProcurementInternalFee) {
|
||||
feeChain(detail, fee)
|
||||
if (fee.walletStatus !== 'SETTLED' || !fee.walletLedgerId || fee.purpose === 'INTERNATIONAL_ESTIMATE') throw new Error('退款必须关联已成功实扣的实际费用')
|
||||
return feeAmount(fee) - detail.fees.filter(row => row.purpose === 'REFUND' && row.parentFeeId === fee.id && row.walletStatus === 'REFUNDED').reduce((sum, row) => sum + feeAmount(row), 0n)
|
||||
}
|
||||
export function replacementCandidates(detail: PlatformProcurement) {
|
||||
return detail.fees.filter(fee => !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) && !fee.sourceFeeId && !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)
|
||||
&& !detail.fees.some(other => other.purpose === fee.purpose && other.feeItemCode === fee.feeItemCode && (other.active || other.feeVersion > fee.feeVersion)))
|
||||
}
|
||||
function frozenTarget(fee: ProcurementInternalFee, amount: string) {
|
||||
const original = cents(amount); const rate = /^(\d+)(?:\.(\d{1,12}))?$/.exec(String(fee.exchangeRate))
|
||||
if (original === null || original < 0n || !rate) throw new Error('请填写正确原币总额,并核对原汇率快照')
|
||||
const denominator = 10n ** BigInt((rate[2] || '').length); const numerator = BigInt(rate[1] + (rate[2] || ''))
|
||||
if (numerator <= 0n) throw new Error('原汇率快照无效')
|
||||
const target = (original * numerator * 2n + denominator) / (2n * denominator)
|
||||
if (target > 99999999999999999999n) throw new Error('折算人民币金额超出支持范围')
|
||||
return target
|
||||
}
|
||||
export function replacementPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
|
||||
if (!replacementCandidates(detail).some(row => row.id === fee.id)) throw new Error('须选择最新、已终止或释放且未实扣的实际费用')
|
||||
const target = frozenTarget(fee, amount)
|
||||
if (target > 9999999999999999n || cents(amount)! > 9999999999999999n) throw new Error('折算人民币或原币金额超出支持范围')
|
||||
if (fee.purpose === 'SAMPLE_PAYMENT') {
|
||||
const budget = detail.customerView.budget; const limit = cents(budget.totalAmount)
|
||||
if (limit === null) throw new Error('客户预算尚未明确')
|
||||
if ((budget.currency === fee.originalCurrency && cents(amount)! > limit) || (budget.currency === 'CNY' && target > limit)) throw new Error('替代货款超过客户授权预算,请先完成预算核验')
|
||||
}
|
||||
return { target: decimal(target), net: '0.00', difference: decimal(target) }
|
||||
}
|
||||
export function targetPreview(detail: PlatformProcurement, fee: ProcurementInternalFee, amount: string) {
|
||||
if (['ADJUSTMENT', 'REFUND', 'INTERNATIONAL_ESTIMATE'].includes(fee.purpose)) throw new Error('目标更正须选择原实际费用')
|
||||
const chain = feeChain(detail, fee); const target = frozenTarget(fee, amount)
|
||||
const net = chain.reduce((sum, row) => row.walletStatus === 'SETTLED' ? sum + feeAmount(row) : row.walletStatus === 'REFUNDED' ? sum - feeAmount(row) : sum, 0n)
|
||||
if (target - net > 9999999999999999n) throw new Error('追加人民币金额超出支持范围')
|
||||
return { target: decimal(target), net: decimal(net), difference: decimal(target - net) }
|
||||
}
|
||||
function amount(value: string, positive = false) { const parsed = cents(value.trim()); if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(value.trim()) || parsed === null || (positive ? parsed <= 0n : parsed < 0n)) throw new Error(positive ? '请填写正额金额,最多两位小数' : '请明确填写非负金额,最多两位小数'); return value.trim() }
|
||||
function select(draft: ChargeDraft, detail: PlatformProcurement, files: ProcurementFile[]): ProcurementFeeSelection {
|
||||
if (draft.mode === 'NEW' && detail.operations.some(row => row.action === 'CONFIRM_CHARGES')) throw new Error('首次核费后只可引用已有费用,新发生或替代费用请使用财务操作')
|
||||
if (draft.mode === 'EXISTING') {
|
||||
if (!detail.fees.some(row => row.id === draft.feeId && row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}引用的费用不是本单该用途的有效版本`)
|
||||
return { feeId: draft.feeId }
|
||||
}
|
||||
if (detail.fees.some(row => row.active && row.purpose === draft.purpose && (draft.purpose !== 'OTHER' || row.feeItemCode === draft.code))) throw new Error(`${draft.name}已有有效费用,请引用原版本或先按权限更正`)
|
||||
if (!draft.determination) throw new Error(`${draft.name}尚未明确核定,未知不能视为零元`)
|
||||
const value = amount(draft.amount, draft.determination === 'POSITIVE')
|
||||
if (draft.determination !== 'POSITIVE' && cents(value) !== 0n) throw new Error('零元或不适用必须明确记为 0.00')
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.businessDate) || !Number.isFinite(Date.parse(draft.businessDate))) throw new Error(`请选择${draft.name}的业务日期`)
|
||||
if (!draft.reason.trim() || draft.reason.trim().length > 500) throw new Error(`请填写${draft.name}的核定说明,最多 500 字`)
|
||||
if (draft.determination === 'POSITIVE' && !files.some(file => file.fileId === draft.voucherFileId && file.category === 'VOUCHER')) throw new Error(`${draft.name}须关联本单费用凭证`)
|
||||
return { value: { determination: draft.determination, amount: value, currency: draft.currency, businessDate: draft.businessDate, ...(draft.voucherFileId ? { voucherFileId: draft.voucherFileId } : {}), reason: draft.reason.trim() } }
|
||||
}
|
||||
export function buildFinance(command: ProcurementCommand, form: FinanceForm, detail: PlatformProcurement, files: ProcurementFile[], base: ProcurementCommandBody, voucherFileId: string): ProcurementCommandBody {
|
||||
if (command === 'fees/additional') {
|
||||
if (!detail.purchaseFacts.purchased_at) throw new Error('独立新增费用须关联已发生购买的订单')
|
||||
const row = form.additional; const value = amount(row.amount, true)
|
||||
if (!row.name.trim() || row.name.trim().length > 128) throw new Error('请填写独立费用名称,最多 128 字')
|
||||
const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(row.businessDate) || !Number.isFinite(Date.parse(row.businessDate)) || row.businessDate > today) throw new Error('请选择已发生费用的真实业务日期')
|
||||
if (!files.some(file => file.fileId === row.voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单新增费用凭证')
|
||||
if ((base.reason?.length || 0) > 500) throw new Error('新增费用说明最多 500 字')
|
||||
return { ...base, name: row.name.trim(), amount: value, currency: row.currency, businessDate: row.businessDate, voucherFileId: row.voucherFileId }
|
||||
}
|
||||
if (command === 'charges/confirm') {
|
||||
if (!detail.purchaseFacts.domestic_received_at) throw new Error('须完成国内收货后确认费用')
|
||||
const fixed = (['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT'] as const).map(purpose => { const rows = form.fixed.filter(row => row.purpose === purpose); if (rows.length !== 1) throw new Error('固定费用项目不完整'); return select(rows[0], detail, files) })
|
||||
if ((form.otherDetermination === 'NOT_APPLICABLE') !== (form.others.length === 0)) throw new Error('其他费用适用时须逐项核定,不适用时不保留收费行')
|
||||
const codes = new Set<string>()
|
||||
const otherFees = form.others.map(row => { if (!/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || !row.name.trim() || row.name.trim().length > 128) throw new Error('其他费用名称必填且项目不可重复'); codes.add(row.code); return { code: row.code, name: row.name.trim(), selection: select(row, detail, files) } })
|
||||
const selected = new Set([...fixed, ...otherFees.map(row => row.selection)].map(row => row.feeId).filter(Boolean))
|
||||
if (detail.fees.some(row => row.active && ['INTERNATIONAL_FREIGHT', 'DUTY', 'DOMESTIC_FREIGHT', 'OTHER'].includes(row.purpose) && !selected.has(row.id))) throw new Error('不能通过省略有效费用隐藏原收费')
|
||||
return { ...base, internationalFreight: fixed[0], duty: fixed[1], domesticFreight: fixed[2], otherDetermination: form.otherDetermination, otherFees }
|
||||
}
|
||||
const fee = detail.fees.find(row => row.id === form.feeId); if (!fee) throw new Error('请选择本单费用')
|
||||
const common = { ...base, feeId: fee.id }
|
||||
if (command === 'fees/retry' || command === 'fees/terminate') {
|
||||
if (!fee.active || !fee.walletStatus.endsWith('_FAILED')) throw new Error('仅能处理资金结果已明确失败的有效费用,未知结果不能新建重试')
|
||||
return common
|
||||
}
|
||||
if (command === 'fees/release-estimate') {
|
||||
if (!fee.active || fee.purpose !== 'INTERNATIONAL_ESTIMATE' || !['FROZEN', 'ZERO'].includes(fee.walletStatus)) throw new Error('仅能释放已冻结或已确认零元的有效预估')
|
||||
return common
|
||||
}
|
||||
if (!files.some(file => file.fileId === voucherFileId && file.category === 'VOUCHER')) throw new Error('请关联本单财务更正凭证')
|
||||
if ((base.reason?.length || 0) > 500) throw new Error('财务更正说明最多 500 字')
|
||||
if (command === 'fees/replace-failed') { const targetOriginalAmount = amount(form.targetOriginalAmount); replacementPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
|
||||
if (command === 'fees/correct-target') { const targetOriginalAmount = amount(form.targetOriginalAmount); targetPreview(detail, fee, targetOriginalAmount); return { ...common, targetOriginalAmount, voucherFileId } }
|
||||
const refund = amount(form.refundAmount, true)
|
||||
if (cents(refund)! > refundAvailable(detail, fee)) throw new Error('退款超过该原扣款的剩余可退金额')
|
||||
return { ...common, amount: refund, voucherFileId }
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
|
||||
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
|
||||
const commands = new Set(['review', 'purchase', 'purchase/confirm', 'budget-authorizations', 'planned-warehouse', 'cancel', 'overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke', 'labels', 'international-estimate', 'labels/apply', 'domestic-repack', 'overseas-shipment', 'customer-shipment', 'charges/confirm', 'fees/retry', 'fees/terminate', 'fees/release-estimate', 'fees/correct-target', 'fees/refund', 'fees/replace-failed', 'fees/additional', 'control-notes', 'control-notes/resolve', 'actual-warehouse/correct', 'exception-close'])
|
||||
export function commandKey() {
|
||||
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
|
||||
if (raw === null) return null
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
|
||||
const payload = value.payload
|
||||
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
|
||||
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
|
||||
const body = payload as Record<string, unknown>
|
||||
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
|
||||
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
|
||||
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
|
||||
if (value.command === 'review') {
|
||||
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
|
||||
} else if (!nonempty('reason')) throw new Error('missing pending reason')
|
||||
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
|
||||
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
|
||||
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
|
||||
}
|
||||
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
|
||||
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
|
||||
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
|
||||
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
|
||||
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
|
||||
}
|
||||
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
|
||||
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
|
||||
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
|
||||
}
|
||||
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
|
||||
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
|
||||
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
|
||||
if (value.command === 'dispositions') {
|
||||
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
|
||||
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
|
||||
}
|
||||
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
|
||||
if (value.command === 'packing') {
|
||||
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
|
||||
const names = new Set<string>()
|
||||
for (const item of body.cartons) {
|
||||
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
|
||||
names.add(item.cartonNo)
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
|
||||
}
|
||||
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
|
||||
if (!source || typeof source !== 'object') return true
|
||||
const row = source as Record<string, unknown>
|
||||
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
|
||||
})) throw new Error('invalid packing source')
|
||||
}
|
||||
}
|
||||
if (value.command === 'labels') {
|
||||
const receiver = body.receiver
|
||||
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
|
||||
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
|
||||
const cartonIds = new Set<string>()
|
||||
for (const row of body.labels) {
|
||||
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
|
||||
cartonIds.add(row.cartonId)
|
||||
}
|
||||
}
|
||||
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
|
||||
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
|
||||
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
|
||||
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
|
||||
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
|
||||
}
|
||||
if (value.command === 'domestic-repack') {
|
||||
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
|
||||
}
|
||||
}
|
||||
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
|
||||
if (!positiveId('feeId')) throw new Error('invalid original fee')
|
||||
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
|
||||
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
|
||||
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
|
||||
}
|
||||
if (value.command === 'charges/confirm') {
|
||||
const selection = (input: unknown) => {
|
||||
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
|
||||
const item = input as Record<string, unknown>
|
||||
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
|
||||
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
|
||||
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
|
||||
const value = item.value as Record<string, unknown>
|
||||
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
|
||||
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
|
||||
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
|
||||
}
|
||||
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
|
||||
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
|
||||
const codes = new Set<string>()
|
||||
for (const row of body.otherFees) {
|
||||
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
|
||||
codes.add(row.code); selection(row.selection)
|
||||
}
|
||||
}
|
||||
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
|
||||
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
|
||||
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
|
||||
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
|
||||
if (value.command === 'exception-close') {
|
||||
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
|
||||
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
|
||||
const sources = new Set<string>()
|
||||
for (const row of body.resolutions) {
|
||||
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
|
||||
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
|
||||
}
|
||||
}
|
||||
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
|
||||
return value as PendingProcurement
|
||||
}
|
||||
export function definitelyRejected(error: unknown) {
|
||||
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
|
||||
const code = error instanceof ApiResponseError ? error.code : ''
|
||||
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPacking, buildPhysical, physicalForm, shippedCartons, managementReason, receiptCorrectionReason } from './procurementPhysicalForm'
|
||||
import { readPendingProcurement } from './procurementPending'
|
||||
import type { PlatformProcurement } from './procurementTypes'
|
||||
const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' }
|
||||
function detail(): PlatformProcurement {
|
||||
return {
|
||||
customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '10001', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳', contactName: '收件人', contactPhone: '13800000000', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'OVERSEAS_RECEIVING', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [{ shipmentNo: 'PS-1', leg: 'OVERSEAS', cartonNo: 'C1', quantity: 5, shippedAt: '2020-01-03T10:00:00', carrier: 'UPS', trackingNo: 'TRACK-1' }], fees: [], exceptionSummaries: [] },
|
||||
purchaseFacts: { actual_warehouse_id: '20', purchased_quantity: 10, purchased_at: '2020-01-01T10:00:00', overseas_shipped_at: '2020-01-03T10:00:00' },
|
||||
physical: { receipts: [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 6, damagedQuantity: 1, extraQuantity: 2, receivedAt: '2020-01-02T10:00:00', active: true }, { id: '12', receiptNo: 'PR-12', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02T12:00:00', active: true }], dispositions: [], packingVersion: 2, packingStatus: 'DRAFT', cartons: [{ id: '31', cartonNo: 'C1', quantity: 5, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 5 }] }, { id: '32', cartonNo: 'C2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', sources: [{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }] }] },
|
||||
transport: { overseasCargo: [], domesticCargo: [], overseasLabels: null, domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [], reviewHistory: [], operations: [], actions: [], internalRemark: null,
|
||||
}
|
||||
}
|
||||
describe('procurement physical request validation', () => {
|
||||
it('builds multi-carton allocations with exact dimensions, retaining independent source references', () => {
|
||||
const value = detail(); const form = physicalForm(value)
|
||||
const payload = buildPhysical('packing', form, value, base, '', '')
|
||||
expect(payload.cartons?.[1].sources).toEqual([{ receiptId: '11', quantity: 1 }, { receiptId: '12', quantity: 3 }])
|
||||
expect(payload.cartons?.[0].grossWeightKg).toBe('1.234')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'packing', payload }))).toEqual({ command: 'packing', payload })
|
||||
form.cartons[0].sources[0].quantity = 1
|
||||
expect(payload.cartons?.[0].sources[0].quantity).toBe(5)
|
||||
})
|
||||
it('rejects repeated boxes, duplicate same-box sources, missing sources and cross-box over-allocation', () => {
|
||||
const value = detail(); let boxes = physicalForm(value).cartons
|
||||
boxes[1].cartonNo = 'C1'; expect(() => buildPacking(boxes, value)).toThrow('箱号')
|
||||
boxes = physicalForm(value).cartons; boxes[0].sources.push({ receiptId: '11', quantity: 1 }); expect(() => buildPacking(boxes, value)).toThrow('同箱来源不可重复')
|
||||
boxes = physicalForm(value).cartons; boxes[0].sources = []; expect(() => buildPacking(boxes, value)).toThrow('每箱必须')
|
||||
boxes = physicalForm(value).cartons; boxes[0].sources[0].quantity = 6; expect(() => buildPacking(boxes, value)).toThrow('超过实收 PR-11')
|
||||
})
|
||||
it('rejects fractional pieces, inactive receipts and excessive measurement precision', () => {
|
||||
const value = detail(); let boxes = physicalForm(value).cartons
|
||||
boxes[0].sources[0].quantity = 1.5; expect(() => buildPacking(boxes, value)).toThrow('整数')
|
||||
boxes = physicalForm(value).cartons; boxes[0].lengthCm = '10.001'; expect(() => buildPacking(boxes, value)).toThrow('最多 2 位')
|
||||
boxes = physicalForm(value).cartons; boxes[0].grossWeightKg = '1.0001'; expect(() => buildPacking(boxes, value)).toThrow('最多 3 位')
|
||||
value.physical.receipts[0].active = false; expect(() => buildPacking(physicalForm(value).cartons, value)).toThrow('有效海外实收')
|
||||
})
|
||||
it('requires domestic receipts to use an actually shipped original carton and excludes unshipped boxes', () => {
|
||||
const value = detail(); const form = physicalForm(); form.leg = 'DOMESTIC'; form.receivedAt = '2020-01-04T10:00:00'; form.goodQuantity = 3
|
||||
expect(shippedCartons(value).map(row => row.cartonNo)).toEqual(['C1'])
|
||||
form.sourceCartonId = '32'; expect(() => buildPhysical('domestic-receipts', form, value, base, '', '')).toThrow('已国际交寄')
|
||||
form.sourceCartonId = '31'; const payload = buildPhysical('domestic-receipts', form, value, base, '', '')
|
||||
expect(payload).toMatchObject({ leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 3, damagedQuantity: 0, extraQuantity: 0 }); expect(payload).not.toHaveProperty('warehouseId')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'domestic-receipts', payload }))).not.toBeNull()
|
||||
value.physical.receipts.push({ id: '13', receiptNo: 'PR-13', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 4, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-04T10:00:00', active: true })
|
||||
expect(() => buildPhysical('domestic-receipts', form, value, base, '', '')).toThrow('超过来源件数')
|
||||
})
|
||||
it('preserves the fixed overseas warehouse, rejects future receipts and keeps extras separate', () => {
|
||||
const value = detail(); value.physical.receipts = []; const form = physicalForm(); form.goodQuantity = 8; form.damagedQuantity = 2; form.extraQuantity = 3; form.receivedAt = '2020-01-02T10:00:00'
|
||||
expect(() => buildPhysical('overseas-receipts', form, value, base, 'other', '')).toThrow('已冻结')
|
||||
expect(buildPhysical('overseas-receipts', form, value, base, '20', '')).toMatchObject({ goodQuantity: 8, damagedQuantity: 2, extraQuantity: 3, warehouseId: '20' })
|
||||
form.receivedAt = '2999-01-01T10:00:00'; expect(() => buildPhysical('overseas-receipts', form, value, base, '20', '')).toThrow('真实实收时间')
|
||||
})
|
||||
it('binds a damaged disposition to its receipt and prevents repeat disposal', () => {
|
||||
const value = detail(); const form = physicalForm(); form.kind = 'DAMAGED'; form.receiptId = '11'; form.quantity = 1
|
||||
const payload = buildPhysical('dispositions', form, value, base, '', '901')
|
||||
expect(payload).toMatchObject({ kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', receiptId: '11', quantity: 1, voucherFileId: '901' })
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'dispositions', payload }))).not.toBeNull()
|
||||
value.physical.dispositions.push({ id: '71', leg: 'OVERSEAS', source_carton_id: null, receipt_id: '11', kind: 'DAMAGED', decision: 'DISPOSE_DAMAGED', quantity: 1, voucher_file_id: '901', reason: '已处置', active: true, created_at: '' })
|
||||
expect(() => buildPhysical('dispositions', form, value, base, '', '901')).toThrow('未处理差异')
|
||||
})
|
||||
it('binds shortage to the purchase instead of fabricating a receipt', () => {
|
||||
const value = detail(); value.physical.receipts.pop(); const form = physicalForm(); form.kind = 'SHORTAGE'; form.quantity = 3; form.receiptId = 'stale'
|
||||
const payload = buildPhysical('dispositions', form, value, base, '', '901')
|
||||
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', () => {
|
||||
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')
|
||||
expect(payload).toMatchObject({ receiptId: '11', leg: 'OVERSEAS', goodQuantity: 5 }); expect(payload).not.toHaveProperty('sourceCartonId')
|
||||
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', () => {
|
||||
const value = detail(); const form = physicalForm(value)
|
||||
expect(buildPhysical('packing/confirm', form, value, base, '', '')).toEqual({ ...base, packingVersion: 2 })
|
||||
value.physical.packingStatus = 'CONFIRMED'; expect(() => buildPhysical('packing/confirm', form, value, base, '', '')).toThrow('装箱草稿')
|
||||
const payload = { ...base, cartons: [{ cartonNo: 'C1', lengthCm: '1', widthCm: '1', heightCm: '1', grossWeightKg: '1', sources: [{ receiptId: '11', quantity: 0 }] }] }
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'packing', payload }))).toThrow('packing source')
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { PlatformProcurement } from './procurementTypes'
|
||||
import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands'
|
||||
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 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 function newCarton(): PackingDraft { return { cartonNo: '', lengthCm: '', widthCm: '', heightCm: '', grossWeightKg: '', sources: [{ receiptId: '', quantity: undefined }] } }
|
||||
export function physicalForm(detail?: PlatformProcurement): PhysicalForm { return { leg: 'OVERSEAS', sourceCartonId: '', receiptId: '', dispositionId: '', goodQuantity: 0, damagedQuantity: 0, extraQuantity: 0, receivedAt: '', kind: 'SHORTAGE', quantity: undefined, cartons: detail?.physical.cartons.length ? detail.physical.cartons.map(row => ({ cartonNo: row.cartonNo, lengthCm: String(row.lengthCm), widthCm: String(row.widthCm), heightCm: String(row.heightCm), grossWeightKg: String(row.grossWeightKg), sources: row.sources.map(source => ({ ...source })) })) : [newCarton()] } }
|
||||
export function shippedCartons(detail: PlatformProcurement) { const numbers = new Set(detail.customerView.shipments.filter(row => row.leg === 'OVERSEAS').map(row => row.cartonNo)); return detail.physical.cartons.filter(row => numbers.has(row.cartonNo)) }
|
||||
export function whole(value: number | undefined, label: string, min = 0): number { if (value === undefined || !Number.isSafeInteger(value) || value < min) throw new Error(`${label}必须为${min ? '正' : '非负'}整数`); return value }
|
||||
export function dimension(value: string, label: string, scale: number, digits: number) { if (!new RegExp(`^\\d{1,${digits}}(?:\\.\\d{1,${scale}})?$`).test(value.trim()) || Number(value) <= 0) throw new Error(`${label}必须为正数,最多 ${scale} 位小数`); return value.trim() }
|
||||
export function buildPacking(cartons: PackingDraft[], detail: PlatformProcurement): ProcurementPackingCarton[] {
|
||||
if (!cartons.length) throw new Error('请至少建立一个装货箱')
|
||||
const used = new Map<string, number>(); const names = new Set<string>()
|
||||
return cartons.map(carton => {
|
||||
const cartonNo = carton.cartonNo.trim(); if (!cartonNo || cartonNo.length > 64 || names.has(cartonNo)) throw new Error('箱号必填、不可重复且不超过 64 个字符'); names.add(cartonNo)
|
||||
if (!carton.sources.length) throw new Error('每箱必须分配实收来源')
|
||||
const inCarton = new Set<string>()
|
||||
const sources = carton.sources.map(source => {
|
||||
const receipt = detail.physical.receipts.find(row => row.id === source.receiptId && row.active && row.leg === 'OVERSEAS')
|
||||
if (!receipt || inCarton.has(source.receiptId)) throw new Error('请选择有效海外实收来源,同箱来源不可重复')
|
||||
inCarton.add(source.receiptId)
|
||||
const quantity = whole(source.quantity, '装箱件数', 1); const total = (used.get(source.receiptId) || 0) + quantity
|
||||
if (!Number.isSafeInteger(total) || total > receipt.goodQuantity) throw new Error(`装箱件数超过实收 ${receipt.receiptNo} 的合格数量`)
|
||||
used.set(source.receiptId, total); return { receiptId: source.receiptId, quantity }
|
||||
})
|
||||
return { cartonNo, lengthCm: dimension(carton.lengthCm, '长', 2, 10), widthCm: dimension(carton.widthCm, '宽', 2, 10), heightCm: dimension(carton.heightCm, '高', 2, 10), grossWeightKg: dimension(carton.grossWeightKg, '毛重', 3, 9), sources }
|
||||
})
|
||||
}
|
||||
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)
|
||||
if (closed) throw new Error('订单已结束,不能改写实物记录')
|
||||
if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) }
|
||||
if (command === 'packing/confirm') {
|
||||
if (detail.physical.packingStatus !== 'DRAFT' || !detail.physical.packingVersion) throw new Error('请先保存当前装箱草稿')
|
||||
return { ...base, packingVersion: detail.physical.packingVersion }
|
||||
}
|
||||
if (command === 'packing/withdraw') {
|
||||
if (detail.physical.packingStatus !== 'CONFIRMED' || detail.purchaseFacts.overseas_shipped_at) throw new Error('仅允许撤回尚未交寄的已确认清单')
|
||||
return base
|
||||
}
|
||||
if (command === 'dispositions/revoke') {
|
||||
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 }
|
||||
}
|
||||
if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' }
|
||||
const correction = command === 'receipts/correct'
|
||||
const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined
|
||||
if (correction && !old) throw new Error('请选择有效原实收记录')
|
||||
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
|
||||
if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱')
|
||||
if (command === 'dispositions') {
|
||||
const quantity = whole(form.quantity, '处置件数', 1)
|
||||
const source = leg === 'DOMESTIC' ? sourceCartonId : null
|
||||
const receipt = detail.physical.receipts.find(row => row.id === form.receiptId && row.active && row.leg === leg && row.sourceCartonId === source)
|
||||
const records = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source)
|
||||
const resolved = detail.physical.dispositions.filter(row => row.active && row.leg === leg && row.source_carton_id === source && row.kind === form.kind && (form.kind === 'SHORTAGE' || row.receipt_id === form.receiptId)).reduce((sum, row) => sum + row.quantity, 0)
|
||||
const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === source)?.quantity
|
||||
const available = form.kind === 'SHORTAGE' ? Number(expected) - records.reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0) - resolved : receipt ? (form.kind === 'DAMAGED' ? receipt.damagedQuantity : receipt.extraQuantity) - resolved : -1
|
||||
if (!Number.isSafeInteger(available) || quantity > available) throw new Error('处置件数超过当前来源的未处理差异')
|
||||
const decisions = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' } as const
|
||||
return { ...base, leg, ...(leg === 'DOMESTIC' ? { sourceCartonId } : {}), ...(form.kind === 'SHORTAGE' ? {} : { receiptId: form.receiptId }), kind: form.kind, quantity, decision: decisions[form.kind], voucherFileId }
|
||||
}
|
||||
const goodQuantity = whole(form.goodQuantity, '合格件数'), damagedQuantity = whole(form.damagedQuantity, '破损件数'), extraQuantity = whole(form.extraQuantity, '多货件数')
|
||||
if (!Number.isSafeInteger(goodQuantity + damagedQuantity + extraQuantity) || goodQuantity + damagedQuantity + extraQuantity <= 0) throw new Error('本次实收总件数必须大于零')
|
||||
if (!form.receivedAt || !Number.isFinite(Date.parse(form.receivedAt)) || Date.parse(form.receivedAt) > Date.now()) throw new Error('请填写不晚于当前时间的真实实收时间')
|
||||
const origin = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_at : detail.purchaseFacts.overseas_shipped_at
|
||||
if (!origin || Date.parse(form.receivedAt) < Date.parse(origin)) throw new Error('实收时间不能早于购买或国际交寄时间')
|
||||
const source = leg === 'DOMESTIC' ? sourceCartonId : null
|
||||
const counted = detail.physical.receipts.filter(row => row.active && row.leg === leg && row.sourceCartonId === source && row.id !== old?.id).reduce((sum, row) => sum + row.goodQuantity + row.damagedQuantity, 0)
|
||||
const expected = leg === 'OVERSEAS' ? detail.purchaseFacts.purchased_quantity : shippedCartons(detail).find(row => row.id === sourceCartonId)?.quantity
|
||||
if (expected == null || goodQuantity + damagedQuantity + counted > expected) throw new Error('合格及破损实收超过来源件数,多到货物请填入多货')
|
||||
if (leg === 'OVERSEAS' && (!warehouseId || (detail.purchaseFacts.actual_warehouse_id && detail.purchaseFacts.actual_warehouse_id !== warehouseId))) throw new Error('实际收货仓必须与本单已冻结的仓库一致')
|
||||
return { ...base, leg, ...(leg === 'OVERSEAS' ? { warehouseId } : { sourceCartonId }), goodQuantity, damagedQuantity, extraQuantity, receivedAt: form.receivedAt, ...(correction ? { receiptId: form.receiptId, voucherFileId } : {}) }
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Amount, ProcurementFee } from './procurementTypes'
|
||||
|
||||
export const procurementStatuses: Record<string, string> = {
|
||||
PENDING_REVIEW: '待审核', WAIT_PURCHASE: '待购买', WAIT_OVERSEAS_RECEIPT: '待海外到仓', OVERSEAS_RECEIVING: '海外收货中',
|
||||
WAIT_PACKING: '待装箱', WAIT_LABELS: '待面单及预估处理', WAIT_OVERSEAS_SHIPMENT: '待海外交寄', INTERNATIONAL_TRANSIT: '国际运输中',
|
||||
DOMESTIC_RECEIVING: '国内收货中', WAIT_CHARGES: '待国内核费', WAIT_CUSTOMER_SHIPMENT: '待寄客户', SHIPPED_TO_CUSTOMER: '已寄客户',
|
||||
REJECTED: '已拒绝', CANCELLED: '已取消', EXCEPTION_CLOSED: '异常关闭',
|
||||
}
|
||||
export const feePurposes: Record<string, string> = {
|
||||
SAMPLE_PAYMENT: '样品货款', INTERNATIONAL_ESTIMATE: '预估国际运费', INTERNATIONAL_FREIGHT: '实际国际运费',
|
||||
DUTY: '关税', DOMESTIC_FREIGHT: '国内运费', OTHER: '其他费用', ADJUSTMENT: '追加费用', REFUND: '退款',
|
||||
}
|
||||
export function statusLabel(status: string | null | undefined) { return status ? procurementStatuses[status] || '待核对进度' : '—' }
|
||||
export function statusTone(status: string | null | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
if (status === 'SHIPPED_TO_CUSTOMER') return 'success'
|
||||
if (status === 'REJECTED' || status === 'EXCEPTION_CLOSED') return 'danger'
|
||||
if (status === 'CANCELLED') return 'info'
|
||||
if (status === 'INTERNATIONAL_TRANSIT') return 'primary'
|
||||
return 'warning'
|
||||
}
|
||||
export function cents(value: Amount | null | undefined): bigint | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim())
|
||||
if (!match) return null
|
||||
const result = BigInt(match[2]) * 100n + BigInt((match[3] || '').padEnd(2, '0'))
|
||||
return match[1] ? -result : result
|
||||
}
|
||||
export function decimal(value: bigint) {
|
||||
const absolute = value < 0n ? -value : value
|
||||
return `${value < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`
|
||||
}
|
||||
export function money(value: Amount | null | undefined, currency: string | null | undefined = 'CNY') {
|
||||
const amount = cents(value)
|
||||
return amount === null ? '—' : `${currency || '—'} ${decimal(amount)}`
|
||||
}
|
||||
export function dateTime(value: string | null | undefined) { return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '—' }
|
||||
export function measurement(value: Amount | null | undefined, scale: number) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const parts = /^(\d+)(?:\.(\d+))?$/.exec(String(value))
|
||||
if (!parts || (parts[2] || '').length > scale) return '待核对'
|
||||
return `${parts[1]}.${(parts[2] || '').padEnd(scale, '0')}`
|
||||
}
|
||||
export function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
export function productHref(value: string | null | undefined) {
|
||||
if (!value) return undefined
|
||||
try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined } catch { return undefined }
|
||||
}
|
||||
export function feeStatus(fee: ProcurementFee) {
|
||||
if (fee.purpose === 'INTERNATIONAL_ESTIMATE') {
|
||||
const label: Record<string, string> = { FROZEN: '已冻结,未扣款', ZERO: '零预估已确认', SETTLED: '原预估已结清', RELEASED: '预估已释放' }
|
||||
if (fee.walletStatus && label[fee.walletStatus]) return label[fee.walletStatus]
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
FREEZE_PENDING: '冻结处理中', FREEZE_FAILED: '冻结未成功', FROZEN: '等待扣款', SETTLE_PENDING: '扣款处理中', SETTLE_ACTUAL_PENDING: '实际费用结算中',
|
||||
SETTLE_FAILED: '扣款未成功', SETTLE_ACTUAL_FAILED: '实际费用未结清', SETTLED: '已扣款', RELEASE_PENDING: '释放处理中', RELEASE_FAILED: '释放未成功',
|
||||
RELEASED: '已释放冻结', REFUND_PENDING: '退款处理中', REFUND_FAILED: '退款未成功', REFUNDED: '已退款', ZERO: '已确认零元', NOT_APPLICABLE: '不适用', TERMINATED: '已终止',
|
||||
}
|
||||
return fee.walletStatus ? labels[fee.walletStatus] || '待核定' : '待核定'
|
||||
}
|
||||
export function feeTone(fee: ProcurementFee): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (fee.walletStatus?.endsWith('_FAILED')) return 'danger'
|
||||
if (['SETTLED', 'REFUNDED', 'ZERO', 'NOT_APPLICABLE', 'RELEASED'].includes(fee.walletStatus || '')) return 'success'
|
||||
if (fee.walletStatus === 'TERMINATED') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTransport, transportForm } from './procurementTransportForm'
|
||||
import { readPendingProcurement } from './procurementPending'
|
||||
import type { PlatformProcurement, ProcurementFile, ProcurementLabelSet } from './procurementTypes'
|
||||
const base = { version: 7, idempotencyKey: 'transport-one', reason: '逐箱核对' }
|
||||
const files: ProcurementFile[] = [{ id: '51', fileId: '501', category: 'LABEL', fileName: 'labels.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }, { id: '52', fileId: '502', category: 'EXCEPTION', fileName: 'customer.pdf', contentType: 'application/pdf', sizeBytes: 10, createdAt: '' }, { id: '53', fileId: '503', category: 'RECEIPT', fileName: 'repack.png', contentType: 'image/png', sizeBytes: 10, createdAt: '' }]
|
||||
function labels(): ProcurementLabelSet { return { id: '71', version: 2, manifestId: '61', cargoHash: 'cargo', sender: { name: '海外发件人', phone: '13800000000', address: 'US warehouse' }, receiver: { name: '国内收货人', phone: '13800000001', address: '上海接收地址' }, labels: [{ cartonId: '31', fileId: '501', page: 1, carrier: 'carrier', trackingNo: 'TRACK-1', appliedAt: '2020-01-02T12:00:00' }, { cartonId: '32', fileId: '501', page: 2, carrier: 'carrier', trackingNo: 'TRACK-2', appliedAt: '2020-01-02T13:00:00' }] } }
|
||||
function detail(): PlatformProcurement {
|
||||
const cargo = [{ cartonId: '31', cartonNo: 'BOX-1', quantity: 6, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.234', packagingRecordId: null, sources: [{ receiptId: '11', quantity: 6 }] }, { cartonId: '32', cartonNo: 'BOX-2', quantity: 4, lengthCm: '10.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '1.111', packagingRecordId: null, sources: [{ receiptId: '12', quantity: 4 }] }]
|
||||
return { customerView: { order: { id: '1', orderNo: 'OP-1', customerId: '9', customerName: '客户', purchaseCategory: '样品', productName: '外套', productSpecification: null, productUrl: null, purchaseQuantity: 10, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: '深圳客户地址', contactName: '客户收件人', contactPhone: '13800000002', estimatedArrivalAt: null, plannedWarehouseName: '海外仓', actualWarehouseName: '海外仓', actualWarehouseCode: 'LAX', status: 'WAIT_OVERSEAS_SHIPMENT', version: 7, createdAt: '', updatedAt: '' }, budget: { totalAmount: '10.00', currency: 'CNY', authorizedAt: null }, purchased: true, purchaseConfirmed: true, purchaseNeedsAttention: false, progress: [], boxes: [], shipments: [], fees: [], exceptionSummaries: [] }, purchaseFacts: { overseas_received_at: '2020-01-02T10:00:00', domestic_received_at: '2020-01-04T10:00:00' }, physical: { receipts: [], dispositions: [], packingVersion: 3, packingStatus: 'CONFIRMED', cartons: [] }, transport: { overseasCargo: cargo, domesticCargo: [{ ...cargo[0], quantity: 5, lengthCm: '15.00', grossWeightKg: '1.987', packagingRecordId: '81', sources: [{ receiptId: '21', quantity: 5 }] }], overseasLabels: labels(), domesticLabels: null, shipments: [] }, fees: [], feeCorrections: [], controls: { notes: [], remaining: [], closures: [], closureItems: [] }, files: [...files], reviewHistory: [], operations: [], actions: [{ key: 'SHIP_OVERSEAS', label: '海外交寄', permission: 'ship-overseas', allowed: true, reason: null }, { key: 'SHIP_CUSTOMER', label: '寄客户', permission: 'ship-customer', allowed: true, reason: null }], internalRemark: null }
|
||||
}
|
||||
describe('procurement transport form rules', () => {
|
||||
it('binds the full batch to files, pages and the exact packing version', () => {
|
||||
const value = detail(); const form = transportForm(value)
|
||||
const payload = buildTransport('labels', form, value, files, base, '')
|
||||
expect(payload).toMatchObject({ leg: 'OVERSEAS', packingVersion: 3, labels: [{ cartonId: '31', fileId: '501', page: 1 }, { cartonId: '32', fileId: '501', page: 2 }] })
|
||||
expect(payload).not.toHaveProperty('sender')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'labels', payload }))).toEqual({ command: 'labels', payload })
|
||||
form.labels[0].trackingNo = 'changed-after-submit'; expect(payload.labels?.[0].trackingNo).toBe('TRACK-1')
|
||||
})
|
||||
it('rejects missing or duplicate cartons, wrong-purpose files and invalid image pages', () => {
|
||||
const value = detail(); let form = transportForm(value); form.labels.pop(); expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('完整覆盖')
|
||||
form = transportForm(value); form.labels[1].cartonId = '31'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('完整覆盖')
|
||||
form = transportForm(value); form.labels[0].fileId = '502'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('每箱选择本单')
|
||||
form = transportForm(value); expect(() => buildTransport('labels', form, value, [{ ...files[0], contentType: 'image/png' }], base, '')).toThrow('第 1 页')
|
||||
})
|
||||
it('uses domestic good quantities and replacement measurements, requiring proof for recipient changes', () => {
|
||||
const value = detail(); const form = transportForm(value, 'DOMESTIC'); form.labels[0] = { cartonId: '31', fileId: '501', page: 1, carrier: 'SF', trackingNo: 'SF-1' }
|
||||
expect(form.receiver).toEqual({ name: '客户收件人', phone: '13800000002', address: '深圳客户地址' })
|
||||
expect(value.transport.domesticCargo[0]).toMatchObject({ quantity: 5, lengthCm: '15.00', grossWeightKg: '1.987' })
|
||||
expect(buildTransport('labels', form, value, files, base, '')).not.toHaveProperty('addressVoucherFileId')
|
||||
form.receiver.address = '客户确认的新地址'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('客户确认凭证')
|
||||
form.addressVoucherFileId = '502'; expect(buildTransport('labels', form, value, files, base, '').addressVoucherFileId).toBe('502')
|
||||
})
|
||||
it('requires confirmed packing, domestic receipt completion and an unshipped leg for label changes', () => {
|
||||
const value = detail(); const form = transportForm(value); value.physical.packingStatus = 'DRAFT'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('确认装箱')
|
||||
value.physical.packingStatus = 'CONFIRMED'; value.purchaseFacts.overseas_shipped_at = '2020-01-03T10:00:00'; expect(() => buildTransport('labels', form, value, files, base, '')).toThrow('本段已交寄')
|
||||
value.purchaseFacts.domestic_received_at = null; expect(() => buildTransport('labels', transportForm(value, 'DOMESTIC'), value, files, base, '')).toThrow('国内收货完成')
|
||||
})
|
||||
it('makes zero estimate explicit without sending actual-charge fields', () => {
|
||||
const value = detail(); const form = transportForm(value); form.businessDate = '2020-01-02'
|
||||
expect(() => buildTransport('international-estimate', form, value, files, base, '')).toThrow('明确填写')
|
||||
form.amount = '0.00'; const payload = buildTransport('international-estimate', form, value, files, base, '')
|
||||
expect(payload).toEqual({ ...base, amount: '0.00', businessDate: '2020-01-02', labelVersion: 2 })
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'international-estimate', payload }))).not.toBeNull()
|
||||
})
|
||||
it('allows partial application only for current unapplied labels with a real time', () => {
|
||||
const value = detail(); value.transport.overseasLabels!.labels[0].appliedAt = null; const form = transportForm(value); form.cartonIds = ['31']; form.occurredAt = '2020-01-02T11:00:00'
|
||||
expect(buildTransport('labels/apply', form, value, files, base, '')).toEqual({ ...base, labelVersion: 2, cartonIds: ['31'], appliedAt: '2020-01-02T11:00:00' })
|
||||
form.cartonIds = ['32']; expect(() => buildTransport('labels/apply', form, value, files, base, '')).toThrow('尚未确认贴单')
|
||||
form.cartonIds = ['31']; form.occurredAt = '2020-01-01T10:00:00'; expect(() => buildTransport('labels/apply', form, value, files, base, '')).toThrow('早于上一节点')
|
||||
})
|
||||
it('ships the entire authoritative batch and checks label time and server readiness', () => {
|
||||
const value = detail(); const form = transportForm(value); form.cartonIds = ['31']; form.occurredAt = '2020-01-03T10:00:00'
|
||||
const payload = buildTransport('overseas-shipment', form, value, files, base, '')
|
||||
expect(payload).toEqual({ ...base, leg: 'OVERSEAS', labelVersion: 2, cartonIds: ['31', '32'], shippedAt: '2020-01-03T10:00:00' })
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'overseas-shipment', payload }))).not.toBeNull()
|
||||
form.occurredAt = '2020-01-02T12:30:00'; expect(() => buildTransport('overseas-shipment', form, value, files, base, '')).toThrow('早于上一节点')
|
||||
value.actions[0].allowed = false; value.actions[0].reason = '预估冻结未完成'; expect(() => buildTransport('overseas-shipment', form, value, files, base, '')).toThrow('预估冻结未完成')
|
||||
})
|
||||
it('keeps repacking one-to-one and never changes quantity or the original source', () => {
|
||||
const value = detail(); const form = transportForm(value, 'DOMESTIC'); Object.assign(form, { cartonId: '31', lengthCm: '16.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '2.123' })
|
||||
const payload = buildTransport('domestic-repack', form, value, files, base, '503')
|
||||
expect(payload).toEqual({ ...base, cartonId: '31', lengthCm: '16.00', widthCm: '20.00', heightCm: '30.00', grossWeightKg: '2.123', voucherFileId: '503' })
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'domestic-repack', payload }))).not.toBeNull()
|
||||
form.cartonId = 'other'; expect(() => buildTransport('domestic-repack', form, value, files, base, '503')).toThrow('可寄原箱')
|
||||
})
|
||||
it('rejects corrupted recovered shipment identity before any request', () => {
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'customer-shipment', payload: { ...base, labelVersion: 1, cartonIds: ['31', '31'], leg: 'DOMESTIC', shippedAt: '2020-01-03T10:00:00' } }))).toThrow('transport cartons')
|
||||
})
|
||||
})
|
||||