diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e752970bf..488d2d08c 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -19218,7 +19218,7 @@ 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 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 '实际提柜时间', + pickup_appointment_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', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -19226,9 +19226,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), - CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), - CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) - AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) ) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail ( id BIGINT UNSIGNED NOT NULL COMMENT '邮件任务ID', @@ -19264,7 +19262,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', @@ -19277,17 +19275,19 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( ) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; -- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。 +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_terminal_drayage' AND column_name='actual_pickup_at'), + 'ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at', 'SELECT 1')); +PREPARE terminal_drayage_drop_manual_pickup_stmt FROM @ddl; +EXECUTE terminal_drayage_drop_manual_pickup_stmt; +DEALLOCATE PREPARE terminal_drayage_drop_manual_pickup_stmt; + ALTER TABLE fl_terminal_drayage - DROP CHECK chk_terminal_drayage_times, - ADD CONSTRAINT chk_terminal_drayage_times CHECK( - (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND - (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)), 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 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小时计算的预计还柜时间', MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', @@ -19301,7 +19301,7 @@ ALTER TABLE fl_terminal_drayage ALTER TABLE fl_terminal_drayage_history DROP CHECK chk_terminal_drayage_history_action, ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')), + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')), MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', @@ -19365,7 +19365,6 @@ BEGIN OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit') OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit') OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit') - OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm') OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm') ) OR EXISTS ( SELECT 1 FROM iam_permission @@ -19376,7 +19375,6 @@ BEGIN OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201) OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202) OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203) - OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204) OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突'; @@ -19400,8 +19398,6 @@ BEGIN '维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0), (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit', '预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0), - (5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm', - '确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0), (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm', '确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE @@ -19434,7 +19430,6 @@ BEGIN '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') WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') @@ -19451,8 +19446,7 @@ BEGIN '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')) <> 9 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整'; END IF; @@ -19472,8 +19466,7 @@ BEGIN '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')) <> 18 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整'; END IF; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java index 494789da7..263235431 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -122,20 +122,6 @@ public class PlatformTerminalDrayageController { containerBookingId, user.userId(), user.username(), key, request), user.permissions())); } - @PostMapping("/{containerBookingId}/pickup") - @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')") - @Operation(summary = "确认目的港实际提柜") - @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER", - resourceId = "#containerBookingId", description = "确认目的港实际提柜") - public ApiResponse recordPickup(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long containerBookingId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ActualTimeRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.authorize(service.recordPickup( - containerBookingId, user.userId(), user.username(), key, request), user.permissions())); - } - @PostMapping("/{containerBookingId}/empty-return") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')") @Operation(summary = "确认实际还空柜") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java index ddd847c04..80035dae0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java @@ -17,7 +17,6 @@ public class TerminalDrayageEntity { private Long fleetSupplierId; private String fleetSupplierName; private LocalDateTime pickupAppointmentAt; - private LocalDateTime actualPickupAt; private LocalDateTime estimatedEmptyReturnAt; private LocalDateTime actualEmptyReturnAt; private Integer version; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java index dfc14f0df..1e713a7f1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -69,7 +69,7 @@ public interface TerminalDrayageMapper extends BaseMapper @Update(""" UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId}, fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt}, - actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, + estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId}, updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion} 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 033ac2009..96d6bd269 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 @@ -135,35 +135,6 @@ public class TerminalDrayageService { request.reason(), operatorId, operatorName); } - @Transactional - public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key, - ActualTimeRequest request) { - String action = "RECORD_PICKUP"; - WorkContext context = requiredLockedContext(containerBookingId); - View replay = replay(context, action, key, request); - if (replay != null) return replay; - requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜"); - TerminalDrayageEntity value = requiredAggregate(containerBookingId); - requireVersion(value, request.expectedVersion()); - if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) { - throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); - } - if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认"); - if (context.facts() != null && context.facts().latestActualAt() != null - && request.actualAt().isBefore(context.facts().latestActualAt())) { - throw badRequest("实际提柜时间不能早于当前实际船节点时间"); - } - advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, - request.remark(), operatorId, operatorName); - int fromVersion = version(value); - String fromStatus = status(context, value); - value.setActualPickupAt(request.actualAt()); - applyAuthoritativeDeadline(context, value); - persist(value, fromVersion, operatorId, operatorName); - return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, - fromVersion, request.remark(), operatorId, operatorName); - } - @Transactional public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key, ActualTimeRequest request) { @@ -297,9 +268,6 @@ public class TerminalDrayageService { result.add("SELECT_FLEET"); if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); } - 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 (context.base().unloadingCompletedAt() != null && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); return List.copyOf(result); @@ -333,8 +301,8 @@ public class TerminalDrayageService { } private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) { - return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() - : context.facts() == null ? null : context.facts().pickedUpAt(); + if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt(); + return context.base().unloadingCompletedAt(); } private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) { @@ -400,7 +368,6 @@ public class TerminalDrayageService { case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit"; case "SEND_PICKUP_EMAIL" -> "platform:firstleg:terminal-drayage:fleet:edit"; case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit"; - case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm"; case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm"; default -> ""; }; 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 eaf6d8cdc..3747d45b4 100644 --- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( 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 '乐观锁版本号', @@ -20,10 +19,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), - CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), - CONSTRAINT chk_terminal_drayage_times CHECK( - (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND - (actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) ) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail ( @@ -54,7 +50,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', - action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN', + action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_EMPTY_RETURN', from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', @@ -67,7 +63,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 023e9849a..a47fca32c 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -2143,7 +2143,7 @@ components: allowedActions: type: array description: 已同时按真实业务状态与当前用户权限裁剪 - items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} + items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'} terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'} ContainerWorkbenchStatusSummary: @@ -2185,12 +2185,12 @@ components: fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} fleetSupplierName: {type: string, nullable: true} pickupAppointmentAt: {type: string, format: date-time, nullable: true} - actualPickupAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间} estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: unloadingCompletedAt加72小时} actualEmptyReturnAt: {type: string, format: date-time, nullable: true} unloadingCompletedAt: {type: string, format: date-time, nullable: true} emptyReturnWarning: {type: object, nullable: true} - allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} ContainerBooking: type: object required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version] diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index 9c50f781a..3361eb0d6 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -38,13 +38,6 @@ paths: parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}} responses: {'200': {$ref: '#/components/responses/ViewResponse'}} - /api/platform/v1/terminal-drayages/{containerBookingId}/pickup: - post: - summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点 - x-permission: platform:firstleg:terminal-drayage:pickup:confirm - parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} - responses: {'200': {$ref: '#/components/responses/ViewResponse'}} /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: post: summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 @@ -153,12 +146,12 @@ components: fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} fleetSupplierName: {type: string, nullable: true} pickupAppointmentAt: {type: string, format: date-time, nullable: true} - actualPickupAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间} estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} actualEmptyReturnAt: {type: string, format: date-time, nullable: true} unloadingCompletedAt: {type: string, format: date-time, nullable: true} emptyReturnWarning: {$ref: '#/components/schemas/Warning'} - allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'} updatedAt: {type: string, format: date-time, nullable: true} ContainerActualFacts: @@ -181,7 +174,7 @@ components: type: object required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt] properties: - action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} + action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]} fromStatus: {type: string} toStatus: {type: string} fromVersion: {type: integer, minimum: 0} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java index dbae50224..fdc975777 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -33,7 +33,6 @@ class TerminalDrayageOpenApiContractTest { assertPermission("retryEmail", "platform:firstleg:terminal-drayage:fleet:edit"); assertPermission("emails", "platform:firstleg:terminal-drayage:list"); assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit"); - assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm"); assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm"); } @@ -43,18 +42,18 @@ class TerminalDrayageOpenApiContractTest { assertThat(yaml).contains( "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", - "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", "platform:firstleg:terminal-drayage:list", "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", "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays", "船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情", "允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件"); assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + assertThat(yaml).doesNotContain("/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "platform:firstleg:terminal-drayage:pickup:confirm", "RECORD_PICKUP"); } @Test 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 a8e42a201..52bc4bd58 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 @@ -26,24 +26,19 @@ class TerminalDrayageSqlContractTest { assertThat(Files.readString(path)).contains( "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", - "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')"); + "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_EMPTY_RETURN')"); } } @Test - void bothSqlEntrypointsAllowReturnWithoutPickupAndRetainKnownTimeOrdering() throws Exception { - for (Path path : java.util.List.of( - Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), - Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) { - String sql = Files.readString(path); - assertThat(sql).contains("actual_empty_return_at IS NULL OR actual_pickup_at IS NULL OR actual_empty_return_at >= actual_pickup_at") - .doesNotContain("actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL"); - } + void bothSqlEntrypointsRemoveTheManualPickupTimestamp() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + assertThat(baseline).doesNotContain("actual_pickup_at", "chk_terminal_drayage_times"); String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); - assertThat(release).contains("DROP CHECK chk_terminal_drayage_times", - "ADD CONSTRAINT chk_terminal_drayage_times CHECK(", + assertThat(release).contains("ALTER TABLE fl_terminal_drayage DROP COLUMN actual_pickup_at", "DROP CHECK chk_terminal_drayage_history_action", "ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); + assertThat(release).doesNotContain("chk_terminal_drayage_times"); } @Test 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 a9a814cfa..74175e9c0 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 @@ -142,7 +142,6 @@ class ContainerWorkbenchServiceTest { row.setId(51L); row.setContainerBookingId(11L); row.setVersion(3); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0)); 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); @@ -205,9 +204,9 @@ class ContainerWorkbenchServiceTest { 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"); + assertThat(result.terminalDrayage().actualPickupAt()).isEqualTo(unloadedAt); + assertThat(result.allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); + assertThat(result.terminalDrayage().allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); } verify(mapper, never()).insert(any(com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity.class)); } 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 6990ee7f0..3d002d287 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 @@ -104,40 +104,12 @@ class TerminalDrayageServiceTest { verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); } - @Test - void recordsPickupOnceAndAdvancesCanonicalNode() { - Context before = context("PICKUP_READY", null); - Context after = context("PICKED_UP", null); - when(mapper.lockContext(11L)).thenReturn(before); - when(mapper.context(11L)).thenReturn(after); - when(mapper.operation(11L, "pickup-1")).thenReturn(null); - TerminalDrayageEntity row = row(2); - row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); - when(mapper.lock(11L)).thenReturn(row); - when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { - row.setVersion(3); - return 1; - }); - when(mapper.find(11L)).thenReturn(row); - - var result = service.recordPickup(11L, 7L, "admin", "pickup-1", - new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜")); - - assertThat(result.status()).isEqualTo("PICKED_UP"); - assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); - ArgumentCaptor node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class); - verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); - assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); - verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); - } - @Test void requiresOverseasUnloadBeforeEmptyReturn() { Context context = context("PICKED_UP", null); when(mapper.lockContext(11L)).thenReturn(context); when(mapper.operation(11L, "return-1")).thenReturn(null); TerminalDrayageEntity row = row(3); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); when(mapper.lock(11L)).thenReturn(row); assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", @@ -152,7 +124,6 @@ class TerminalDrayageServiceTest { LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); Context context = context("PICKED_UP", unloadedAt); TerminalDrayageEntity row = row(3); - row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0)); when(mapper.context(11L)).thenReturn(context); when(mapper.find(11L)).thenReturn(row); @@ -178,7 +149,7 @@ class TerminalDrayageServiceTest { assertThat(result.status()).isEqualTo("UNLOADED"); assertThat(result.allowedActions()).contains("RECORD_EMPTY_RETURN"); - assertThat(result.actualPickupAt()).isNull(); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0)); assertThat(result.version()).isZero(); } @@ -206,7 +177,7 @@ class TerminalDrayageServiceTest { 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.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 3, 0, 0)); assertThat(result.fleetSupplierId()).isNull(); assertThat(result.version()).isEqualTo(1); assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); @@ -229,12 +200,12 @@ class TerminalDrayageServiceTest { void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() { Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0)); TerminalDrayageEntity row = row(3); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); when(mapper.context(11L)).thenReturn(context); when(mapper.find(11L)).thenReturn(row); var raw = service.detail(11L); assertThat(raw.status()).isEqualTo("UNLOADED"); + assertThat(raw.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 8, 0)); assertThat(service.authorize(raw, Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions()) .containsExactly("RECORD_EMPTY_RETURN"); @@ -265,22 +236,6 @@ class TerminalDrayageServiceTest { verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L)); } - @Test - void allowedActionsAreReducedByFrozenPermissionMatrix() { - Context context = context("PICKUP_READY", null); - TerminalDrayageEntity row = row(3); - row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); - when(mapper.context(11L)).thenReturn(context); - when(mapper.find(11L)).thenReturn(row); - - var raw = service.detail(11L); - var authorized = service.authorize(raw, - Set.of("platform:firstleg:terminal-drayage:pickup:confirm")); - - assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP"); - assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP"); - } - @Test void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { Context context = context("ARRIVED_DESTINATION_PORT", null); @@ -337,7 +292,6 @@ class TerminalDrayageServiceTest { when(mapper.operation(11L, "return-2")).thenReturn(null); TerminalDrayageEntity row = row(3); row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); - row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); when(mapper.lock(11L)).thenReturn(row); when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { row.setVersion(4); diff --git a/iam/src/main/resources/db/container-workspace-permissions.sql b/iam/src/main/resources/db/container-workspace-permissions.sql index 57b15c1fd..b55c70ebb 100644 --- a/iam/src/main/resources/db/container-workspace-permissions.sql +++ b/iam/src/main/resources/db/container-workspace-permissions.sql @@ -15,7 +15,6 @@ BEGIN OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit') OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit') OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit') - OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm') OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm') ) OR EXISTS ( SELECT 1 FROM iam_permission @@ -26,7 +25,6 @@ BEGIN OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201) OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202) OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203) - OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204) OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突'; @@ -50,8 +48,6 @@ BEGIN '维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0), (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit', '预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0), - (5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm', - '确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0), (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm', '确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE @@ -84,7 +80,6 @@ BEGIN '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') WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') @@ -101,8 +96,7 @@ BEGIN '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')) <> 9 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整'; END IF; @@ -122,8 +116,7 @@ BEGIN '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')) <> 18 THEN + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 16 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整'; END IF; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index dd8fce05a..33640c020 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:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 9223ad914..99e75072f 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -42,7 +42,6 @@ class IamOpenApiContractTest { .contains("platform:firstleg:terminal-drayage:document:edit") .contains("platform:firstleg:terminal-drayage:fleet:edit") .contains("platform:firstleg:terminal-drayage:pickup-appointment:edit") - .contains("platform:firstleg:terminal-drayage:pickup:confirm") .contains("platform:firstleg:terminal-drayage:empty-return:confirm") .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java index 64b215294..70a0f887e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -22,7 +22,6 @@ class ContainerWorkspacePermissionSqlContractTest { "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"); @Test @@ -50,7 +49,6 @@ class ContainerWorkspacePermissionSqlContractTest { "'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'", "'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'", "'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'", - "'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'", "'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'"); } @@ -75,8 +73,8 @@ class ContainerWorkspacePermissionSqlContractTest { "MERCHANT_OWNER"); assertThat(sql).contains( "permission_code IN (", - ")) <> 9 THEN", - ")) <> 18 THEN"); + ")) <> 8 THEN", + ")) <> 16 THEN"); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index f73da7fe9..008055319 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -853,6 +853,40 @@ class ReceivingContainerServiceBehaviorTest { .containsExactly("SKU-A", "SKU-B", "SKU-C"); } + @Test + void mixedContainerFbaBulkNeverReceivesDropshipOrOrdinaryOverseasLines() { + InboundPlanLine fba = line("FBA"); + InboundPlanLine dropship = line("FBA"); + dropship.setId(20L); dropship.setOrderId(2L); dropship.setInboundCode("IN-DROPSHIP"); + InboundPlanLine overseas = line("OVERSEAS"); + overseas.setId(30L); overseas.setOrderId(3L); overseas.setInboundCode("IN-OVERSEAS"); + LastlegOrder dropshipOrder = order("FBA"); + dropshipOrder.setId(2L); dropshipOrder.setOrderType("ONE_CLICK_DROPSHIP"); + LastlegOrder overseasOrder = order("OVERSEAS"); overseasOrder.setId(3L); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "SIGNED")); + when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(lines.selectList(any())).thenReturn(List.of(dropship, overseas, fba)); + when(orders.selectById(1L)).thenReturn(order("FBA")); + when(orders.selectById(2L)).thenReturn(dropshipOrder); + when(orders.selectById(3L)).thenReturn(overseasOrder); + when(orderDetails.selectList(any())).thenReturn(List.of(detail("FBA-SKU", 2))); + stubReservedFbaPallet(); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenAnswer(invocation -> invocation.getArgument(4)); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var result = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 2, "mixed-fba-receive")); + + assertThat(result.acceptedPackages()).isEqualTo(2); + ArgumentCaptor captured = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(workflow).receiveBulkResolved(any(), any(), captured.capture(), any(), + org.mockito.ArgumentMatchers.eq(2), any(), any()); + assertThat(captured.getValue()).isSameAs(fba); + verifyNoInteractions(dropshipProfiles, dropshipCartons); + } + @Test void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() { stubCurrent("OVERSEAS", "CONT-A"); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 2536eb322..d8b004768 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitt 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-browser-fixture', 'mixed-container-receiving'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -44,6 +44,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, const MODULE_DEPENDENCIES = new Map([ ['dropship-pallet-matrix', ['merchant-qualification-fixture']], ['dropship-browser-fixture', ['merchant-qualification-fixture']], + ['mixed-container-receiving', ['merchant-qualification-fixture']], ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 029302c00..1345d5b68 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -37,6 +37,14 @@ export async function run(context) { ## 可用上下文 +`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。 + +一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。 + +一件代发矩阵在每个剩余实物托盘收货前核对待收箱数,并以 `allowDifference=false` 尝试正常完成:必须返回 A0409,回查会话事实不变后才继续收货。这不覆盖显式差异完成,不得把正常完成门禁解读成系统禁止一切差异结案。 + +每托首次收货及重复扫描后,矩阵按本轮已提交的来源托盘、箱唛和入仓码独立计算期望进度,回查柜级及每票的总箱数、实收、待收三项。柜级总数正确但串票、重复行、缺票或逐票待收未刷新仍判失败。离线验证器包含三票交替进度样本,但该样本不等于三类混柜真实 HTTP 验收。 + `dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。 `dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 diff --git a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs index f4eb8d493..9e36a4786 100644 --- a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs +++ b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs @@ -5,6 +5,30 @@ const MODULE = 'dropship-pallet-matrix' const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true } const BASE = '/api/platform/v1/last-leg/outbound-orders' +// Expected progress comes from submitted fixture operations, never the server response. +export function receivingProgressMatches(snapshot, expected) { + const count = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN + if (!Array.isArray(snapshot?.inbounds) || !Array.isArray(expected) || !expected.length) return false + if (new Set(expected.map(row => row.inboundCode)).size !== expected.length + || snapshot.inbounds.length !== expected.length + || new Set(snapshot.inbounds.map(row => row.inboundCode)).size !== expected.length) return false + let total = 0 + let received = 0 + for (const row of expected) { + if (!Number.isInteger(row.total) || !Number.isInteger(row.received) + || row.total <= 0 || row.received < 0 || row.received > row.total) return false + const actual = snapshot.inbounds.find(item => item.inboundCode === row.inboundCode) + if (!actual || count(actual.totalPackages) !== row.total + || count(actual.receivedPackages) !== row.received + || count(actual.pendingPackages) !== row.total - row.received) return false + total += row.total + received += row.received + } + return count(snapshot.totalPackages) === total && count(snapshot.receivedPackages) === received + && count(snapshot.pendingPackages) === total - received +} + function record(edge, name, response, passed, evidence = '') { edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS) } @@ -25,6 +49,41 @@ async function taskSnapshot(edge) { return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`) } +export async function outboundCandidateGuards(edge) { + const warehouse = encodeURIComponent(edge.warehouseId) + const customer = encodeURIComponent(edge.customerId) + const paths = [ + ['平台首次查询不传客户', `${BASE}/one-click-dropship/candidates?warehouseId=${warehouse}`], + ['平台指定客户查询', `${BASE}/one-click-dropship/candidates?customerId=${customer}&warehouseId=${warehouse}`], + ] + const snapshots = [] + for (const [name, path] of paths) { + const rows = await edge.call(name, 'GET', path) + edge.check(`${edge.label}${name}返回候选数组`, null, Array.isArray(rows)) + edge.check(`${edge.label}${name}仓库隔离`, null, + rows.every(row => String(row.overseasWarehouseId) === String(edge.warehouseId))) + if (name === '平台指定客户查询') { + edge.check(`${edge.label}指定客户不返回其他客户库存`, null, + rows.every(row => String(row.customerId) === String(edge.customerId))) + } + const own = rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode)) + for (const record of edge.group) { + const selected = own.filter(row => row.inboundCode === record.source.inboundCode) + const expectedPallets = [...new Set(record.task.pallets.map(p => p.palletNo))].sort() + const actualPallets = [...new Set(selected.map(row => row.palletNo))].sort() + edge.check(`${record.label}${name}全部托盘与箱数完整`, null, + JSON.stringify(expectedPallets) === JSON.stringify(actualPallets) + && selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === record.expectedCartonMarks.length + && selected.every(row => String(row.customerId) === String(edge.customerId))) + } + snapshots.push(own.map(row => ({ inbound: row.inboundCode, pallet: row.palletNo, + position: String(row.inventoryPositionId), sku: row.skuCode, packages: Number(row.availablePackages) })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))) + } + edge.check(`${edge.label}客户筛选前后本轮库存事实一致`, null, + JSON.stringify(snapshots[0]) === JSON.stringify(snapshots[1])) +} + export async function outboundCreationGuards(edge) { const before = await candidates(edge) const first = edge.body.items[0] diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index db8a42ad7..06be6a3f5 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -3,7 +3,78 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { sameOutboundTask } from './dropship-pallet-matrix.mjs' -import { probePartialPick } from './dropship-outbound-edge-cases.mjs' +import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' + +test('receiving verifier follows three independent inbound codes through interleaved progress', () => { + const expected = [{ inboundCode: 'FBA', total: 5, received: 0 }, + { inboundCode: 'DROPSHIP', total: 4, received: 0 }, { inboundCode: 'OVERSEAS', total: 3, received: 0 }] + for (const [index, count] of [[0, 2], [1, 1], [2, 3], [0, 3], [1, 3]]) { + expected[index].received += count + const snapshot = { totalPackages: 12, receivedPackages: expected.reduce((sum, x) => sum + x.received, 0), + pendingPackages: expected.reduce((sum, x) => sum + x.total - x.received, 0), + inbounds: expected.map(x => ({ inboundCode: x.inboundCode, totalPackages: x.total, + receivedPackages: x.received, pendingPackages: x.total - x.received })) } + assert.equal(receivingProgressMatches(snapshot, expected), true) + const stale = structuredClone(snapshot) + stale.inbounds[index].pendingPackages += 1 + assert.equal(receivingProgressMatches(stale, expected), false) + const swapped = structuredClone(snapshot) + swapped.inbounds[0].receivedPackages += 1 + swapped.inbounds[1].receivedPackages -= 1 + assert.equal(receivingProgressMatches(swapped, expected), false, 'correct container total cannot conceal wrong ownership') + const duplicate = structuredClone(snapshot) + duplicate.inbounds[2] = duplicate.inbounds[0] + assert.equal(receivingProgressMatches(duplicate, expected), false) + } + assert.equal(receivingProgressMatches({ inbounds: [] }, []), false) +}) + +test('receiving verifier does not interpret absent or malformed progress as zero', () => { + const expected = [{ inboundCode: 'IN', total: 4, received: 0 }] + const good = { totalPackages: 4, receivedPackages: 0, pendingPackages: 4, + inbounds: [{ inboundCode: 'IN', totalPackages: 4, receivedPackages: 0, pendingPackages: 4 }] } + for (const value of [null, undefined, '', false, -1, 0.5]) { + const snapshot = structuredClone(good) + snapshot.inbounds[0].receivedPackages = value + assert.equal(receivingProgressMatches(snapshot, expected), false) + } + assert.equal(receivingProgressMatches(good, expected), true) +}) + +function candidateProbe(transform = rows => rows) { + const rows = ['P1', 'P2'].map((palletNo, index) => ({ palletNo, inventoryPositionId: index + 1, + inboundCode: 'IN-1', skuCode: 'SKU', availablePackages: 2, customerId: 'C1', overseasWarehouseId: 'W1' })) + const paths = [] + return { paths, edge: { label: 'MULTI', customerId: 'C1', warehouseId: 'W1', + group: [{ label: 'MULTI', source: { inboundCode: 'IN-1' }, + task: { pallets: [{ palletNo: 'P1' }, { palletNo: 'P2' }] }, expectedCartonMarks: ['a', 'b', 'c', 'd'] }], + check: (name, _response, passed) => assert.ok(passed, name), + call: async (_name, method, path) => { + assert.equal(method, 'GET'); paths.push(path) + return transform(structuredClone(rows), path) + }, + } } +} + +test('candidate probe sends the actual platform query without customer and checks both pallets', async () => { + const { edge, paths } = candidateProbe() + await outboundCandidateGuards(edge) + assert.equal(new URL(paths[0], 'https://example.invalid').searchParams.has('customerId'), false) + assert.equal(new URL(paths[1], 'https://example.invalid').searchParams.get('customerId'), 'C1') +}) + +for (const [name, transform] of [ + ['missing second pallet', rows => rows.slice(0, 1)], + ['wrong warehouse', rows => rows.map(row => ({ ...row, overseasWarehouseId: 'W2' }))], + ['wrong customer', rows => rows.map(row => ({ ...row, customerId: 'C2' }))], + ['bad request response', () => ({ code: 'A0400', message: 'Bad request' })], + ['candidate identity drift after filtering', (rows, path) => path.includes('customerId=') + ? rows.map(row => ({ ...row, inventoryPositionId: row.inventoryPositionId + 10 })) : rows], +]) { + test(`candidate probe fails closed on ${name}`, async () => { + await assert.rejects(outboundCandidateGuards(candidateProbe(transform).edge)) + }) +} test('browser fixture is explicit and stops before overseas receipt or outbound', () => { assert.deepEqual(normalizeModuleOrder(['dropship-browser-fixture']), ['merchant-qualification-fixture', 'dropship-browser-fixture']) @@ -47,6 +118,21 @@ test('outbound replay preserves business identity and database millisecond times const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') const edges = readFileSync(new URL('./dropship-outbound-edge-cases.mjs', import.meta.url), 'utf8') +test('matrix runs candidate probes before selecting inventory or creating outbound', () => { + const probe = source.indexOf('await outboundCandidateGuards(') + assert.ok(probe > 0) + assert.ok(probe < source.indexOf("const all = await call('读取专用出库可用库存'")) +}) + +test('each remaining pallet tests normal completion rejection and zero drift before receiving', () => { + const loop = source.slice(source.indexOf('for (const [index, sourcePalletNo] of sourceNos.entries())'), + source.indexOf("check(`${label}整柜实收总箱数守恒`")) + assert.match(loop, /allowDifference: false/) + assert.match(loop, /premature.body\?\.code === 'A0409'/) + assert.match(loop, /JSON.stringify\(unchanged\) === JSON.stringify\(before\)/) + assert.ok(loop.indexOf('JSON.stringify(unchanged)') < loop.indexOf("const received = await call")) +}) + test('outbound edge probes preserve failures and require zero drift before continuing', () => { assert.match(edges, /deploymentBlocker: true/) assert.match(edges, /四箱任务先拣一箱成功/) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index 1498936bb..d0a96fc11 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -11,7 +11,8 @@ import { run as completeCustoms } from './phase3-customs.mjs' import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs' import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs' -import { outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' +import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' +import { receiveMixedContainer } from './mixed-container-receiving.mjs' const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' @@ -37,7 +38,11 @@ export async function prepareBrowserFixture(context) { return runWorkflow(context, true) } -async function runWorkflow(context, stopBeforeOverseasPda) { +export async function runMixedContainer(context) { + return runWorkflow(context, false, true) +} + +async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = false) { const marker = context.marker const savedOptions = context.options.prepareDropshipManualCount const savedContext = { ...context.context } @@ -48,25 +53,40 @@ async function runWorkflow(context, stopBeforeOverseasPda) { context.assert(MODULE, name, response, passed, details, OPTIONS) if (!passed) throw new Error(name) } - async function prepare(label, palletCount, mode = {}) { + async function prepare(label, palletCount, mode = {}, destinationType = null) { context.marker = `${marker}-${label}` for (const key of ['orders', 'inboundMeasurement', 'ocrInbound', 'testInbound', 'dropshipManualCount']) { delete context.createdResources[key] } for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] - context.context.orderDestinationType = 'OVERSEAS' - context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.orderDestinationType = destinationType || 'OVERSEAS' + context.context.orderFulfillmentType = destinationType ? 'STANDARD' : 'ONE_CLICK_DROPSHIP' context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount } if (label.startsWith('MULTI-MERGE-')) { context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)] } - context.options.prepareDropshipManualCount = true + context.options.prepareDropshipManualCount = !destinationType const start = context.steps.length await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context)) await context.executeModule(`${MODULE}-${label}-measurement`, () => measure(context)) check(`${label}本轮订单和测量前置全部通过`, null, !context.steps.slice(start).some((step) => !step.passed)) + if (destinationType) { + const measurement = { ...context.createdResources.inboundMeasurement } + const order = { ...context.createdResources.orders } + const response = await context.request('GET', + `/api/platform/inbounds/${measurement.inboundId}?tenantId=${measurement.tenantId}`) + const detail = context.apiSuccess(MODULE, `${label}普通订单待分柜快照`, response, '', OPTIONS) + check(`${label}普通订单四箱待分柜且类型未漂移`, response, + detail?.summary?.processStatus === 'WAIT_SPLIT' && measurement.actualPackageCount === 4 + && order.fulfillmentType === 'STANDARD' && order.destinationType === destinationType) + const record = { label, order, measurement, summary: detail.summary, task: null, + source: { inboundId: String(measurement.inboundId), inboundCode: detail.summary.inboundCode }, + expectedCartonMarks: Array.from({ length: 4 }, (_, index) => `${detail.summary.inboundCode}#4-${index + 1}`) } + records.push(record) + return record + } const source = context.createdResources.dropshipManualCount check(`${label}存在本轮未清点任务`, null, Boolean(source?.taskId)) const task = await completeDropshipPalletization(context, { @@ -112,11 +132,11 @@ async function runWorkflow(context, stopBeforeOverseasPda) { check(`${label}${name}成功`, response, response?.body?.code === '200' && Boolean(value)) return value } - const pallets = [...new Map(group.flatMap((item) => item.task.pallets) + const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || []) .map((pallet) => [String(pallet.palletId), pallet])).values()] for (const record of group) { const fee = await ensureInitialCalculated(context, { ...record.measurement, - tenantId: String(record.task.tenantId), inboundId: record.source.inboundId, + tenantId: String(record.order.tenantId), inboundId: record.source.inboundId, finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS) check(`${record.label}初始费用已计算`, null, Boolean(fee)) } @@ -136,14 +156,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) { check(`${label}${name}没有混入其他订单`, null, detail.items?.every((item) => expectedIds.has(String(item.inboundId)))) check(`${label}${name}整托汇总不重复计算箱数`, null, - plan.items?.length === pallets.length + (mixedContainer || plan.items?.length === pallets.length) && plan.items.reduce((sum, item) => sum + Number(item.packageCount), 0) === group.reduce((sum, record) => sum + record.expectedCartonMarks.length, 0)) for (const record of group) { const actual = detail.items.filter((item) => String(item.inboundId) === record.source.inboundId) check(`${record.label}${name}分配箱数与托盘身份正确`, null, actual.reduce((sum, item) => sum + Number(item.packageCount), 0) === record.expectedCartonMarks.length - && actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId)))) + && (!record.task || actual.every((item) => record.task.pallets.some((pallet) => String(pallet.palletId) === String(item.palletId))))) } } for (const pallet of pallets) { @@ -152,7 +172,16 @@ async function runWorkflow(context, stopBeforeOverseasPda) { operationId: `${context.marker}-add-${pallet.palletId}`, }) } + if (mixedContainer) { + for (const record of group.filter(item => !item.task)) { + plan = await command(`${record.label}普通订单按整票箱数加入`, 'POST', `${path}/inbound-assignments`, { + inboundId: record.source.inboundId, packageCount: record.expectedCartonMarks.length, + expectedVersion: Number(plan.version), operationId: `${context.marker}-add-${record.label}`, + }) + } + } await conserve('手工加入后') + if (!mixedContainer) { for (const pallet of pallets) { plan = await command('按物理托盘完整移除', 'DELETE', `${path}/pallet-assignments/${pallet.palletId}`, { expectedPlanVersion: Number(plan.version), operationId: `${context.marker}-remove-${pallet.palletId}`, @@ -164,13 +193,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) { customerId: String(group[0].task.tenantId), inboundCode: group[0].source.inboundCode, }) await conserve('智能推荐后') + } for (const record of group) record.loading = { cabinet, plan, allocationVerified: true } const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS) const agent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id)) const suffix = markerSuffix(context.marker) const draft = oceanBookingDraft(context, suffix, { - customerId: String(group[0].task.tenantId), customerName: summary.customerName, + customerId: String(group[0].order.tenantId), customerName: summary.customerName, warehouseId: String(summary.warehouseId), channelCode: summary.channelCode, }, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name, bookingAgentId: String(agent.id), bookingAgentName: agent.name }) @@ -211,10 +241,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) { warehouseId: String(summary.warehouseId), loadingPlanId: String(plan.id), containerBookingId: String(cabinet.id), expectedVersion: Number(claimed.version), inventorySnapshotToken: inventory.snapshotToken, items: claimed.items.map((item) => ({ itemId: String(item.id), version: Number(item.version), - action: 'PICK_PALLET', palletNo: item.palletNo })), + ...(item.unitType === 'SEALED_PALLET' + ? { action: 'PICK_PALLET', palletNo: item.palletNo } + : { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })), } check(`${label}PDA全部任务均为封托单元`, null, - claimed.items.length > 0 && claimed.items.every((item) => item.unitType === 'SEALED_PALLET')) + claimed.items.length > 0 && (mixedContainer + ? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET') + : claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))) const picked = await command('整托拣货', 'POST', `${pda}/batch`, batch, { 'Idempotency-Key': `${context.marker}-pick` }) check(`${label}整托拣货等待最终确认`, null, picked.workflowStatus === 'AWAITING_REVIEW') @@ -236,7 +270,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) { `status=${finalTask?.taskStatus}, attempts=${terminal.attempts}`) for (const record of group) { const inbound = await command(`${record.label}回查入仓单实装状态`, 'GET', - `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.task.tenantId}`) + `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`) check(`${record.label}装柜完成回写`, null, inbound.summary?.processStatus === 'LOADED') record.loading = { cabinet: linked, plan, task: finalTask, completed, booking: released, allocationVerified: true, loadingVerified: true, containerNo } @@ -245,10 +279,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) { async function progressShipping(group) { const record = group[0] context.marker = `${marker}-${record.label}-SHIPPING` - context.createdResources.orders = { ...record.order, tenantId: String(record.task.tenantId), + context.createdResources.orders = { ...record.order, tenantId: String(record.order.tenantId), inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId) } context.createdResources.phase3Loading = { - marker: context.marker, tenantId: String(record.task.tenantId), orderId: record.order.orderId, + marker: context.marker, tenantId: String(record.order.tenantId), orderId: record.order.orderId, inboundId: record.source.inboundId, warehouseId: String(record.summary.warehouseId), containerBookingId: String(record.loading.cabinet.id), loadingPlanId: String(record.loading.plan.id), oceanBookingId: String(record.loading.booking.oceanBooking.id), @@ -270,7 +304,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) { String(inbound.inboundId) === String(item.source.inboundId) && inbound.inboundStatus === 'LOADED')) && detail.timeline?.some((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH')) context.createdResources.phase3OutboundDispatch = { marker: context.marker, containerBookingId: bookingId, - tenantId: String(record.task.tenantId), confirmedStatus: aggregate.dispatchStatus } + tenantId: String(record.order.tenantId), confirmedStatus: aggregate.dispatchStatus } } for (const [name, action] of [['customs', completeCustoms], ['departure', confirmDomesticDeparture], ['carrier-query', verifyCarrierQuery], ['pickup-ready', runUntilPickupReady]]) { @@ -309,7 +343,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) { check(`${label}跨区事件逐票建单完整`, null, summary?.orders?.length === group.length && group.every((record) => summary.orders.some((order) => order.inboundCode === record.source.inboundCode - && String(order.customerId) === String(record.task.tenantId)))) + && String(order.customerId) === String(record.order.tenantId)))) const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`) check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length) const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), @@ -321,10 +355,10 @@ async function runWorkflow(context, stopBeforeOverseasPda) { const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode)) check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1) const target = matching[0] - const expectedNos = record.task.pallets.map((pallet) => pallet.palletNo).sort() + const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort() const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort() check(`${record.label}来源托盘完整绑定海外真实资源`, null, - JSON.stringify(expectedNos) === JSON.stringify(actualNos) + (expectedNos ? JSON.stringify(expectedNos) === JSON.stringify(actualNos) : actualNos.length > 0) && target.pallets.every((pallet) => pallet.warehousePalletId && pallet.warehouseLocationId)) record.overseas = { plan: target, warehouseId: String(preview.overseasWarehouseId), summary: summary.orders.find((order) => order.inboundCode === record.source.inboundCode), @@ -373,9 +407,35 @@ async function runWorkflow(context, stopBeforeOverseasPda) { const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`, { photos, idempotencyKey: `${context.marker}-sign` }) check(`${label}签收成功`, null, signed.status === 'SIGNED') + if (mixedContainer) { + const completed = await receiveMixedContainer({ group, call, check, marker: context.marker, + request: (method, path, body) => context.requestOverseas(method, path, body, {}, picker.token) }) + for (const item of group) { + const stock = await call(`${item.label}混柜完成后按入仓码查询库存`, 'POST', + '/api/pda/v1/last-leg/inventory/resolve', { code: item.source.inboundCode }) + check(`${item.label}库存归属和真实托盘绑定正确且箱数核平`, null, + stock.queryCode === item.source.inboundCode && Array.isArray(stock.items) && stock.items.length > 0 + && stock.items.every(row => row.inboundCode === item.source.inboundCode + && String(row.customerId) === String(item.order.tenantId) + && row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId))) + && stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length) + item.overseas.receiving = { completed, stock, receivingVerified: true } + } + return + } const sourceNos = [...new Set(group.flatMap((item) => item.task.pallets.map((pallet) => pallet.palletNo)))] + const receivedPallets = new Set() let accepted = 0 for (const [index, sourcePalletNo] of sourceNos.entries()) { + const before = await call('未齐收货前回查柜级事实', 'GET', `${base}/current`) + check(`${label}剩余托盘待收箱数准确`, null, + Number(before.pendingPackages) === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0) - accepted) + const premature = await context.requestOverseas('POST', `${base}/current/complete`, + { idempotencyKey: `${context.marker}-premature-${index}`, allowDifference: false }, {}, picker.token) + check(`${label}未收齐不得按正常完成提交`, premature, premature.body?.code === 'A0409') + const unchanged = await call('未齐完成被拒后回查柜级事实', 'GET', `${base}/current`) + check(`${label}未齐完成拒绝后事实不变且可继续收货`, null, + JSON.stringify(unchanged) === JSON.stringify(before)) const body = { sourcePalletNo, idempotencyKey: `${context.marker}-pallet-${index}` } const received = await call('按来源实物托盘整托收货', 'POST', `${base}/current/receipts/pallet`, body) accepted += Number(received.acceptedPackages) @@ -385,6 +445,14 @@ async function runWorkflow(context, stopBeforeOverseasPda) { { sourcePalletNo, idempotencyKey: `${context.marker}-duplicate-${index}` }) check(`${label}同托重复扫描不重复收货`, null, duplicate.result === 'ALREADY_RECEIVED' && Number(duplicate.acceptedPackages) === 0) + receivedPallets.add(sourcePalletNo) + const progress = await call('每托收货及重扫后回查逐票进度', 'GET', `${base}/current`) + const expected = group.map(record => ({ inboundCode: record.source.inboundCode, + total: record.expectedCartonMarks.length, + received: new Set(record.task.pallets.filter(p => receivedPallets.has(p.palletNo)) + .flatMap(p => p.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId)) + .map(c => c.boxCode)).size })) + check(`${label}每托实收不串票且柜级与逐票剩余同步`, null, receivingProgressMatches(progress, expected)) } check(`${label}整柜实收总箱数守恒`, null, accepted === group.reduce((sum, item) => sum + item.expectedCartonMarks.length, 0)) @@ -416,6 +484,7 @@ async function runWorkflow(context, stopBeforeOverseasPda) { return value } const candidatePath = `${base}/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${warehouseId}` + await outboundCandidateGuards({ context, group, customerId, warehouseId, label, check, call }) const all = await call('读取专用出库可用库存', 'GET', candidatePath) const selected = all.filter((candidate) => group.some((record) => record.source.inboundCode === candidate.inboundCode)) const inputs = selected.map((candidate) => { @@ -480,6 +549,24 @@ async function runWorkflow(context, stopBeforeOverseasPda) { for (const record of group) record.outbound = { task: completed, selected, detail: outbound, completed: true } } try { + if (mixedContainer) { + const dropship = await prepare('MIXED-DROPSHIP', 2) + await verify(dropship, 2) + context.context.waitSplitRoute = { originWarehouseName: dropship.order.receivingWarehouseName, + channelCode: dropship.order.channelCode } + const fba = await prepare('MIXED-FBA', 1, {}, 'FBA') + const overseas = await prepare('MIXED-OVERSEAS', 1, {}, 'OVERSEAS') + const group = [dropship, fba, overseas] + check('混柜三票属于同一起运仓和渠道组', null, group.every(r => + String(r.summary.warehouseId) === String(dropship.summary.warehouseId) + && channelGroup(r.summary.channelCode) === channelGroup(dropship.summary.channelCode))) + await allocate(group) + await progressShipping(group) + await convertOverseas(group) + await receiveOverseas(group) + context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED' + return + } if (stopBeforeOverseasPda) { const record = await prepare('BROWSER-MULTI', 2) await verify(record, 2) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs new file mode 100644 index 000000000..1a159b375 --- /dev/null +++ b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs @@ -0,0 +1,84 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs' + +function fixtures() { + const record = (type, fulfillmentType = 'STANDARD') => ({ label: type, + order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type }, + expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`), + overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } }) + const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP') + d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] }, + { palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] } + return [d, record('FBA'), record('OVERSEAS')] +} + +test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => { + assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving']) + const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8') + const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'") + const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start)) + for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'", + 'allocate(group)', 'progressShipping(group)', 'convertOverseas(group)', 'receiveOverseas(group)', + 'MIXED_CONTAINER_INBOUND_COMPLETED']) assert.ok(branch.includes(entry), entry) + assert.doesNotMatch(branch, /outboundOverseas|warehouse-transfers/) + assert.match(source, /混柜完成后按入仓码查询库存/) + assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/) + assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/) +}) + +test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => { + const steps = mixedReceivingSchedule(fixtures()) + assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet']) + assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]]) + assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12) + assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2))) +}) + +function driver({ earlyComplete = false, wrongModeAccepted = false, drift = false } = {}) { + const group = fixtures() + const expected = new Map(group.map(r => [r.source.inboundCode, 0])) + const schedule = mixedReceivingSchedule(group) + const seen = new Map() + let index = 0 + let complete = false + const snapshot = () => ({ status: complete ? 'COMPLETED' : 'SIGNED', totalPackages: 12, + receivedPackages: [...expected.values()].reduce((a, b) => a + b, 0), + pendingPackages: 12 - [...expected.values()].reduce((a, b) => a + b, 0), + inbounds: [...expected].map(([inboundCode, receivedPackages]) => ({ inboundCode, totalPackages: 4, + receivedPackages, pendingPackages: 4 - receivedPackages })) }) + return { group, marker: 'MIXED-TEST', check: (name, _response, passed) => assert.ok(passed, name), + request: async (_method, path) => ({ body: { code: (path.endsWith('/complete') ? earlyComplete : wrongModeAccepted) ? '200' : 'A0409' } }), + call: async (_name, method, path, body) => { + if (method === 'GET') return snapshot() + if (seen.has(body.idempotencyKey)) return structuredClone(seen.get(body.idempotencyKey)) + if (path.endsWith('/complete')) { + complete = true + const result = snapshot(); seen.set(body.idempotencyKey, result); return result + } + const step = schedule[index++] + assert.ok(path.endsWith(`/receipts/${step.path}`)) + const inbound = drift ? 'FBA' : step.record.source.inboundCode + expected.set(inbound, expected.get(inbound) + step.count) + const result = { acceptedPackages: step.count } + seen.set(body.idempotencyKey, result) + return result + } } +} + +test('mixed receiving executes the whole schedule and completes only after all three rows balance', async () => { + const result = await receiveMixedContainer(driver()) + assert.equal(result.status, 'COMPLETED') + assert.equal(result.receivedPackages, 12) +}) +test('mixed receiving fails on early-completion acceptance', async () => { + await assert.rejects(receiveMixedContainer(driver({ earlyComplete: true }))) +}) +test('mixed receiving fails if overseas or dropship can bypass pallet/carton receipt rules', async () => { + await assert.rejects(receiveMixedContainer(driver({ wrongModeAccepted: true }))) +}) +test('mixed receiving detects correct aggregate count allocated to the wrong inbound', async () => { + await assert.rejects(receiveMixedContainer(driver({ drift: true }))) +}) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving.mjs b/scripts/admin-test-env-modules/mixed-container-receiving.mjs new file mode 100644 index 000000000..e260ed2cb --- /dev/null +++ b/scripts/admin-test-env-modules/mixed-container-receiving.mjs @@ -0,0 +1,69 @@ +import { receivingProgressMatches } from './dropship-outbound-edge-cases.mjs' + +export async function run(context) { + const { runMixedContainer } = await import('./dropship-pallet-matrix.mjs') + return runMixedContainer(context) +} + +export function mixedReceivingSchedule(group) { + const fba = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'FBA') + const overseas = group.filter(r => r.order.fulfillmentType === 'STANDARD' && r.order.destinationType === 'OVERSEAS') + const dropship = group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP') + if (group.length !== 3 || fba.length !== 1 || overseas.length !== 1 || dropship.length !== 1 + || dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票') + const [f, o, d] = [fba[0], overseas[0], dropship[0]] + const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo + const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo + if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数') + const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c => + String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } }) + return [ + { record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } }, + palletStep(d.task.pallets[0]), + ...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })), + { record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1, + body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } }, + palletStep(d.task.pallets[1]), + ] +} + +export async function receiveMixedContainer({ group, call, check, request, marker }) { + const base = '/api/pda/v1/last-leg/receiving-containers/current' + const steps = mixedReceivingSchedule(group) + const expected = group.map(r => ({ inboundCode: r.source.inboundCode, total: r.expectedCartonMarks.length, received: 0 })) + for (const record of group.filter(r => r.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' || r.order.destinationType === 'OVERSEAS')) { + const before = await call('混柜错误收货模式前回查', 'GET', base) + const invalid = await request('POST', `${base}/receipts/bulk`, { + inboundCode: record.source.inboundCode, palletCode: record.overseas.plan.pallets[0].palletNo, + packageCount: 1, idempotencyKey: `${marker}-forbid-bulk-${record.label}`, + }) + check('普通海外仓和一件代发均不能绕过各自规则使用FBA批量收货', invalid, invalid.body?.code === 'A0409') + const after = await call('混柜错误收货模式拒绝后回查', 'GET', base) + check('混柜错误收货模式拒绝后零漂移', null, JSON.stringify(before) === JSON.stringify(after)) + } + for (const [index, step] of steps.entries()) { + const before = await call('混柜交替收货前进度', 'GET', base) + check('混柜每步前逐票和柜级进度准确', null, receivingProgressMatches(before, expected)) + const premature = await request('POST', `${base}/complete`, + { allowDifference: false, idempotencyKey: `${marker}-early-${index}` }) + check('混柜任一票未齐不得正常完成', premature, premature.body?.code === 'A0409') + const afterRejected = await call('混柜提前完成拒绝后回查', 'GET', base) + check('混柜提前完成拒绝后零漂移', null, JSON.stringify(afterRejected) === JSON.stringify(before)) + const path = `${base}/receipts/${step.path}` + const body = { ...step.body, idempotencyKey: `${marker}-mixed-receive-${index}` } + const received = await call(`${step.record.label}交替收货`, 'POST', path, body) + check('混柜本次只收当前操作对应箱数', null, Number(received.acceptedPackages) === step.count) + const replay = await call('混柜收货同键重放', 'POST', path, body) + check('混柜同键重放结果稳定', null, JSON.stringify(replay) === JSON.stringify(received)) + expected.find(row => row.inboundCode === step.record.source.inboundCode).received += step.count + const after = await call('混柜每步后逐票回查', 'GET', base) + check('混柜交替收货不串票不重计', null, receivingProgressMatches(after, expected)) + } + const body = { allowDifference: false, idempotencyKey: `${marker}-mixed-complete` } + const completed = await call('混柜全部核平后正常完成入库', 'POST', `${base}/complete`, body) + check('混柜三票全收且普通完成', null, + completed.status === 'COMPLETED' && receivingProgressMatches(completed, expected)) + const replay = await call('混柜入库完成重放', 'POST', `${base}/complete`, body) + check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed)) + return completed +} 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 c39c9eb87..b4a04ba53 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 @@ -7,12 +7,12 @@ import { prepareTerminalDrayage, 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 () => { +test('three 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'] + const actions = ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'] + const paths = ['fleet', 'pickup-appointment', 'empty-return'] let state = { containerBookingId, containerNo, version: 0, allowedActions: ['SELECT_FLEET'] } const history = [] const commands = new Map() @@ -59,20 +59,19 @@ test('four real command payloads mutate contract fields and preserve remarks and 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: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] }) - if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } }) + if (index === 2) 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 prepareTerminalDrayage(context, producer) - state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] } + state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', actualPickupAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] } await run(context) - assert.equal(commands.size, 4) - assert.equal(replays, 4) - assert.equal(conflicts, 4) - assert.equal(state.version, 4) + assert.equal(commands.size, 3) + assert.equal(replays, 3) + assert.equal(conflicts, 3) + assert.equal(state.version, 3) assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED') }) @@ -90,7 +89,7 @@ test('terminal drayage prepares pickup before unloading and finalizes return bef test('module uses only public business endpoints and reuses public trucking master data', () => { for (const path of [ - '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history', + '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'empty-return'", '/history', 'category=TRUCKING', ]) assert.ok(source.includes(path), `missing ${path}`) assert.doesNotMatch(source, /\/internal\//) @@ -98,11 +97,11 @@ test('module uses only public business endpoints and reuses public trucking mast assert.match(source, /脚本不临时创建主数据/) }) -test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => { +test('all three commands cover idempotent replay hash conflict and lifecycle semantics', () => { for (const token of [ - '选择车队', '预约提柜', '确认实际提柜', '确认实际还柜', - '同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED', - 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', + '选择车队', '预约提柜', '确认实际还柜', + '同键重放', '同键异载荷返回A0409', 'UNLOADED', 'EMPTY_RETURNED', + 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', ]) assert.ok(source.includes(token), `missing ${token}`) assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/) assert.match(source, /terminal-\$\{path\}.*replace\(\/\[\^A-Za-z0-9:_-\]\//) diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs index 656c3a5f2..36cbf266e 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -39,7 +39,7 @@ 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 = ['pickup', 'empty-return'].includes(path) ? { ...body, remark: `${body.remark}-DIFFERENT` } + const changed = path === 'empty-return' ? { ...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 }) @@ -103,15 +103,7 @@ export async function prepareTerminalDrayage(context, producer) { trace(scheduled.response), OPTIONS) if (!success(scheduled.response)) return null - 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, remark: context.marker, - }) - context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response, - picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt, - trace(picked.response), OPTIONS) - if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null - resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) } + resource.prepared = { ...target, appointmentAt, scheduledVersion: Number(scheduled.data.version) } resource.containerBookingId = target.containerBookingId resource.containerNo = target.containerNo return resource.prepared @@ -149,15 +141,15 @@ export async function run(context) { context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response, id(initial.data?.containerBookingId) === target.containerBookingId && id(initial.data?.containerNo) === id(target.completed.containerNo) - && Number(initial.data?.version) === Number(target.pickedVersion) + && Number(initial.data?.version) === Number(target.scheduledVersion) && initial.data?.status === 'UNLOADED' - && initial.data?.actualPickupAt === target.pickupAt + && initial.data?.actualPickupAt === initial.data?.unloadingCompletedAt && initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), `pollRequests=${pollRequests}, status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) if (!success(initial.response)) return const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`) - const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) + const returnBase = Number.isNaN(unloadingAt) ? Date.now() : unloadingAt const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) const returned = await command(context, target, '确认实际还柜', 'empty-return', { expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker, @@ -176,13 +168,12 @@ export async function run(context) { && list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS) const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`) const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action)) - context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response, - ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) + context.assert(MODULE, '历史完整记录三个业务动作和连续版本', history.response, + ['SELECT_FLEET', 'SCHEDULE_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)), + context.assert(MODULE, '还柜备注写入不可变历史', history.response, + history.data?.some((item) => item.action === 'RECORD_EMPTY_RETURN' && item.reason === context.marker), trace(history.response), OPTIONS) context.createdResources.phase5TerminalDrayage = { ...context.createdResources.phase5TerminalDrayage, diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 9da3839df..09d788868 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,6 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。使用公共托盘真实编码并复用 Print `INBOUND_MARK_V1`,模板仍只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由;`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,未配置时使用服务发现 | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java new file mode 100644 index 000000000..4d8179fbb --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/PrintPdfClient.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.client; + +import java.math.BigDecimal; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** Reuses the Print service's existing label renderer; no local PDF template. */ +@FeignClient(name = "print-service", contextId = "overseasWarehousePrintPdfClient", + url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") +public interface PrintPdfClient { + @PostMapping(value = "/inbound-marks", consumes = "application/json") + ResponseEntity generateInboundMarks( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @RequestBody Request request); + + record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List