From a3354c92c26ade750636f4876f07e195c579ee79 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:57:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 +- .../service/TerminalDrayageService.java | 10 +-- .../db/phase3-terminal-drayage-schema.sql | 4 +- .../openapi/terminal-drayage-v1.yaml | 1 + .../TerminalDrayageSqlContractTest.java | 2 + .../ContainerWorkbenchServiceTest.java | 44 ++++++++++++- .../service/TerminalDrayageServiceTest.java | 49 +++++++++++++- iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../phase5-terminal-drayage-contract.test.mjs | 65 +++++++++++++++++++ .../phase5-terminal-drayage.mjs | 13 +++- 11 files changed, 179 insertions(+), 19 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 00e5d25b4..a462cdee7 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -231,7 +231,7 @@ SELECT - 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。 - 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态/资料管理、异常订单列表/创建/处理和客户回访列表/编辑十六项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。 -- 派送七类资料上传、替换、解绑和保存统一使用 `platform:overseas-warehouse:delivery:documents`,读取和下载仍使用 `platform:overseas-warehouse:delivery:list`;默认授予 `PLATFORM_ADMIN`、`PLATFORM_CUSTOM_ADMIN`、`WAREHOUSE_OPERATOR`。 +- 派送三类资料上传、替换、解绑和保存统一使用 `platform:overseas-warehouse:delivery:documents`,读取和下载仍使用 `platform:overseas-warehouse:delivery:list`;默认授予 `PLATFORM_ADMIN`、`PLATFORM_CUSTOM_ADMIN`、`WAREHOUSE_OPERATOR`。 - 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。 - `WAREHOUSE_OPERATOR` 获得十六项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8b9913b04..07b97973b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18120,7 +18120,7 @@ DROP PROCEDURE IF EXISTS assert_wallet_event_schema; USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', - fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照', pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', @@ -18160,8 +18160,8 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( ALTER TABLE fl_terminal_drayage MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', - MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', - MODIFY COLUMN fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + MODIFY COLUMN fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照', MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 1c25435c1..fe95c4aed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -164,12 +164,11 @@ public class TerminalDrayageService { WorkContext context = requiredLockedContext(containerBookingId); View replay = replay(context, action, key, request); if (replay != null) return replay; - TerminalDrayageEntity value = requiredAggregate(containerBookingId); + TerminalDrayageEntity value = mapper.lock(containerBookingId); requireVersion(value, request.expectedVersion()); LocalDateTime pickupAt = actualPickupAt(context, value); - if (pickupAt == null) throw conflict("实际提柜后才能确认还柜"); if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜"); - LocalDateTime earliest = pickupAt.isAfter(context.base().unloadingCompletedAt()) + LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt()) ? pickupAt : context.base().unloadingCompletedAt(); if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); @@ -177,6 +176,7 @@ public class TerminalDrayageService { request.remark(), operatorId, operatorName); int fromVersion = version(value); String fromStatus = status(context, value); + if (value == null) value = create(containerBookingId, operatorId, operatorName); applyAuthoritativeDeadline(context, value); value.setActualEmptyReturnAt(request.actualAt()); persist(value, fromVersion, operatorId, operatorName); @@ -277,14 +277,14 @@ public class TerminalDrayageService { if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence() && value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null && actualPickupAt(context, value) == null) result.add("RECORD_PICKUP"); - if (value != null && context.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null + if (context.base().unloadingCompletedAt() != null && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); return List.copyOf(result); } private String status(WorkContext context, TerminalDrayageEntity value) { if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED"; - if (context.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED"; + if (context.base().unloadingCompletedAt() != null) return "UNLOADED"; if (actualPickupAt(context, value) != null) return "PICKED_UP"; if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; CarrierScheduleNode current = node(context.facts().latestActualNode()); diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql index 47d88d067..f68365ccd 100644 --- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -4,8 +4,8 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', - fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', - fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + fleet_supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + fleet_supplier_name VARCHAR(128) NULL COMMENT 'Warehouse供应商名称快照', pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index 8cc4e5935..ad98b0d01 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -47,6 +47,7 @@ paths: /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: post: summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 + description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。 x-permission: platform:firstleg:terminal-drayage:empty-return:confirm parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java index 3111f706b..fd6635a2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -15,6 +15,8 @@ class TerminalDrayageSqlContractTest { "src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java")); assertThat(sql).contains( "CREATE TABLE IF NOT EXISTS fl_terminal_drayage (", + "fleet_supplier_id BIGINT UNSIGNED NULL", + "fleet_supplier_name VARCHAR(128) NULL", "UNIQUE KEY uk_terminal_drayage_container(container_booking_id)", "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (", "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java index 8fcbb1d3e..628f38447 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -95,7 +95,7 @@ class ContainerWorkbenchServiceTest { } @Test - void unloadedListWithoutDrayageAggregateDoesNotOfferUnexecutableReturn() throws Exception { + void unloadedListWithoutDrayageAggregateOffersEmptyReturn() throws Exception { verifyRealEmptyReturnProjection(true, false); } @@ -129,7 +129,7 @@ class ContainerWorkbenchServiceTest { var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions); var listed = page.records().get(0); var detail = service.overseasDetail(11L, permissions); - List expected = hasPermission && hasAggregate ? List.of("RECORD_EMPTY_RETURN") : List.of(); + List expected = hasPermission ? List.of("RECORD_EMPTY_RETURN") : List.of(); assertThat(listed.workbenchStage()).isEqualTo("UNLOADED"); assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED"); assertThat(listed.allowedActions()).isEqualTo(expected); @@ -143,6 +143,46 @@ class ContainerWorkbenchServiceTest { verify(mapper).contexts(List.of(11L)); } + @Test + void unloadedWorkbenchWithoutPickupOffersEmptyReturn() { + var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class); + var realDrayages = new TerminalDrayageService(mapper, + mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, + new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages); + ContainerBookingEntity container = container(); + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0); + container.setUnloadingCompletedAt(unloadedAt); + var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context( + 11L, container.getContainerNo(), 21L, "ACTIVE", unloadedAt); + LocalDateTime readyAt = unloadedAt.minusDays(1); + var actualFacts = new ContainerActualFacts(11L, 41L, 8, "READY", "PICKUP_READY", readyAt, + readyAt.minusDays(5), readyAt.minusDays(4), readyAt.minusDays(1), readyAt, null, null); + when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(repository.find(11L)).thenReturn(container); + when(repository.departed(11L)).thenReturn(true); + when(mapper.contexts(List.of(11L))).thenReturn(List.of(context)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.findAll(List.of(11L))).thenReturn(List.of()); + when(mapper.find(11L)).thenReturn(null); + when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(actualFacts)); + Set permissions = Set.of("platform:firstleg:terminal-drayage:fleet:edit", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + + var listed = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions).records().get(0); + var detail = service.overseasDetail(11L, permissions); + + for (var result : List.of(listed, detail)) { + assertThat(result.workbenchStage()).isEqualTo("UNLOADED"); + assertThat(result.terminalDrayage().status()).isEqualTo("UNLOADED"); + assertThat(result.terminalDrayage().version()).isZero(); + assertThat(result.terminalDrayage().actualPickupAt()).isNull(); + assertThat(result.allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN"); + assertThat(result.terminalDrayage().allowedActions()).containsExactly("SELECT_FLEET", "RECORD_EMPTY_RETURN"); + } + verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class)); + } + @Test void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() { ContainerBookingEntity container = container(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index 5f70e4462..16fae4d27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -165,16 +165,61 @@ class TerminalDrayageServiceTest { } @Test - void doesNotOfferEmptyReturnWithoutExecutableDrayageAggregate() { + void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() { LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); - Context context = context("PICKED_UP", unloadedAt); + Context context = context("ARRIVED_DESTINATION_PORT", unloadedAt); when(mapper.context(11L)).thenReturn(context); when(mapper.find(11L)).thenReturn(null); var result = service.detail(11L); assertThat(result.status()).isEqualTo("UNLOADED"); + assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN"); + assertThat(result.actualPickupAt()).isNull(); + assertThat(result.version()).isZero(); + } + + @Test + void createsReturnAggregateWithoutInventingPickupAndReplaysWithoutWritingAgain() { + Context context = context("ARRIVED_DESTINATION_PORT", LocalDateTime.of(2026, 9, 3, 0, 0)); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.lock(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.operation(11L, "return-new")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + ActualTimeRequest request = new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 4, 9, 0), "手动还柜"); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-new", request); + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(result.actualPickupAt()).isNull(); + assertThat(result.fleetSupplierId()).isNull(); + assertThat(result.version()).isEqualTo(1); assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); + assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void rejectsReturnBeforeUnloadWithoutAggregateOrPickup() { + when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", LocalDateTime.of(2026, 9, 3, 0, 0))); + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-early", + new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null))) + .isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); } @Test diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 537459834..9d5da3f64 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -124,7 +124,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送七类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs index 04935c24b..3e8b99fe7 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs @@ -3,9 +3,74 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { run } from './phase5-terminal-drayage.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8') +test('four real command payloads mutate contract fields and preserve remarks and state on conflict', async () => { + const marker = 'offline-terminal' + const containerBookingId = '9223372036854775806' + const containerNo = 'TEST0000001' + const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'] + const paths = ['fleet', 'pickup-appointment', 'pickup', 'empty-return'] + let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] } + const history = [] + const commands = new Map() + let conflicts = 0 + let replays = 0 + const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline-request', traceId: 'offline-trace' }) + const context = { + marker, token: 'offline-only', + createdResources: { + phase4InboundProducerFixtures: { marker, containers: [{ containerBookingId, containerNo, + shippingReady: true, carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }] }, + phase4OverseasPda: { marker, finalStatus: 'COMPLETED', completedContainerFacts: [{ containerBookingId, containerNo }] }, + }, + apiSuccess(_module, name, result) { assert.equal(result.body.code, '200', name); return result.body.data }, + assert(_module, name, _response, passed) { assert.ok(passed, name) }, + addStep(_module, name, _response, passed) { assert.ok(passed, name) }, + addCoverageGap() { assert.fail('unexpected missing fixture') }, + async request(method, path, body, _token, headers) { + if (path.includes('/suppliers?')) return response({ records: [{ id: '9223372036854775804', name: 'Offline fleet', status: 'ACTIVE' }] }) + if (method === 'GET') { + if (path.endsWith('/history')) return response(history) + if (path.includes('?containerBookingIds=')) return response([state]) + return response(state) + } + const actionPath = path.split('/').at(-1) + const index = paths.indexOf(actionPath) + assert.ok(index >= 0) + const field = index < 2 ? 'reason' : 'remark' + const forbidden = index < 2 ? 'remark' : 'reason' + assert.equal(Object.hasOwn(body, forbidden), false, `${actionPath} must not send ${forbidden}`) + const key = headers['Idempotency-Key'] + const saved = commands.get(key) + if (saved) { + if (JSON.stringify(saved.body) === JSON.stringify(body)) { replays++; return response(saved.state) } + assert.deepEqual(body, { ...saved.body, [field]: `${marker}-DIFFERENT` }) + conflicts++ + return response(null, 'A0409') + } + assert.equal(body[field], marker) + assert.equal(body.expectedVersion, state.version) + state = { ...state, version: state.version + 1 } + if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] }) + if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt + if (index === 2) Object.assign(state, { status: 'UNLOADED', actualPickupAt: body.actualAt, unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }) + if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } }) + history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] }) + commands.set(key, { body: structuredClone(body), state: structuredClone(state) }) + return response(state) + }, + } + await run(context) + assert.equal(commands.size, 4) + assert.equal(replays, 4) + assert.equal(conflicts, 4) + assert.equal(state.version, 4) + assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED') +}) + test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => { const modules = normalizeModuleOrder(['phase4-carrier-nodes']) const pickup = modules.indexOf('phase4-carrier-pickup-ready') diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs index b0ffb1391..15aac7dbd 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -46,12 +46,15 @@ async function command(context, target, action, path, body) { `${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key }) context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response, JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) - const changed = path === 'fleet' ? { ...body, reason: `${body.reason}-DIFFERENT` } + const changed = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` } : { ...body, reason: `${body.reason}-DIFFERENT` } const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`, changed, context.token, { 'Idempotency-Key': key }) context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const afterConflict = await api(context, 'GET', `${action}冲突后回读聚合`, `${BASE}/${target.containerBookingId}`) + context.assert(MODULE, `${action}冲突后聚合零漂移`, afterConflict.response, + JSON.stringify(afterConflict.data) === JSON.stringify(first.data), trace(afterConflict.response), OPTIONS) return first } @@ -104,7 +107,7 @@ export async function run(context) { const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000)) const picked = await command(context, target, '确认实际提柜', 'pickup', { - expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, reason: context.marker, + expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker, }) context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response, picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt @@ -115,7 +118,7 @@ export async function run(context) { const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) const returned = await command(context, target, '确认实际还柜', 'empty-return', { - expectedVersion: Number(picked.data.version), actualAt: returnedAt, reason: context.marker, + expectedVersion: Number(picked.data.version), actualAt: returnedAt, remark: context.marker, }) context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response, returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt @@ -135,6 +138,10 @@ export async function run(context) { ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) && history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1), `actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS) + context.assert(MODULE, '提柜和还柜备注写入不可变历史', history.response, + ['RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => + history.data?.some((item) => item.action === action && item.reason === context.marker)), + trace(history.response), OPTIONS) context.createdResources.phase5TerminalDrayage = { ...context.createdResources.phase5TerminalDrayage, containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo,