From b31405af430fe8773add3d9dfad6c421df22336b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 24 Sep 2026 17:07:21 +0800 Subject: [PATCH 01/31] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 + .../PlatformMojiaSkuSyncController.java | 7 ++-- .../baiyue/firstleg/dto/MojiaSkuSyncDtos.java | 5 +-- .../entity/MojiaMeasurementReceiptEntity.java | 1 + .../repository/MojiaSkuSyncRepository.java | 16 +++++++--- .../MojiaMeasurementOpenApiService.java | 32 ++++++++++++++++++- .../firstleg/service/MojiaSkuSyncService.java | 6 ++-- .../resources/db/phase2-inbound-schema.sql | 1 + .../db/recent-schema-incremental-20260923.sql | 13 ++++++++ 9 files changed, 70 insertions(+), 12 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a0a4f7989..c4c522c6d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3316,6 +3316,7 @@ CREATE TABLE fl_mojia_measurement_receipt ( tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID', box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU', + inbound_code varchar(64) DEFAULT NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空', measurement_kind varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java index 44675775d..3eae3921d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java @@ -35,15 +35,16 @@ public class PlatformMojiaSkuSyncController { @GetMapping @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") - @Operation(summary = "查询墨家一件代发SKU测量记录") + @Operation(summary = "查询墨家机器全部上传记录") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String skuCode, + @RequestParam(required = false) String code, + @RequestParam(required = false) String measurementKind, @RequestParam(required = false) String syncStatus, @RequestParam(defaultValue = "1") @Min(1) long page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.page(new MojiaSkuSyncDtos.Query(skuCode, syncStatus, page, size))); + return ApiResponse.success(service.page(new MojiaSkuSyncDtos.Query(code, measurementKind, syncStatus, page, size))); } @GetMapping("/config") @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java index 131000cab..88b6a8763 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaSkuSyncDtos.java @@ -10,7 +10,7 @@ import java.time.LocalDateTime; public final class MojiaSkuSyncDtos { private MojiaSkuSyncDtos() {} - public record Query(String skuCode, String syncStatus, long page, long size) {} + public record Query(String code, String measurementKind, String syncStatus, long page, long size) {} public record Config(boolean autoSyncEnabled) {} @@ -25,7 +25,8 @@ public final class MojiaSkuSyncDtos { public record RetryRequest(@NotNull Integer revision) {} public record Record( - Long id, String skuCode, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + Long id, String measurementKind, String boxCode, String inboundCode, String skuCode, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal weightKg, Long imageFileId, LocalDateTime measuredAt, LocalDateTime receivedAt, String syncStatus, String syncFailureCode, String syncFailureReason, String lingxingSkuId, Integer revision, LocalDateTime updatedAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java index 9df013eef..78e45cf5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java @@ -19,6 +19,7 @@ public class MojiaMeasurementReceiptEntity { private Long tenantId; private Long subjectId; private String boxCode; + private String inboundCode; /** BOX_LABEL 表示现有箱唛链路;SKU 表示一件代发新品测量同步收件。 */ private String measurementKind; private String scannedShipmentId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java index 7482ac209..8454e5f0a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java @@ -14,14 +14,22 @@ import org.springframework.util.StringUtils; public class MojiaSkuSyncRepository { @Resource private MojiaMeasurementReceiptMapper mapper; - public PageResult page(String skuCode, String syncStatus, + /** 保留仓储层旧测试/内部调用的四参数入口,统一查询改为全部上传记录。 */ + public PageResult page(String code, String syncStatus, long page, long size) { - String normalizedSkuCode = StringUtils.hasText(skuCode) ? skuCode.trim() : null; + return page(code, null, syncStatus, page, size); + } + + public PageResult page(String code, String measurementKind, String syncStatus, + long page, long size) { + String normalizedCode = StringUtils.hasText(code) ? code.trim() : null; return PageResult.from(mapper.selectPage(new Page<>(page, size), new LambdaQueryWrapper() - .eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU") + .eq(StringUtils.hasText(measurementKind), MojiaMeasurementReceiptEntity::getMeasurementKind, measurementKind) .eq(StringUtils.hasText(syncStatus), MojiaMeasurementReceiptEntity::getSyncStatus, syncStatus) - .like(normalizedSkuCode != null, MojiaMeasurementReceiptEntity::getBoxCode, normalizedSkuCode) + .and(normalizedCode != null, wrapper -> wrapper + .like(MojiaMeasurementReceiptEntity::getBoxCode, normalizedCode) + .or().like(MojiaMeasurementReceiptEntity::getInboundCode, normalizedCode)) .orderByDesc(MojiaMeasurementReceiptEntity::getMeasuredAt) .orderByDesc(MojiaMeasurementReceiptEntity::getId))); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java index 1de4e3153..9956d34c2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java @@ -111,7 +111,8 @@ public class MojiaMeasurementOpenApiService { throw exception; } if (platformBinding.warehouseId() != machineWarehouseId) { - throw new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库"); + return saveUnmatched(app, request, image, + new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库")); } CartonMeasurementResult result; try { @@ -132,6 +133,7 @@ public class MojiaMeasurementOpenApiService { correlation("requestId"), correlation("traceId"), safe(request.boxCode()), safe(request.machineNo()), request.length(), request.width(), request.height(), image.getSize(), result.measurementId(), result.itemId(), result.created()); + saveMatchedReceipt(app, request, result, platformBinding.binding().inboundCode()); return new UploadResponse(true, true, null, result.measurementId(), result.itemId(), result.boxCode(), result.imageFileId(), result.created()); } catch (RuntimeException exception) { @@ -146,6 +148,33 @@ public class MojiaMeasurementOpenApiService { } } + /** 每次箱码上传都保留一条设备收件流水,正式测量成功后补上权威入仓码。 */ + private void saveMatchedReceipt(AuthenticatedMeasurementApp app, UploadRequest request, + CartonMeasurementResult result, String inboundCode) { + MojiaMeasurementReceiptEntity receipt = new MojiaMeasurementReceiptEntity(); + receipt.setId(IdWorker.getId()); + receipt.setTenantId(app.tenantId()); + receipt.setSubjectId(app.subjectId()); + receipt.setBoxCode(request.boxCode().trim()); + receipt.setInboundCode(inboundCode); + receipt.setMeasurementKind("BOX_LABEL"); + receipt.setScannedShipmentId(optionalTrim(request.shipmentId())); + receipt.setLengthCm(request.length()); + receipt.setWidthCm(request.width()); + receipt.setHeightCm(request.height()); + receipt.setWeightKg(request.weightKg()); + receipt.setMachineNo(request.machineNo().trim()); + receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt()); + receipt.setImageFileId(result.imageFileId()); + receipt.setStatus("MATCHED"); + receipt.setRevision(1); + receipt.setRequestId(correlation("requestId")); + receipt.setTraceId(correlation("traceId")); + receipt.setCreatedAt(LocalDateTime.now()); + receipt.setUpdatedAt(LocalDateTime.now()); + receipts.insert(receipt); + } + private UploadResponse saveSkuPending(AuthenticatedMeasurementApp app, UploadRequest request, MultipartFile image) { long receiptId = IdWorker.getId(); @@ -203,6 +232,7 @@ public class MojiaMeasurementOpenApiService { receipt.setTenantId(app.tenantId()); receipt.setSubjectId(app.subjectId()); receipt.setBoxCode(request.boxCode().trim()); + receipt.setMeasurementKind("BOX_LABEL"); receipt.setScannedShipmentId(optionalTrim(request.shipmentId())); receipt.setLengthCm(request.length()); receipt.setWidthCm(request.width()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java index ea51e2c90..47b2c9d97 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java @@ -42,7 +42,7 @@ public class MojiaSkuSyncService { public PageResult page(MojiaSkuSyncDtos.Query query) { PageResult source = repository.page( - query.skuCode(), query.syncStatus(), query.page(), query.size()); + query.code(), query.measurementKind(), query.syncStatus(), query.page(), query.size()); return new PageResult<>(source.records().stream().map(MojiaSkuSyncService::view).toList(), source.page(), source.size(), source.total(), source.pages()); } @@ -104,7 +104,9 @@ public class MojiaSkuSyncService { } private static MojiaSkuSyncDtos.Record view(MojiaMeasurementReceiptEntity value) { - return new MojiaSkuSyncDtos.Record(value.getId(), value.getBoxCode(), value.getLengthCm(), + return new MojiaSkuSyncDtos.Record(value.getId(), value.getMeasurementKind(), value.getBoxCode(), + value.getInboundCode(), "SKU".equals(value.getMeasurementKind()) ? value.getBoxCode() : null, + value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getWeightKg(), value.getImageFileId(), value.getMeasuredAt(), value.getCreatedAt(), value.getSyncStatus(), value.getSyncFailureCode(), value.getSyncFailureReason(), value.getLingxingSkuId(), value.getRevision(), value.getUpdatedAt()); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index bd1f6b222..2afc054aa 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -329,6 +329,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID', box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码', + inbound_code VARCHAR(64) NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空', measurement_kind VARCHAR(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm', diff --git a/scripts/db/recent-schema-incremental-20260923.sql b/scripts/db/recent-schema-incremental-20260923.sql index 95fb242b8..3763bbe85 100644 --- a/scripts/db/recent-schema-incremental-20260923.sql +++ b/scripts/db/recent-schema-incremental-20260923.sql @@ -108,6 +108,19 @@ DEALLOCATE PREPARE recent_schema_stmt; -- Firstleg:墨家测量收件箱扩展为箱唛/SKU 双分流 -- ============================================================================ +SET @ddl = IF( + NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' + AND column_name = 'inbound_code' + ), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN inbound_code VARCHAR(64) NULL COMMENT ''箱码命中后的权威入仓码;SKU记录为空'' AFTER box_code', + 'SELECT 1' +); +PREPARE recent_schema_stmt FROM @ddl; +EXECUTE recent_schema_stmt; +DEALLOCATE PREPARE recent_schema_stmt; + SET @ddl = IF( NOT EXISTS ( SELECT 1 FROM information_schema.columns From 4e9663e852adf9f3b08c1922829abfbbbd6b7df6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 24 Sep 2026 17:24:01 +0800 Subject: [PATCH 02/31] test: cover persisted mismatched Mojia uploads --- .../MojiaMeasurementOpenApiServiceTest.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java index 73c3dd64a..50579951d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -59,6 +59,7 @@ class MojiaMeasurementOpenApiServiceTest { var service = new MojiaMeasurementOpenApiService(authenticator, cartons, new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), images, receipts, labels); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp( @@ -170,6 +171,7 @@ class MojiaMeasurementOpenApiServiceTest { var service = new MojiaMeasurementOpenApiService(authenticator, cartons, new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), images, receipts, labels); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp( @@ -178,10 +180,18 @@ class MojiaMeasurementOpenApiServiceTest { .thenReturn(new InboundCartonLabelService.PlatformBinding(10002L, 30002L, new CartonLabelBindingResolver.Binding( 70002L, 80002L, "PCHK2608140001", 1, 5, "60x40x30cm"))); + when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(0L), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 9L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null))); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.receive(headers, json())) - .isInstanceOf(BizException.class) - .hasMessageContaining("不属于当前设备绑定仓库"); + UploadResponse result = service.receive(headers, json()); + assertThat(result.accepted()).isTrue(); + assertThat(result.matched()).isFalse(); + ArgumentCaptor receipt = + ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); + verify(receipts).insert(receipt.capture()); + assertThat(receipt.getValue().getMeasurementKind()).isEqualTo("BOX_LABEL"); + assertThat(receipt.getValue().getMatchFailureReason()).contains("不属于当前设备绑定仓库"); verify(cartons, org.mockito.Mockito.never()).upsert(any(Long.TYPE), any(Long.TYPE), any()); } From bdf14dffb57547d8c5d002b7b2585a6f71e1ba73 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 25 Sep 2026 21:40:59 +0800 Subject: [PATCH 03/31] chore: rename Mojia permissions to weighing machine --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 +++--- iam/src/main/resources/db/mojia-sku-sync-permissions.sql | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c4c522c6d..6fc3f5016 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6122,9 +6122,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','一件代发SKU同步','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑一件代发SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送一件代发SKU',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','称重机器','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送SKU测量',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); diff --git a/iam/src/main/resources/db/mojia-sku-sync-permissions.sql b/iam/src/main/resources/db/mojia-sku-sync-permissions.sql index 25b36b996..5b69b0a5b 100644 --- a/iam/src/main/resources/db/mojia-sku-sync-permissions.sql +++ b/iam/src/main/resources/db/mojia-sku-sync-permissions.sql @@ -22,9 +22,9 @@ BEGIN (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES - (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0), - (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0), - (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 1, 0) + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '称重机器', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0), + (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0), + (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送SKU测量', NULL, 'POST', 20, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0; From 8940c4bcffad84c04a8e0feec3926fa53429a962 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 25 Sep 2026 23:00:00 +0800 Subject: [PATCH 04/31] test: align customs acceptance with receipt confirmation --- ...ainer-customs-acceptance-contract.test.mjs | 2 +- .../container-customs-acceptance-matrix.mjs | 3 +- .../phase3-container-customs-fixture.mjs | 4 +++ .../phase3-container-customs.mjs | 34 ++++++++++++++++--- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index ae7b4af76..7a7dc1541 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -69,7 +69,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream', '缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移', "['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移', - '最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成', + '最后一份完成文件前禁止整柜完成', '客户确认整份回执后纯退税柜完成', '客户确认整份回执并同键重放', '混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票', '买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED', '自动离仓只写一次SYSTEM历史', '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务', diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs index 1c3f1c363..7d49e220d 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs @@ -15,6 +15,7 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([ 'merchantReplaceDocument', 'merchantUpdateScope', 'merchantDeleteDocument', + 'merchantConfirmReceipt', 'platformStart', 'platformUploadCompletion', 'platformComplete', @@ -28,7 +29,7 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([ { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' }, { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' }, - { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份完成文件后才允许整柜完成' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份平台回执上传后由客户确认整份回执,才允许整柜完成' }, { priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' }, { priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' }, diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index c61286914..3df5cc060 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -81,6 +81,10 @@ function routes(context, merchantToken) { merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`, undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), + merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`, + { expectedVersion: Number(request.expectedVersion), remark: request.remark || '' }, + customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index dfe66a3f5..ecfed8107 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -201,11 +201,22 @@ async function runRefund(context, routes, fixture) { } } const completedReady = await platformDetail(context, routes, fixture) - const completed = await routes.platformComplete(context, fixture, { - expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker, - }, key(context, 'refund-complete')) - context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed, - success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) + const confirmed = [] + for (const customer of customers) { + const document = documents.find((item) => (item.inboundIds || []).some((inboundId) => + customer.inboundIds.map(id).includes(id(inboundId)))) + if (!document) continue + const version = Number(document.source?.version || completedReady.body?.data?.documents + ?.find((item) => id(item.documentId) === id(document.documentId))?.source?.version || 0) + const response = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker, + }, key(context, `refund-receipt-confirm-${customer.customerId}`)) + confirmed.push(response) + } + const completed = await platformDetail(context, routes, fixture) + context.assert(MODULE, '客户确认整份回执后纯退税柜完成', completed, + success(completed) && completed.body?.data?.status === 'COMPLETED' + && confirmed.every(success), evidence(completed), OPTIONS) const firstDocument = documents[0] if (firstDocument) { @@ -335,6 +346,19 @@ async function runMixedConcurrency(context, routes, fixture) { context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent, a0409(completionDifferent), evidence(completionDifferent), OPTIONS) + const receiptVersion = Number(completion.body?.data?.source?.version || completion.body?.data?.version || 0) + const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + const receiptReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { + documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, + }, key(context, 'mixed-receipt-confirm')) + context.assert(MODULE, '客户确认整份回执并同键重放', receiptReplay || receiptConfirm, + success(receiptConfirm) && success(receiptReplay) + && receiptConfirm.body?.data?.status === 'COMPLETED' + && receiptReplay.body?.data?.status === 'COMPLETED', + [receiptConfirm, receiptReplay].map((response) => evidence(response)).join('; '), OPTIONS) + let completed = await platformDetail(context, routes, fixture) context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) From 5a895bf214b231eb2e2f63b11eb863a5239c98d4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 12:08:29 +0800 Subject: [PATCH 05/31] test(firstleg): verify manual rollover schedule edit --- firstleg/AGENTS.md | 2 +- .../admin-test-env-modules/phase3-port-schedules.mjs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 8d8cf8596..8b67df4c5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -77,7 +77,7 @@ - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 -- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 +- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。 - 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index ff109fa1f..bd1171bb1 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -172,6 +172,9 @@ export async function run(context) { const initialVersion = Number(created.portSchedule.schedule.version) const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true) expiredData.sourceRef = payload.data.sourceRef + // 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。 + expiredData.vesselName = `甩柜改配船-${suffix}` + expiredData.voyageNo = `R${suffix}` const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' }, { 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) }) @@ -180,6 +183,13 @@ export async function run(context) { updated?.portSchedule?.expired === true && Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1, `expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) + context.assert(MODULE, '甩柜改船只更新原柜船信息且不改变柜次订舱关联', updateResponse, + id(updated?.portSchedule?.schedule?.id) === scheduleId + && id(updated?.portSchedule?.schedule?.oceanBookingId) === ocean.id + && id(updated?.portSchedule?.schedule?.containerBookingId) === (knownAllocation || '') + && updated?.portSchedule?.schedule?.vesselName === expiredData.vesselName + && updated?.portSchedule?.schedule?.voyageNo === expiredData.voyageNo, + `schedule=${id(updated?.portSchedule?.schedule?.id)}, ocean=${id(updated?.portSchedule?.schedule?.oceanBookingId)}, container=${id(updated?.portSchedule?.schedule?.containerBookingId)}, vessel=${updated?.portSchedule?.schedule?.vesselName || 'missing'}, voyage=${updated?.portSchedule?.schedule?.voyageNo || 'missing'}`, OPTIONS) const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' }, From ff2386e5bf42cecf02db4e23bdbb77870f754764 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 13:28:18 +0800 Subject: [PATCH 06/31] feat(firstleg): split customs receipt and final document flow --- .../MerchantContainerCustomsController.java | 11 +- .../PlatformContainerCustomsController.java | 33 +++- .../container/dto/ContainerCustomsDtos.java | 4 +- .../mapper/ContainerCustomsMapper.java | 69 ++++++++ .../service/ContainerCustomsService.java | 82 ++++++++- .../db/phase3-container-customs-schema.sql | 28 ++- .../main/resources/openapi/customs-v1.yaml | 42 ++++- .../ContainerCustomsServiceTest.java | 18 +- scripts/README.md | 2 + .../container-customs-acceptance-matrix.mjs | 13 +- .../phase3-container-customs-fixture.mjs | 23 ++- .../phase3-container-customs.mjs | 162 ++++++++++-------- .../customs-two-file-incremental-20260926.sql | 28 +++ 13 files changed, 405 insertions(+), 110 deletions(-) create mode 100644 scripts/db/customs-two-file-incremental-20260926.sql diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java index 93f3035fa..2b448a216 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -84,13 +84,20 @@ public class MerchantContainerCustomsController { return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false)); } - @GetMapping("/documents/{documentId}/completion/download") + @GetMapping("/documents/{documentId}/receipt/download") @PreAuthorize("hasAuthority('merchant:order:view')") - public ResponseEntity downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user, + public ResponseEntity downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long documentId) { return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false)); } + @GetMapping("/documents/{documentId}/final/download") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity downloadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + return response(service.downloadFinal(FirstlegScopeGuard.merchant(user),documentId,false)); + } + @PostMapping("/documents/{documentId}/receipt/confirm") @PreAuthorize("hasAuthority('merchant:order:edit')") public ApiResponse confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java index 8ce46a403..f25e831be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java @@ -44,7 +44,18 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request)); } - @PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping("/{planId}/submit-to-broker") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER") + public ApiResponse submitToBroker(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.submitToBroker(planId,user.userId(),user.username(),key,request)); + } + + @PostMapping(value="/documents/{documentId}/receipt/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT") public ApiResponse uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user, @@ -57,6 +68,18 @@ public class PlatformContainerCustomsController { expectedVersion,file)); } + @PostMapping(value="/documents/{documentId}/final/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_FINAL_DOCUMENT_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT") + public ApiResponse uploadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @RequestParam @Min(0) int expectedVersion, + @RequestPart("file") MultipartFile file) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.uploadFinalDocument(documentId,user.userId(),user.username(),key,expectedVersion,file)); + } + @PostMapping("/{planId}/complete-all-buy") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER") @@ -84,6 +107,14 @@ public class PlatformContainerCustomsController { return response(service.downloadCompletion(0,documentId,true)); } + @GetMapping("/documents/{documentId}/final/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ResponseEntity downloadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return response(service.downloadFinal(0,documentId,true)); + } + private static ResponseEntity response(Download value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(value.contentType())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 509b9096e..1f388c41e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -19,8 +19,8 @@ public final class ContainerCustomsDtos { public record DocumentView(long documentId, long tenantId, FileView source, List inboundIds, List inboundCodes, - boolean frozen, FileView completion, String receiptStatus, - String receiptRejectReason) {} + boolean frozen, FileView receipt, String receiptStatus, + String receiptRejectReason, FileView finalDocument) {} public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 9d6cab745..5839894b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -20,6 +20,11 @@ public interface ContainerCustomsMapper { r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version, r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason """; + String FINAL_COLUMNS = """ + f.id id,f.source_document_id sourceDocumentId,f.file_id fileId,f.file_name fileName, + f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy, + f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version + """; @Select(""" SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, @@ -102,6 +107,18 @@ public interface ContainerCustomsMapper { int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_customs_container SET broker_submitted_at=CURRENT_TIMESTAMP(3), + broker_submitted_by=#{operatorId},broker_submitted_by_name=#{operatorName}, + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} AND status='DECLARING' AND broker_submitted_at IS NULL + """) + int submitToBroker(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}") + LocalDateTime brokerSubmittedAt(@Param("id") long id); + @Update(""" UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName}, completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 @@ -268,6 +285,22 @@ public interface ContainerCustomsMapper { @Param("status") String status,@Param("reason") String reason, @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + JOIN fl_customs_completion_document r ON r.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' + AND r.customer_confirm_status='CONFIRMED' + """) + int confirmedReceiptCount(@Param("containerId") long containerId); + + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' + AND (r.id IS NULL OR r.customer_confirm_status<>'CONFIRMED') + """) + int unconfirmedReceiptCount(@Param("containerId") long containerId); + @Select(""" SELECT COUNT(*) FROM fl_customs_customer_document d JOIN fl_customs_completion_document r ON r.source_document_id=d.id @@ -288,6 +321,40 @@ public interface ContainerCustomsMapper { @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId}") + FinalRow finalDocument(@Param("documentId") long documentId); + + @Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId} FOR UPDATE") + FinalRow lockFinalDocument(@Param("documentId") long documentId); + + @Insert(""" + INSERT INTO fl_customs_final_document + (id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name, + uploaded_at,version,created_at,updated_at) + VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName}, + CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertFinal(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId, + @Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType, + @Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_customs_final_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash}, + content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName}, + uploaded_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} + """) + int replaceFinal(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId, + @Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType, + @Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + LEFT JOIN fl_customs_final_document f ON f.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND f.id IS NULL + """) + int missingFinalCount(@Param("containerId") long containerId); + @Insert(""" INSERT INTO fl_customs_container_history (id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at) @@ -382,6 +449,8 @@ public interface ContainerCustomsMapper { record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version, String customerConfirmStatus,String customerRejectReason) {} + record FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, + Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {} record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status, Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {} record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 5a40f339f..a334eaaac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -33,6 +33,7 @@ public class ContainerCustomsService { public static final String DECLARING = "DECLARING"; public static final String COMPLETED = "COMPLETED"; private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE"; + private static final String RECEIPT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RECEIPT"; private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT"; private static final long MAX_FILE_BYTES = 50L * 1024 * 1024; @@ -172,6 +173,24 @@ public class ContainerCustomsService { return platformView(planId); } + @Transactional + public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) { + String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId; + if (!request.confirm()) throw bad("请确认已将客户确认结果发送给报关行"); + String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return platformView(planId); + ContainerRow container = mapper.lockContainer(planId); + if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); + if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认"); + if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行"); + if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent(); + history(container, "SUBMIT_CUSTOMS_TO_BROKER", container.status(), container.status(), request.remark(), operatorId, operatorName); + completeCommand(operatorId, action, key, container.id()); + return platformView(planId); + } + @Transactional public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key, int expectedVersion, MultipartFile file) { @@ -187,7 +206,7 @@ public class ContainerCustomsService { throw conflict("当前客户资料不在报关中冻结版本"); } CompletionRow completion = mapper.lockCompletion(documentId); - CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); + CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); long completionId; int version; if (completion == null) { @@ -227,12 +246,47 @@ public class ContainerCustomsService { if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED", confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试"); if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) { - completeContainer(container, operatorId, operatorName, "客户已确认全部报关行回执"); + history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName); } completeCommand(operatorId, action, key, documentId); return merchantViewForDocument(tenantId, source); } + @Transactional + public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key, + int expectedVersion, MultipartFile file) { + validateFile(file); + DocumentRow readable = requiredDocument(documentId); + String action = "UPLOAD_FINAL_CUSTOMS_DOCUMENT_" + documentId; + String requestHash = hash(action, expectedVersion, fileHash(file)); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(documentId), false); + ContainerRow container = mapper.lockContainerById(readable.customsContainerId()); + DocumentRow source = requiredDocumentLocked(documentId, null); + if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) { + throw conflict("当前客户资料不在报关中冻结版本"); + } + if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行"); + CompletionRow receipt = mapper.lockCompletion(documentId); + if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认"); + FinalRow existing = mapper.lockFinalDocument(documentId); + CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); + long finalId; + int version; + if (existing == null) { + if (expectedVersion != 0) throw concurrent(); + finalId = IdWorker.getId(); version = 1; + mapper.insertFinal(finalId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName); + } else { + finalId = existing.id(); version = existing.version() + 1; + if (mapper.replaceFinal(existing.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent(); + } + history(container, "UPLOAD_FINAL_CUSTOMS_DOCUMENT", container.status(), container.status(), "真实报关完税文件已上传", operatorId, operatorName); + if (mapper.missingFinalCount(container.id()) == 0) completeContainer(container, operatorId, operatorName, "全部真实报关完税文件已上传"); + completeCommand(operatorId, action, key, finalId); + return documentView(requiredDocument(documentId), false); + } + @Transactional public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) { String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId; @@ -261,12 +315,23 @@ public class ContainerCustomsService { public Download downloadCompletion(long tenantId, long documentId, boolean platform) { DocumentRow source = requiredDocument(documentId); ContainerRow container = mapper.containerById(source.customsContainerId()); - if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) { - throw notFound("报关完成文件不存在"); + if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) { + throw notFound("报关行回执不存在"); } CompletionRow completion = mapper.completion(documentId); - if (completion == null) throw notFound("报关完成文件不存在"); - return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName()); + if (completion == null) throw notFound("报关行回执不存在"); + return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName()); + } + + public Download downloadFinal(long tenantId, long documentId, boolean platform) { + DocumentRow source = requiredDocument(documentId); + ContainerRow container = mapper.containerById(source.customsContainerId()); + if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) { + throw notFound("报关完税文件不存在"); + } + FinalRow finalDocument = mapper.finalDocument(documentId); + if (finalDocument == null) throw notFound("报关完税文件不存在"); + return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), finalDocument.fileId(), finalDocument.fileName()); } private ContainerRow ensure(long planId) { @@ -367,12 +432,14 @@ public class ContainerCustomsService { private DocumentView documentView(DocumentRow source, boolean merchant) { List scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id()); CompletionRow completion = mapper.completion(source.id()); + FinalRow finalDocument = mapper.finalDocument(source.id()); ContainerRow container = mapper.containerById(source.customsContainerId()); FileView result = completion == null ? null : file(completion); return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(), scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result, completion == null ? null : completion.customerConfirmStatus(), - completion == null ? null : completion.customerRejectReason()); + completion == null ? null : completion.customerRejectReason(), + finalDocument == null ? null : file(finalDocument)); } private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) { @@ -444,6 +511,7 @@ public class ContainerCustomsService { private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } + private static FileView file(FinalRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); } private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); } private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } } diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql index 3d2982d05..92ea78873 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -12,6 +12,9 @@ CREATE TABLE IF NOT EXISTS fl_customs_container ( completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID', completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照', completed_at DATETIME(3) NULL COMMENT '报关完成时间', + broker_submitted_by BIGINT UNSIGNED NULL COMMENT '已将客户确认结果发送给报关行的操作人ID', + broker_submitted_by_name VARCHAR(64) NULL COMMENT '发送报关行操作人名称快照', + broker_submitted_at DATETIME(3) NULL COMMENT '发送报关行执行时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', @@ -93,10 +96,10 @@ CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound ( ) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID', + id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执文件ID', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', - file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照', + file_name VARCHAR(255) NOT NULL COMMENT '回执文件名称快照', file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要', content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型', size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)', @@ -114,7 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( PRIMARY KEY(id), UNIQUE KEY uk_customs_completion_source(source_document_id), CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) -) ENGINE=InnoDB COMMENT='报关完成文件当前版本'; +) ENGINE=InnoDB COMMENT='报关行回执当前版本'; CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID', @@ -133,6 +136,25 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) ) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; + CREATE TABLE IF NOT EXISTS fl_customs_container_history ( id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID', customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index f1c36d611..7175f95d3 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -21,10 +21,17 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {$ref: '#/components/requestBodies/ActionBody'} responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} - /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload: + /api/platform/v1/customs/loading-plans/{planId}/submit-to-broker: post: - summary: 上传或覆盖冻结客户资料的一对一完成文件 - description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。 + summary: 将客户确认回执发送给报关行执行 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload: + post: + summary: 上传或覆盖冻结客户资料的一对一报关行回执 + description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回。 x-permission: platform:firstleg:customs:manage parameters: - {$ref: '#/components/parameters/DocumentId'} @@ -38,9 +45,22 @@ paths: x-permission: platform:firstleg:customs:list parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} - /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download: + /api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download: get: - summary: 平台下载报关完成文件 + summary: 平台下载报关行回执 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload: + post: + summary: 上传真实报关完税文件 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}, {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}] + requestBody: {$ref: '#/components/requestBodies/FileBody'} + responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/final/download: + get: + summary: 平台下载真实报关完税文件 x-permission: platform:firstleg:customs:list parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} @@ -93,9 +113,15 @@ paths: x-permission: merchant:order:view parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} - /api/merchant/v1/customs/documents/{documentId}/completion/download: + /api/merchant/v1/customs/documents/{documentId}/receipt/download: get: - summary: 整柜完成后客户下载对应完成文件 + summary: 客户下载报关行回执 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/merchant/v1/customs/documents/{documentId}/final/download: + get: + summary: 整柜完成后客户下载真实报关完税文件 x-permission: merchant:order:view parameters: [{$ref: '#/components/parameters/DocumentId'}] responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} @@ -142,7 +168,7 @@ components: ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}} InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}} - DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 退税报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}}} + DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, receipt: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}, finalDocument: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 真实报关完税文件}}} ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}} ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}} ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 84c73bbbb..6df5f6cba 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -248,9 +248,9 @@ class ContainerCustomsServiceTest { when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1); when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null); - when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),any())) + when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),any())) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L, - "LOADING_LIST_CUSTOMS_RESULT","9","result.bin","b".repeat(64),"application/octet-stream",1L))); + "LOADING_LIST_CUSTOMS_RECEIPT","9","result.bin","b".repeat(64),"application/octet-stream",1L))); when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1); when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1); when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I"))); @@ -259,7 +259,7 @@ class ContainerCustomsServiceTest { var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); var result=service.uploadCompletion(1L,5L,"admin","k",0,file); assertThat(result).isNotNull(); - verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)); + verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)); verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); } @@ -282,7 +282,7 @@ class ContainerCustomsServiceTest { var commandState=new AtomicReference(); var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1}); var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2}); - var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RESULT","9","result.pdf", + var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RECEIPT","9","result.pdf", "b".repeat(64),"application/pdf",1L); when(mapper.document(1L)).thenReturn(source); when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString())) @@ -296,7 +296,7 @@ class ContainerCustomsServiceTest { when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get()); when(mapper.lockDocument(1L)).thenReturn(source); when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get()); - when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file))) + when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file))) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata)); when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)), eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{ @@ -319,12 +319,12 @@ class ContainerCustomsServiceTest { var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file); var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file); - assertThat(first.completion().fileId()).isEqualTo(99L); - assertThat(replay.completion().fileId()).isEqualTo(99L); + assertThat(first.receipt().fileId()).isEqualTo(99L); + assertThat(replay.receipt().fileId()).isEqualTo(99L); assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile)) .isInstanceOfSatisfying(BizException.class, error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)); - verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)); + verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)); verify(mapper,times(1)).lockContainerById(2L); verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(), eq(1L),eq(5L),eq("admin")); @@ -335,7 +335,7 @@ class ContainerCustomsServiceTest { var source=document(1L);when(mapper.document(1L)).thenReturn(source); when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3)); assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class) - .hasMessageContaining("完成文件不存在"); + .hasMessageContaining("报关行回执不存在"); verifyNoInteractions(files); } diff --git a/scripts/README.md b/scripts/README.md index 07dcb4b5b..7e44caf20 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,6 +4,8 @@ `db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 +`db/customs-two-file-incremental-20260926.sql`:报关两文件流程增量结构,保留报关行回执与新增真实报关完税文件,补充“已发送给报关行执行”的操作人和时间;仅新增结构,可重复执行,不修改业务数据。 + `local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。 `local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs index 7d49e220d..d44cff54d 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs @@ -17,10 +17,14 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([ 'merchantDeleteDocument', 'merchantConfirmReceipt', 'platformStart', - 'platformUploadCompletion', + 'platformUploadReceipt', + 'platformSubmitToBroker', + 'platformUploadFinal', 'platformComplete', - 'platformDownload', - 'merchantDownload', + 'platformDownloadReceipt', + 'platformDownloadFinal', + 'merchantDownloadReceipt', + 'merchantDownloadFinal', 'outboundState', 'satisfyOutboundPrerequisites', ]) @@ -29,7 +33,7 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([ { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' }, { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' }, - { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份平台回执上传后由客户确认整份回执,才允许整柜完成' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认、报关行执行与完税文件', expected: '开始冻结来源文件和关联;上传回执、客户确认、发送报关行执行,最后上传真实报关完税文件才允许整柜完成' }, { priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' }, { priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' }, @@ -91,6 +95,7 @@ export const READ_ONLY_DB_EVIDENCE = Object.freeze([ 'fl_customs_frozen_document_inbound', 'fl_customs_completion_document', 'fl_customs_completion_document_version', + 'fl_customs_final_document', 'fl_customs_command', 'fl_customs_container_history', 'fl_outbound_dispatch', diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 3df5cc060..8b3bda756 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -88,16 +88,27 @@ function routes(context, merchantToken) { platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), - platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart( - `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`, + platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart( + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`, {}, request, ctx.token, idempotencyKey), + platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), - platformDownload: (ctx, fixture, request) => binary('GET', - `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`), - merchantDownload: (ctx, fixture, customer, request) => binary('GET', - `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`, + platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart( + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`, + {}, request, ctx.token, idempotencyKey), + platformDownloadReceipt: (ctx, fixture, request) => binary('GET', + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`), + platformDownloadFinal: (ctx, fixture, request) => binary('GET', + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`), + merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`, + customer.customerToken || merchantToken), + merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`, customer.customerToken || merchantToken), buyDeclarationXlsx: async (ctx, request) => { const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', { diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index ecfed8107..1b8f1339b 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -183,56 +183,71 @@ async function runRefund(context, routes, fixture) { const documents = frozenSnapshot?.documents || [] for (const [index, document] of documents.entries()) { - const completion = await routes.platformUploadCompletion(context, fixture, { + const receipt = await routes.platformUploadReceipt(context, fixture, { documentId: id(document.documentId), expectedVersion: Number(document.source?.version), - fileName: `${context.marker}-completion-${index + 1}.done`, - contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]), - }, key(context, `completion-${index + 1}`)) - context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion, - success(completion) - && zeroDrift(completion.body?.data?.inboundIds, document.inboundIds), - evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) - if (index + 1 < documents.length) { - const beforeEarlyComplete = await platformDetail(context, routes, fixture) - const early = await routes.platformComplete(context, fixture, { - expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion', - }, key(context, `early-complete-${index + 1}`)) - await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early) - } + fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]), + }, key(context, `receipt-${index + 1}`)) + context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt, + success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds), + evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) } - const completedReady = await platformDetail(context, routes, fixture) + const beforeConfirm = await platformDetail(context, routes, fixture) + const earlyFinal = await routes.platformUploadFinal(context, fixture, { + documentId: id(documents[0]?.documentId), expectedVersion: 0, + fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]), + }, key(context, 'early-final')) + await assertZeroDrift(context, '回执未确认且未发送执行时禁止上传完税文件', routes, fixture, beforeConfirm, earlyFinal) const confirmed = [] for (const customer of customers) { const document = documents.find((item) => (item.inboundIds || []).some((inboundId) => customer.inboundIds.map(id).includes(id(inboundId)))) if (!document) continue - const version = Number(document.source?.version || completedReady.body?.data?.documents - ?.find((item) => id(item.documentId) === id(document.documentId))?.source?.version || 0) + const current = await platformDetail(context, routes, fixture) + const live = current.body?.data?.documents?.find((item) => id(item.documentId) === id(document.documentId)) + const version = Number(live?.receipt?.version || 0) const response = await routes.merchantConfirmReceipt(context, fixture, customer, { documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker, }, key(context, `refund-receipt-confirm-${customer.customerId}`)) confirmed.push(response) } + const afterConfirm = await platformDetail(context, routes, fixture) + context.assert(MODULE, '客户确认整份回执后仍处于报关中', afterConfirm, + success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success), + evidence(afterConfirm), OPTIONS) + const submitted = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(afterConfirm.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker')) + context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted, + success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + const submitReplay = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(submitted.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker')) + context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS) + for (const [index, document] of documents.entries()) { + const finalDocument = await routes.platformUploadFinal(context, fixture, { + documentId: id(document.documentId), expectedVersion: 0, + fileName: `${context.marker}-customs-tax-final-${index + 1}.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x46, index + 1]), + }, key(context, `final-${index + 1}`)) + context.assert(MODULE, `逐份上传真实报关完税文件${index + 1}`, finalDocument, + success(finalDocument) && zeroDrift(finalDocument.body?.data?.inboundIds, document.inboundIds), + evidence(finalDocument), OPTIONS) + } const completed = await platformDetail(context, routes, fixture) - context.assert(MODULE, '客户确认整份回执后纯退税柜完成', completed, - success(completed) && completed.body?.data?.status === 'COMPLETED' - && confirmed.every(success), evidence(completed), OPTIONS) - + context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed, + success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) const firstDocument = documents[0] if (firstDocument) { - const platformDownload = await routes.platformDownload(context, fixture, { - documentId: id(firstDocument.documentId), completion: true, - }) - context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload, - platformDownload.http === 200 && platformDownload.bytes?.length > 0, - `HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS) - const merchantDownload = await routes.merchantDownload(context, fixture, mutable, { - documentId: id(firstDocument.documentId), completion: true, - }) - context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload, - merchantDownload.http === 200 && merchantDownload.bytes?.length > 0, - `HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS) + const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Admin下载报关行回执', platformReceipt, + platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS) + const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal, + platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS) + const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal, + merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS) } + } async function runMixedConcurrency(context, routes, fixture) { @@ -320,44 +335,55 @@ async function runMixedConcurrency(context, routes, fixture) { }, key(context, 'locked-scope')) await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) - const completionRequest = { + const receiptRequest = { documentId: customer.documentId, expectedVersion: 0, - finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`, - contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]), + fileName: `${context.marker}-mixed-broker-receipt.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]), } - const completionKey = key(context, 'mixed-completion') - const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) - const completionReplay = success(completion) - ? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null - const completionConverged = success(completion) && success(completionReplay) - && id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId) - && id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId) - context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion, - completionConverged, - [completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) - if (!completionConverged) { - context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认', - '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入', - 'completion-idempotency', { deploymentBlocker: true }) - return - } - const completionDifferent = await routes.platformUploadCompletion(context, fixture, - { ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey) - context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent, - a0409(completionDifferent), evidence(completionDifferent), OPTIONS) - - const receiptVersion = Number(completion.body?.data?.source?.version || completion.body?.data?.version || 0) + const receiptKey = key(context, 'mixed-receipt-upload') + const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) + const receiptReplay = success(receipt) + ? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null + const receiptConverged = success(receipt) && success(receiptReplay) + && id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId) + && id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId) + context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged, + [receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + const receiptDifferent = await routes.platformUploadReceipt(context, fixture, + { ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey) + context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS) + const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0) const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, }, key(context, 'mixed-receipt-confirm')) - const receiptReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { + const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, }, key(context, 'mixed-receipt-confirm')) - context.assert(MODULE, '客户确认整份回执并同键重放', receiptReplay || receiptConfirm, - success(receiptConfirm) && success(receiptReplay) - && receiptConfirm.body?.data?.status === 'COMPLETED' - && receiptReplay.body?.data?.status === 'COMPLETED', - [receiptConfirm, receiptReplay].map((response) => evidence(response)).join('; '), OPTIONS) + context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm, + success(receiptConfirm) && success(receiptConfirmReplay) + && receiptConfirm.body?.data?.status === 'DECLARING' + && receiptConfirmReplay.body?.data?.status === 'DECLARING', + [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS) + const submitted = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(receiptConfirm.body?.data?.version), remark: context.marker }, key(context, 'mixed-submit-to-broker')) + context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted, + success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + const finalRequest = { + documentId: customer.documentId, expectedVersion: 0, + fileName: `${context.marker}-mixed-customs-tax-final.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x46, 0x49, 0x4e]), + } + const finalKey = key(context, 'mixed-final') + const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) + const finalReplay = success(finalDocument) + ? await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) : null + const finalConverged = success(finalDocument) && success(finalReplay) + && id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId) + context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged, + [finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + const finalDifferent = await routes.platformUploadFinal(context, fixture, + { ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey) + context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS) let completed = await platformDetail(context, routes, fixture) context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, @@ -396,10 +422,10 @@ async function runMixedConcurrency(context, routes, fixture) { evidence(queryDetail), OPTIONS) } - const completionDownload = await routes.merchantDownload(context, fixture, customer, { - documentId: customer.documentId, completion: true, + const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, { + documentId: customer.documentId, }) - context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload, + context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload, completionDownload.http === 200 && completionDownload.bytes?.length > 0, `HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS) } diff --git a/scripts/db/customs-two-file-incremental-20260926.sql b/scripts/db/customs-two-file-incremental-20260926.sql new file mode 100644 index 000000000..6d861f37f --- /dev/null +++ b/scripts/db/customs-two-file-incremental-20260926.sql @@ -0,0 +1,28 @@ +-- 报关两文件流程:回执与真实报关完税文件分离。 +-- 仅在已确认目标测试库后执行;所有 DDL 可重复执行,不创建数据库、不写业务数据。 +USE baiyue_firstleg; + +SET @ddl = IF(NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_at' +), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''发送报关行执行的操作人ID'' AFTER completed_at, ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行执行的操作人名称'' AFTER broker_submitted_by, ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1'); +PREPARE customs_container_broker_submit_stmt FROM @ddl; EXECUTE customs_container_broker_submit_stmt; DEALLOCATE PREPARE customs_container_broker_submit_stmt; + +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; From e46d541780032f51fc7026a28628dc49f0946784 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 13:57:53 +0800 Subject: [PATCH 07/31] fix(firstleg): map broker submission fields in customs container --- .../mapper/ContainerCustomsMapper.java | 3 ++- ...ntainer-customs-acceptance-contract.test.mjs | 8 ++++---- .../phase3-container-customs.mjs | 17 ++++++++++------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 5839894b1..7f13b0b06 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -441,7 +441,8 @@ public interface ContainerCustomsMapper { String cabinetNo,String containerNo) {} record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, - LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} + LocalDateTime completedAt,Long brokerSubmittedBy,String brokerSubmittedByName, + LocalDateTime brokerSubmittedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash, String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt, Integer frozenVersion,LocalDateTime frozenAt,Integer version) {} diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index 7a7dc1541..d5d3f09ed 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -49,8 +49,8 @@ test('mixed fixture producer is registered and uses only public HTTP composition test('coverage matrix freezes every requested product boundary', () => { const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n') - for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件', - '最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式', + for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '回执确认', '报关行执行', '完税文件', + '最后上传', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式', '幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) { assert.match(text, new RegExp(token)) } @@ -69,7 +69,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream', '缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移', "['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移', - '最后一份完成文件前禁止整柜完成', '客户确认整份回执后纯退税柜完成', '客户确认整份回执并同键重放', + '回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜', '混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票', '买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED', '自动离仓只写一次SYSTEM历史', '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务', @@ -87,7 +87,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => { for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound', - 'fl_customs_completion_document', 'fl_customs_command', 'fl_customs_container_history', + 'fl_customs_completion_document', 'fl_customs_final_document', 'fl_customs_command', 'fl_customs_container_history', 'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table) assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i) }) diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index 1b8f1339b..b467a8b65 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -58,12 +58,12 @@ async function runAllBuy(context, routes, fixture) { xlsx.http === 200 && xlsxMagic(xlsx.bytes), `HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS) - const startBody = { expectedVersion: Number(detail.body?.data?.version), remark: context.marker } + const startBody = { expectedVersion: Number(detail.body?.data?.version), confirm: true, remark: context.marker } const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start')) context.assert(MODULE, '全买单柜开始报关', started, success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS) - const completeBody = { expectedVersion: Number(started.body?.data?.version), remark: context.marker } + const completeBody = { expectedVersion: Number(started.body?.data?.version), confirm: true, remark: context.marker } const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete')) context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed, success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) @@ -153,7 +153,7 @@ async function runRefund(context, routes, fixture) { const ready = await platformDetail(context, routes, fixture) const started = await routes.platformStart(context, fixture, { - expectedVersion: Number(ready.body?.data?.version), remark: context.marker, + expectedVersion: Number(ready.body?.data?.version), confirm: true, remark: context.marker, }, key(context, 'refund-start')) context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started, success(started) && started.body?.data?.status === 'DECLARING' @@ -184,7 +184,7 @@ async function runRefund(context, routes, fixture) { const documents = frozenSnapshot?.documents || [] for (const [index, document] of documents.entries()) { const receipt = await routes.platformUploadReceipt(context, fixture, { - documentId: id(document.documentId), expectedVersion: Number(document.source?.version), + documentId: id(document.documentId), expectedVersion: 0, fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]), }, key(context, `receipt-${index + 1}`)) @@ -216,11 +216,11 @@ async function runRefund(context, routes, fixture) { success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success), evidence(afterConfirm), OPTIONS) const submitted = await routes.platformSubmitToBroker(context, fixture, - { expectedVersion: Number(afterConfirm.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker')) + { expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker')) context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted, success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) const submitReplay = await routes.platformSubmitToBroker(context, fixture, - { expectedVersion: Number(submitted.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker')) + { expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker')) context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS) for (const [index, document] of documents.entries()) { const finalDocument = await routes.platformUploadFinal(context, fixture, { @@ -365,7 +365,7 @@ async function runMixedConcurrency(context, routes, fixture) { && receiptConfirmReplay.body?.data?.status === 'DECLARING', [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS) const submitted = await routes.platformSubmitToBroker(context, fixture, - { expectedVersion: Number(receiptConfirm.body?.data?.version), remark: context.marker }, key(context, 'mixed-submit-to-broker')) + { expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker')) context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted, success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) const finalRequest = { @@ -381,6 +381,9 @@ async function runMixedConcurrency(context, routes, fixture) { && id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId) context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged, [finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + if (!finalConverged) { + blocker(context, '完税文件幂等收敛', '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入') + } const finalDifferent = await routes.platformUploadFinal(context, fixture, { ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey) context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS) From 12f87537d7088f6a17ef6fa457878c8dba82451a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 14:01:39 +0800 Subject: [PATCH 08/31] test(firstleg): preserve customs container row fixtures --- .../customs/container/mapper/ContainerCustomsMapper.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 7f13b0b06..31150ac16 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -442,7 +442,14 @@ public interface ContainerCustomsMapper { record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, LocalDateTime completedAt,Long brokerSubmittedBy,String brokerSubmittedByName, - LocalDateTime brokerSubmittedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} + LocalDateTime brokerSubmittedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) { + public ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, + String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, + LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) { + this(id, loadingPlanId, containerBookingId, status, startedBy, startedByName, startedAt, + completedBy, completedByName, completedAt, null, null, null, createdAt, updatedAt, version); + } + } record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash, String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt, Integer frozenVersion,LocalDateTime frozenAt,Integer version) {} From 7680c3e5c2f1d2176cd24af9f8c0a0e1a0b5acd6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 14:11:28 +0800 Subject: [PATCH 09/31] docs(firstleg): align customs baseline schema --- deploy/sql/头程数据库统一增量-MySQL8.sql | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6fc3f5016..5d85f593d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1756,6 +1756,9 @@ CREATE TABLE fl_customs_container ( completed_by bigint unsigned DEFAULT NULL COMMENT '完成报关操作人ID', completed_by_name varchar(64) DEFAULT NULL COMMENT '完成报关操作人名称快照', completed_at datetime(3) DEFAULT NULL COMMENT '报关完成时间', + broker_submitted_by bigint unsigned DEFAULT NULL COMMENT '发送报关行执行操作人ID', + broker_submitted_by_name varchar(64) DEFAULT NULL COMMENT '发送报关行执行操作人名称快照', + broker_submitted_at datetime(3) DEFAULT NULL COMMENT '发送报关行执行时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间', version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', @@ -1824,7 +1827,26 @@ CREATE TABLE fl_customs_completion_document ( PRIMARY KEY (id), UNIQUE KEY uk_customs_completion_source (source_document_id), CONSTRAINT fk_customs_completion_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件当前版本'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关行回执当前版本'; + +CREATE TABLE fl_customs_final_document ( + id bigint unsigned NOT NULL COMMENT '真实报关完税文件ID', + source_document_id bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完税文件逻辑根', + file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID', + file_name varchar(255) NOT NULL COMMENT '完税文件名称快照', + file_hash char(64) NOT NULL COMMENT '完税文件SHA-256摘要', + content_type varchar(128) DEFAULT NULL COMMENT '完税文件媒体类型', + size_bytes bigint unsigned NOT NULL COMMENT '完税文件大小(字节)', + uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID', + uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + version int unsigned NOT NULL DEFAULT '1' COMMENT '完税文件版本号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完税文件创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '完税文件更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_final_source (source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实报关完税文件当前版本'; CREATE TABLE fl_customs_completion_document_version ( id bigint unsigned NOT NULL COMMENT '完成文件版本记录ID', From c266cdee9ae2f095e5a73f7308b92e65516d3ea3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 14:22:29 +0800 Subject: [PATCH 10/31] feat(firstleg): expose broker submission timestamp --- .../firstleg/customs/container/dto/ContainerCustomsDtos.java | 2 +- .../customs/container/service/ContainerCustomsService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 1f388c41e..bc96143fa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -27,7 +27,7 @@ public final class ContainerCustomsDtos { String merchantStatus, int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount, List inbounds, List documents, - LocalDateTime startedAt, LocalDateTime completedAt) {} + LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime brokerSubmittedAt) {} public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds, @Min(0) int expectedVersion) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index a334eaaac..8e013fdbe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -426,7 +426,7 @@ public class ContainerCustomsService { String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(), plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund, - actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt()); + actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt(), container.brokerSubmittedAt()); } private DocumentView documentView(DocumentRow source, boolean merchant) { From 5d365f9040ea50bc019cace65385e0bcd60d2925 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 17:38:47 +0800 Subject: [PATCH 11/31] docs(firstleg): expose broker submission timestamp --- firstleg/src/main/resources/openapi/customs-v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 7175f95d3..1a4695d49 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -169,6 +169,6 @@ components: FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}} InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}} DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, receipt: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}, finalDocument: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 真实报关完税文件}}} - ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}} + ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, brokerSubmittedAt: {type: string, format: date-time, nullable: true, description: 已将客户确认后的回执发送给报关行执行的时间;不表示报关完成}, completedAt: {type: string, format: date-time, nullable: true}}} ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}} ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]} From 5927034b1c2d7e0d0688dc3d5fa2c1d72f7e649a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 17:45:41 +0800 Subject: [PATCH 12/31] fix(firstleg): freeze customs receipt after broker submission --- .../container/service/ContainerCustomsService.java | 3 +++ .../container/ContainerCustomsServiceTest.java | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 8e013fdbe..6a208ec24 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -205,6 +205,9 @@ public class ContainerCustomsService { if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) { throw conflict("当前客户资料不在报关中冻结版本"); } + if (mapper.brokerSubmittedAt(container.id()) != null) { + throw conflict("客户确认回执已发送给报关行,不可再修改回执"); + } CompletionRow completion = mapper.lockCompletion(documentId); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); long completionId; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 6df5f6cba..363ae3538 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -274,6 +274,18 @@ class ContainerCustomsServiceTest { verifyNoInteractions(files); } + @Test void receiptCannotBeReplacedAfterBrokerSubmission(){ + var source=document(1L);when(mapper.document(1L)).thenReturn(source); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1); + when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,4)); + when(mapper.lockDocument(1L)).thenReturn(source); + when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now()); + var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file)) + .isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执"); + verifyNoInteractions(files); + } + @Test void receiptReplaySucceedsAndDoesNotCompleteContainer(){ var source=document(1L); var declaring=container(ContainerCustomsService.DECLARING,3); From 5f7df21f9c4551e7b7bb210c964130d1aac2b1de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 17:54:19 +0800 Subject: [PATCH 13/31] fix(firstleg): include receipt version in confirmation idempotency --- .../customs/container/service/ContainerCustomsService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 6a208ec24..e2fd3f213 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -232,7 +232,7 @@ public class ContainerCustomsService { public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName, String key, int expectedVersion, boolean confirm, String reason) { String action = (confirm ? "CONFIRM_CUSTOMS_RECEIPT_" : "REJECT_CUSTOMS_RECEIPT_") + documentId; - String requestHash = hash(action, confirm, reason); + String requestHash = hash(action, expectedVersion, confirm, reason); CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) { DocumentRow source = requiredDocument(documentId); From 703b0f63cea1f0517fb4367834b57ff8ca54c43b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 26 Sep 2026 18:01:40 +0800 Subject: [PATCH 14/31] fix(firstleg): restrict broker submission to tax refund cargo --- .../customs/container/mapper/ContainerCustomsMapper.java | 8 ++++++++ .../container/service/ContainerCustomsService.java | 1 + 2 files changed, 9 insertions(+) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 31150ac16..0d9b75de5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -217,6 +217,14 @@ public interface ContainerCustomsMapper { """) int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId); + @Select(""" + SELECT COUNT(DISTINCT i.id) + FROM fl_loading_plan_item item + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + """) + int taxRefundCount(@Param("planId") long planId); + @Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " + "WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL") int freezeDocuments(@Param("containerId") long containerId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index e2fd3f213..f46221205 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -183,6 +183,7 @@ public class ContainerCustomsService { ContainerRow container = mapper.lockContainer(planId); if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行"); if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认"); if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行"); if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent(); From 9f025edc969724a3c560bd1d50ca7bc5409ba9f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 01:01:40 +0800 Subject: [PATCH 15/31] fix(file): allow customs broker receipt files --- file/AGENTS.md | 2 +- .../file/service/BusinessFileCategory.java | 1 + .../openapi/customs-file-and-export-v1.yaml | 6 +++--- .../config/CustomsFileExportContractTest.java | 2 +- .../service/InternalBusinessFileServiceTest.java | 3 +++ scripts/admin-test-env-modules/README.md | 1 + .../domestic-pda-operator-contract.test.mjs | 16 ++++++++++++++++ .../domestic-pda-operator.mjs | 14 ++++++++++++++ .../manual-state-loading-pda-contract.test.mjs | 2 +- .../manual-state-loading-pda.mjs | 3 ++- .../phase3-container-customs-fixture.mjs | 2 +- .../phase3-container-loading.mjs | 3 ++- .../admin-test-env-modules/phase3-loading.mjs | 3 ++- .../phase3-order-splitting-contract.test.mjs | 2 +- .../phase3-order-splitting.mjs | 3 ++- 15 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/domestic-pda-operator.mjs diff --git a/file/AGENTS.md b/file/AGENTS.md index 7d16583d5..c4a01d206 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -19,7 +19,7 @@ | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | -| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | +| 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 10c944048..42add2447 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -31,6 +31,7 @@ enum BusinessFileCategory { OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), + LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO); diff --git a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml index f7fa002a8..88e895f73 100644 --- a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml +++ b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml @@ -5,7 +5,7 @@ info: description: | File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。 Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。 - LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0; + LOADING_LIST_CUSTOMS_SOURCE、LOADING_LIST_CUSTOMS_RECEIPT 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0; 客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。 用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list; Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。 @@ -15,7 +15,7 @@ paths: post: summary: 保存报关业务文件对象 description: | - 仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。 + 仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE、LOADING_LIST_CUSTOMS_RECEIPT 或 LOADING_LIST_CUSTOMS_RESULT。 支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。 parameters: - $ref: '#/components/parameters/InternalToken' @@ -94,7 +94,7 @@ components: InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}} TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域} - FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}} + FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RECEIPT, LOADING_LIST_CUSTOMS_RESULT]}} LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java index 1252a954f..29fef5e9e 100644 --- a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -29,7 +29,7 @@ class CustomsFileExportContractTest { String source = Files.readString(repositoryRoot() .resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml")); - assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT") + assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RECEIPT", "LOADING_LIST_CUSTOMS_RESULT") .contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId") .contains("platform:firstleg:customs:manage", "platform:firstleg:customs:list", "merchant:order:edit", "merchant:order:view") diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 3f1c1fe34..d0d9790b1 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -169,14 +169,17 @@ class InternalBusinessFileServiceTest { }); var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file); + var receipt = service.upload(10001L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file); var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file); assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessId) .containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51"); + assertThat(receipt.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RECEIPT"); assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT"); verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE); + verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RECEIPT); verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT); } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 8f2193cab..3178c1d80 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -168,6 +168,7 @@ export async function run(context) { | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | +| 国内装柜验收作业员选择 | `domestic-pda-operator.mjs`;按本次 PDA 登录 Token 的字符串 subject 匹配目标仓库候选 userId,缺少匹配即停止,不使用首位候选或成员编号 | `domestic-pda-operator-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` | | 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs b/scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs new file mode 100644 index 000000000..a5a416d1d --- /dev/null +++ b/scripts/admin-test-env-modules/domestic-pda-operator-contract.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' + +const subject = '2103886047128588290' +const token = `header.${Buffer.from(JSON.stringify({ sub: subject })).toString('base64url')}.signature` +test('task assignee matches authenticated user even when another operator is first', () => { + const current = { userId: subject, membershipId: '2103886047128588291' } + const operators = [{ userId: '194000000000000101' }, current] + assert.equal(selectSessionPdaOperator(operators, token), current) + assert.equal(selectSessionPdaOperator({ records: operators }, token), current) +}) +test('missing warehouse candidate does not fall back to another operator or membership ID', () => { + assert.throws(() => selectSessionPdaOperator([{ userId: '2103886047128588291' }], token), /不在目标仓库候选/) + assert.throws(() => selectSessionPdaOperator([], ''), /有效登录身份/) +}) diff --git a/scripts/admin-test-env-modules/domestic-pda-operator.mjs b/scripts/admin-test-env-modules/domestic-pda-operator.mjs new file mode 100644 index 000000000..1b7ecb345 --- /dev/null +++ b/scripts/admin-test-env-modules/domestic-pda-operator.mjs @@ -0,0 +1,14 @@ +// Use the subject of the authenticated test session, never the first candidate +// or IAM membership ID. Server-side warehouse and assignment checks remain authoritative. +export function selectSessionPdaOperator(operators, token) { + let subject + try { subject = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')).sub } + catch { throw new Error('国内 PDA 验收缺少有效登录身份') } + if (typeof subject !== 'string' || !/^[1-9]\d*$/.test(subject)) { + throw new Error('国内 PDA 登录身份必须包含字符串用户编号') + } + const rows = Array.isArray(operators) ? operators : operators?.records || [] + const operator = rows.find((item) => String(item.userId) === subject) + if (!operator) throw new Error('当前登录 PDA 作业员不在目标仓库候选中,停止分配任务') + return operator +} diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 314b60a0d..e4734a05f 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -30,7 +30,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro assert.doesNotMatch(source, /push-preload/) assert.match(source, /计划确认前不生成 PDA 任务/) assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) - assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion, assigneeId: id\(pdaOperator.userId\) \}\)/) assert.match(source, /confirmationStatus === 'CONFIRMED'/) assert.match(source, /readiness\?\.readyForPda === true/) assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 858ece9f2..cc72bc3e2 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' const MODULE = 'manual-state-loading-pda' const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' @@ -216,7 +217,7 @@ async function createPlanTask(context, label) { const pdaOperatorsResponse = await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(source.warehouseId)}`) const pdaOperators = context.apiSuccess(MODULE, `${label} 查询国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS) - const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || [])[0] + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) context.assert(MODULE, `${label} 存在可用 PDA 作业员`, pdaOperatorsResponse, Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) if (!pdaOperator?.userId) throw new Error(`${label} 缺少可用 PDA 作业员`) diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 8b3bda756..51785dbd0 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -83,7 +83,7 @@ function routes(context, merchantToken) { undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST', `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`, - { expectedVersion: Number(request.expectedVersion), remark: request.remark || '' }, + { expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' }, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }), platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index aaee70b9f..4d91841ed 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' import { publishPlatformLogisticsChannels, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' @@ -563,7 +564,7 @@ export async function run(context) { const pdaOperatorsResponse = await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`) const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS) - const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : records(pdaOperators))[0] + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse, Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) if (!pdaOperator?.userId) return diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 93e61f0ba..024401f5c 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' @@ -672,7 +673,7 @@ export async function run(context) { const pdaOperatorsResponse = await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`) const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS) - const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || [])[0] + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse, Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) if (!pdaOperator?.userId) return diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 7858d015b..b160be16c 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -52,7 +52,7 @@ test('PDA opens only after linking a released requirement and uses stable plan i assert.match(source, /if \(!transportStable\) return null/) assert.match(source, /计划确认前不生成 PDA 任务/) assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) - assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion, assigneeId: id\(pdaOperator.userId\) \}\)/) assert.match(source, /confirmationStatus === 'CONFIRMED'/) assert.match(source, /readiness\?\.readyForPda === true/) assert.match(source, /taskId === confirmedTaskId/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index fd6228711..2e774186b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,3 +1,4 @@ +import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' const MODULE = 'phase3-order-splitting' const SCENARIO = 'source-loaded-single-remainder-chain' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -339,7 +340,7 @@ async function loadOneFragment(context, state) { const pdaOperatorsResponse = await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`) const pdaOperators = context.apiSuccess(MODULE, `查询 ${sourceOrderNo} 国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS) - const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || [])[0] + const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken) context.assert(MODULE, `${sourceOrderNo} 存在可用 PDA 作业员`, pdaOperatorsResponse, Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS) if (!pdaOperator?.userId) return null From 93e2e8fb4409c3afa009f637a489d1e77c652f96 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 08:29:53 +0800 Subject: [PATCH 16/31] test(customs): align receipt and final file contracts --- .../baiyue/file/service/InternalBusinessFileServiceTest.java | 4 +++- scripts/agents-index-contract.ps1 | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index d0d9790b1..03ef101c5 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -159,7 +159,7 @@ class InternalBusinessFileServiceTest { .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); } - @Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() { + @Test void loadingCustomsSourceReceiptAndResultAreCustomerOwnedArbitraryFormatObjects() { MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes()); when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class))) .thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip", @@ -188,6 +188,8 @@ class InternalBusinessFileServiceTest { assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file)) .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); verifyNoInteractions(files, storage); diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 15c9477f4..5451d495a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -776,13 +776,15 @@ Assert-Contains $firstlegAgents 'customs/container/controller/MerchantContainerC Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:' '客户退税资料上传契约' Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/documents/{documentId}/inbounds:' '客户资料入仓码范围契约' Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/start:' '平台开始报关冻结契约' -Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:' '平台完成文件契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload:' '平台报关行回执契约' +Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload:' '平台完成文件契约' Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/complete-all-buy:' '全买单柜完成契约' Assert-Contains $containerCustomsOpenApi 'enum: [TAX_REFUND, BUY_DECLARATION]' '整柜报关税务模式' Assert-Contains $adminContainerCustomsApi "const basePath = '/platform/v1/customs/loading-plans'" 'Admin 整柜报关真实调用' Assert-Contains $clientContainerCustomsApi '/merchant/v1/customs/orders/' 'Client 订单报关真实调用' Assert-Contains $clientContainerCustomsApi 'Idempotency-Key' 'Client 报关写入幂等' Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_SOURCE' 'File 报关源文件类别' +Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RECEIPT' 'File 报关行回执类别' Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RESULT' 'File 报关完成文件类别' # 平台商品图片和海外 PDA 签收照片都必须走鉴权下载,不暴露内部存储路径。 From 4525790534b5730a711e08ae6c46d87eb036f269 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 27 Sep 2026 18:55:19 +0800 Subject: [PATCH 17/31] feat(firstleg): add sailing calendar facts and APIs --- deploy/sql/头程数据库统一增量-MySQL8.sql | 274 +++++++++- .../PlatformSailingAssignmentController.java | 34 ++ .../PlatformSailingCalendarController.java | 104 ++++ .../PlatformSailingChannelController.java | 30 ++ .../PlatformSailingForecastController.java | 74 +++ .../PlatformSailingTemplateController.java | 40 ++ .../dto/SailingCalendarDtos.java | 283 ++++++++++ .../entity/SailingChannelEntity.java | 32 ++ .../sailingcalendar/entity/SailingEntity.java | 43 ++ .../entity/SailingHistoryEntity.java | 25 + .../entity/SailingTemplateEntity.java | 45 ++ .../fact/entity/SailingAssignmentEntity.java | 40 ++ .../fact/entity/SailingBookingLinkEntity.java | 32 ++ .../SailingInboundVolumeForecastEntity.java | 35 ++ .../entity/SailingReceiptBatchFactEntity.java | 36 ++ .../fact/mapper/SailingAssignmentMapper.java | 24 + .../fact/mapper/SailingBookingLinkMapper.java | 8 + .../SailingInboundVolumeForecastMapper.java | 8 + .../mapper/SailingReceiptBatchFactMapper.java | 18 + .../repository/SailingFactsRepository.java | 195 +++++++ .../mapper/SailingChannelMapper.java | 34 ++ .../mapper/SailingHistoryMapper.java | 8 + .../sailingcalendar/mapper/SailingMapper.java | 75 +++ .../mapper/SailingTemplateMapper.java | 36 ++ .../repository/SailingCalendarRepository.java | 112 ++++ .../service/SailingCalendarService.java | 482 ++++++++++++++++++ .../service/MeasurementReviewService.java | 105 ++++ .../db/phase4-sailing-calendar-schema.sql | 143 ++++++ .../db/phase4-sailing-facts-schema.sql | 126 +++++ .../openapi/sailing-calendar-v1.yaml | 138 +++++ .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../fact/SailingFactsSqlContractTest.java | 52 ++ gateway/src/main/resources/application.yml | 2 +- .../initialization-baseline-contract.test.mjs | 4 +- 34 files changed, 2694 insertions(+), 5 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingAssignmentController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingCalendarController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingChannelController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingForecastController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingTemplateController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/entity/SailingChannelEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/entity/SailingEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/entity/SailingHistoryEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/entity/SailingTemplateEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/entity/SailingAssignmentEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/entity/SailingBookingLinkEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/entity/SailingInboundVolumeForecastEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/entity/SailingReceiptBatchFactEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/mapper/SailingAssignmentMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/mapper/SailingBookingLinkMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/mapper/SailingInboundVolumeForecastMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/mapper/SailingReceiptBatchFactMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/fact/repository/SailingFactsRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingChannelMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingHistoryMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/mapper/SailingTemplateMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/repository/SailingCalendarRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java create mode 100644 firstleg/src/main/resources/db/phase4-sailing-calendar-schema.sql create mode 100644 firstleg/src/main/resources/db/phase4-sailing-facts-schema.sql create mode 100644 firstleg/src/main/resources/openapi/sailing-calendar-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/fact/SailingFactsSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5d85f593d..c60b2b3b0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7778,4 +7778,276 @@ USE baiyue_finance; -- 系统基线 fin_subject:1 行。 INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables; +-- phase4-sailing-calendar-schema.sql + +SET NAMES utf8mb4; +USE baiyue_firstleg; + +-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。 +CREATE TABLE fl_sailing_channel ( + id BIGINT NOT NULL, + channel_code VARCHAR(64) NOT NULL, + channel_name VARCHAR(128) NOT NULL, + source_type VARCHAR(24) NOT NULL, + mapped_channel_code VARCHAR(64) NULL, + default_departure_port_code VARCHAR(32) NULL, + default_destination_port_code VARCHAR(32) NULL, + default_cutoff_days TINYINT NOT NULL DEFAULT 3, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_channel_code (channel_code), + KEY idx_fl_sailing_channel_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_template ( + id BIGINT NOT NULL, + template_no VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + weekday TINYINT NOT NULL, + weekday_mask TINYINT NOT NULL DEFAULT 1, + etd_local_time TIME NOT NULL, + cutoff_days TINYINT NOT NULL DEFAULT 3, + cutoff_local_time TIME NOT NULL DEFAULT '18:00:00', + valid_from DATE NOT NULL, + valid_to DATE NULL, + voyage_rule VARCHAR(64) NULL, + vessel_name VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_template_no (template_no), + KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), + CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing ( + id BIGINT NOT NULL, + sailing_no VARCHAR(64) NOT NULL, + template_id BIGINT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + vessel_name VARCHAR(128) NOT NULL, + voyage_no VARCHAR(64) NOT NULL, + etd_at DATETIME(3) NOT NULL, + warehouse_cutoff_at DATETIME(3) NOT NULL, + eta_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL', + generated_key VARCHAR(192) NULL, + planned_capacity_cbm DECIMAL(14,3) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_no (sailing_no), + UNIQUE KEY uk_fl_sailing_generated_key (generated_key), + UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no), + KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), + KEY idx_fl_sailing_route (departure_port_code,destination_port_code), + CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_history ( + id BIGINT NOT NULL, + sailing_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + reason VARCHAR(1000) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE baiyue_iam; + +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0), + (5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0), + (5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0), + (5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0), + (5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0), + (5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0), + (5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0); + +INSERT INTO iam_role_permission(role_id,permission_id,tenant_id) +SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN ( + 'platform:firstleg:sailing-calendar:list', + 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', + 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', + 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign' + ) +WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); + +-- phase4-sailing-facts-schema.sql + +SET NAMES utf8mb4; +USE baiyue_firstleg; + +/* + * 船期日历的事实契约。 + * + * 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是 + * RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。 + */ + +CREATE TABLE fl_sailing_receipt_batch_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3', + fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version), + KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version), + KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id), + KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id), + CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0), + CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED')) +) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +CREATE TABLE fl_sailing_inbound_volume_forecast ( + id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照', + expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间', + forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version), + KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version), + KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id), + KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id), + CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0), + CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED')) +) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史'; + +CREATE TABLE fl_sailing_assignment ( + id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空', + origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID', + inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示', + receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID', + forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID', + assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本', + match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积', + forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积', + route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源', + rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本', + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + superseded_at DATETIME(3) NULL, + locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间', + lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + operator_id BIGINT UNSIGNED NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id), + KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id), + KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id), + KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id), + KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id), + CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')), + CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER')) +) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史'; + +CREATE TABLE fl_sailing_booking_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键', + sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', + link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照', + channel_code VARCHAR(64) NULL COMMENT '关联渠道快照', + departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照', + destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照', + linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + operator_id BIGINT UNSIGNED NULL, + operator_name VARCHAR(64) NULL, + reason VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard), + KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id), + KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id), + CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')), + CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)), + CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') +) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 219 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingAssignmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingAssignmentController.java new file mode 100644 index 000000000..cb031a952 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingAssignmentController.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.sailingcalendar.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionPage; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionQuery; +import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@Tag(name = "船期异常货量") +@RequestMapping("/api/platform/v1/sailing-assignments") +public class PlatformSailingAssignmentController { + @Resource private SailingCalendarService service; + + @GetMapping("/exceptions") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + @Operation(summary = "查询未安排和晚到货量") + public ApiResponse exceptions(@AuthenticationPrincipal AuthenticatedUser user, @Valid ExceptionQuery query) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.exceptions(query)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingCalendarController.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingCalendarController.java new file mode 100644 index 000000000..74d2555dd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingCalendarController.java @@ -0,0 +1,104 @@ +package com.baiyue.firstleg.sailingcalendar.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@Validated +@RestController +@Tag(name = "船期日历") +@RequestMapping("/api/platform/v1/sailings") +public class PlatformSailingCalendarController { + @Resource private SailingCalendarService service; + + @GetMapping("/calendar") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + @Operation(summary = "查询船期月历") + public ApiResponse calendar(@AuthenticationPrincipal AuthenticatedUser user, @Valid SailingQuery query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.calendar(query)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id)); + } + + @GetMapping("/{id}/history") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + @Operation(summary = "查询船期变更历史") + public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.history(id)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody SailingCreateRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(service.createSailing(user.userId(), user.username(), request).getId())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @Valid @RequestBody SailingUpdateRequest request) { + FirstlegScopeGuard.platform(user); service.updateSailing(id, user.userId(), user.username(), request); return ApiResponse.success(service.detail(id)); + } + + @PostMapping("/{id}/cancel") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @Valid @RequestBody CancelRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(id, user.userId(), user.username(), request)); + } + + @PostMapping("/{id}/exception-reassign") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:exception-reassign')") + public ApiResponse exceptionReassign(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @Valid @RequestBody ExceptionReassignRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.exceptionReassign(id, user.userId(), user.username(), request)); + } + + @PostMapping("/{id}/copy") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')") + public ApiResponse copy(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @Valid @RequestBody CopyRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.copy(id, user.userId(), user.username(), request)); + } + + @GetMapping("/{id}/volume-summary") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + public ApiResponse volumeSummary(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.volumeSummary(id)); + } + + @GetMapping("/{id}/booking-advice") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + public ApiResponse bookingAdvice(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.bookingAdvice(id)); + } + + @PostMapping("/{id}/booking-links") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-booking:link')") + public ApiResponse> bookingLink(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @Valid @RequestBody BookingLinkRequest request) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkBooking(id, user.userId(), user.username(), request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingChannelController.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingChannelController.java new file mode 100644 index 000000000..a55b925b7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingChannelController.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.sailingcalendar.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*; +import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity; +import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/sailing-channels") +public class PlatformSailingChannelController { + @Resource private SailingCalendarService service; + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid ChannelQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(query).stream().map(ChannelView::channel).toList()); } + @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ChannelCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createChannel(user.userId(), user.username(), request)); } + @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody ChannelUpdateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateChannel(id, user.userId(), user.username(), request)); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingForecastController.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingForecastController.java new file mode 100644 index 000000000..3bdbc64c8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingForecastController.java @@ -0,0 +1,74 @@ +package com.baiyue.firstleg.sailingcalendar.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastCreateRequest; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastRevisionRequest; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastReceiptBindRequest; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastVoidRequest; +import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity; +import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 平台运营维护船期日历使用的正式未收货量预报事实入口。 */ +@Validated +@RestController +@Tag(name = "船期货量预报") +@RequestMapping("/api/platform/v1/sailing-forecasts") +public class PlatformSailingForecastController { + @Resource private SailingCalendarService service; + + @PostMapping + @PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')") + @Operation(summary = "新增未收货量预报", description = "保存版本化预报事实并按仓库、渠道、港口和截止时间自动归班;无法解释时保留UNMATCHED。") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ForecastCreateRequest request) { + FirstlegScopeGuard.platform(user, request.tenantId()); + return ApiResponse.success(service.createForecast(user.userId(), request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')") + @Operation(summary = "修订未收货量预报", description = "旧版本标记为SUPERSEDED,新版本重新归班;不覆盖历史版本。") + public ApiResponse revise(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody ForecastRevisionRequest request) { + FirstlegScopeGuard.platform(user, request.tenantId()); + return ApiResponse.success(service.reviseForecast(user.userId(), id, request)); + } + + @PostMapping("/{id}/void") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')") + @Operation(summary = "作废未收货量预报", description = "作废有效预报并将其当前归班版本标记为SUPERSEDED。") + public ApiResponse voidForecast(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody ForecastVoidRequest request) { + FirstlegScopeGuard.platform(user, request.tenantId()); + service.voidForecast(user.userId(), id, request); + return ApiResponse.success((Void) null); + } + + @PostMapping("/{id}/bind-receipt") + @PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')") + @Operation(summary = "将实收批次绑定到预报批次", description = "多批预报无法由实测体积唯一解释时,由运营显式选择预报批次;服务端保留原历史版本。") + public ApiResponse bindReceipt(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody ForecastReceiptBindRequest request) { + FirstlegScopeGuard.platform(user, request.tenantId()); + service.bindReceiptToForecast(user.userId(), id, request); + return ApiResponse.success((Void) null); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingTemplateController.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingTemplateController.java new file mode 100644 index 000000000..08699772e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/controller/PlatformSailingTemplateController.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.sailingcalendar.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity; +import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/sailing-templates") +public class PlatformSailingTemplateController { + @Resource private SailingCalendarService service; + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid TemplateQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateViews(query)); } + @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody TemplateCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateView(service.createTemplate(user.userId(), user.username(), request).getId())); } + @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody TemplateUpdateRequest request) { FirstlegScopeGuard.platform(user); service.updateTemplate(id, user.userId(), user.username(), request); return ApiResponse.success(service.templateView(id)); } + @PostMapping("/{id}/generation-preview") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse preview(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); return ApiResponse.success(new GenerationPreviewResult(id, service.generationPreview(id, range.from(), range.to()))); } + @PostMapping("/{id}/generate") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')") + public ApiResponse generate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); List generated = service.generate(user.userId(), user.username(), id, range.from(), range.to()); List records = service.generationPreview(id, range.from(), range.to()); return ApiResponse.success(new GenerationResult(id, records, generated.size(), Math.max(0, records.size() - generated.size()))); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java new file mode 100644 index 000000000..715b7e293 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/dto/SailingCalendarDtos.java @@ -0,0 +1,283 @@ +package com.baiyue.firstleg.sailingcalendar.dto; + +import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity; +import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +public final class SailingCalendarDtos { + private SailingCalendarDtos() {} + + public record ChannelQuery(@Size(max = 64) String keyword, String status) {} + public record ChannelData(@NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotBlank String sourceType, + @Size(max = 64) String mappedChannelCode, + @Size(max = 32) String defaultDeparturePortCode, + @Size(max = 32) String defaultDestinationPortCode, + @Min(0) @Max(30) Integer defaultCutoffDays, + @Size(max = 1000) String remark) {} + /** Flat JSON contract; data() keeps the service boundary grouped by domain object. */ + public record ChannelCreateRequest(@Min(0) Integer expectedVersion, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotBlank String sourceType, + @Size(max = 64) String mappedChannelCode, + @Size(max = 32) String defaultDeparturePortCode, + @Size(max = 32) String defaultDestinationPortCode, + @Min(0) @Max(30) Integer defaultCutoffDays, + @Size(max = 1000) String remark) { + public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType, + mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); } + } + public record ChannelUpdateRequest(@Min(0) Integer expectedVersion, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotBlank String sourceType, + @Size(max = 64) String mappedChannelCode, + @Size(max = 32) String defaultDeparturePortCode, + @Size(max = 32) String defaultDestinationPortCode, + @Min(0) @Max(30) Integer defaultCutoffDays, + @Size(max = 1000) String remark, + @NotBlank String status) { + public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType, + mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); } + } + public record ChannelView(SailingChannelEntity channel, boolean editable) {} + + public record TemplateQuery(@Positive Long warehouseId, @Positive Long channelId, + String status, @Min(1) long page, @Min(1) @Max(200) long size) { + public TemplateQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + public record TemplateData(@Size(max = 128) String templateName, + @NotNull @Positive Long channelId, + @NotNull @Positive Long warehouseId, + @Size(max = 128) String warehouseName, + List<@NotNull @Min(1) @Max(7) Integer> weekdays, + @NotNull LocalDate effectiveFrom, + LocalDate effectiveTo, + @NotNull LocalTime departureTime, + @NotNull @Min(0) @Max(30) Integer cutoffDays, + @NotNull LocalTime cutoffLocalTime, + @Size(max = 64) String voyageRule, + @Size(max = 128) String vesselName, + @Size(max = 32) String departurePortCode, + @Size(max = 32) String destinationPortCode, + @Size(max = 1000) String remark) {} + public record TemplateCreateRequest(@Min(0) Integer expectedVersion, + @Size(max = 128) String templateName, + @NotNull @Positive Long channelId, + @NotNull @Positive Long warehouseId, + @Size(max = 128) String warehouseName, + List<@NotNull @Min(1) @Max(7) Integer> weekdays, + @NotNull LocalDate effectiveFrom, + LocalDate effectiveTo, + @NotNull LocalTime departureTime, + @NotNull @Min(0) @Max(30) Integer cutoffDays, + @NotNull LocalTime cutoffLocalTime, + @Size(max = 64) String voyageRule, + @Size(max = 128) String vesselName, + @Size(max = 32) String departurePortCode, + @Size(max = 32) String destinationPortCode, + @Size(max = 1000) String remark) { + public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName, + weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule, + vesselName, departurePortCode, destinationPortCode, remark); } + } + public record TemplateUpdateRequest(@Min(0) Integer expectedVersion, + @Size(max = 128) String templateName, + @NotNull @Positive Long channelId, + @NotNull @Positive Long warehouseId, + @Size(max = 128) String warehouseName, + List<@NotNull @Min(1) @Max(7) Integer> weekdays, + @NotNull LocalDate effectiveFrom, + LocalDate effectiveTo, + @NotNull LocalTime departureTime, + @NotNull @Min(0) @Max(30) Integer cutoffDays, + @NotNull LocalTime cutoffLocalTime, + @Size(max = 64) String voyageRule, + @Size(max = 128) String vesselName, + @Size(max = 32) String departurePortCode, + @Size(max = 32) String destinationPortCode, + @Size(max = 1000) String remark, + String status) { + public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName, + weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule, + vesselName, departurePortCode, destinationPortCode, remark); } + } + public record TemplateView(Long id, String templateNo, String templateName, Long channelId, String channelCode, + String channelName, Long warehouseId, String warehouseName, List weekdays, + LocalDate effectiveFrom, LocalDate effectiveTo, LocalTime departureTime, + Integer cutoffDays, LocalTime cutoffLocalTime, String voyageRule, String vesselName, + String departurePortCode, String destinationPortCode, String remark, + String status, Integer version) {} + + public record SailingQuery(@NotBlank String month, + @Positive Long warehouseId, @Positive Long channelId, + @Size(max = 64) String channelCode, + @Size(max = 32) String departurePortCode, + @Size(max = 32) String destinationPortCode, + @Size(max = 128) String keyword, String status) {} + public record SailingData(@Positive Long templateId, @Positive Long channelId, + @NotNull @Positive Long warehouseId, + @Size(max = 128) String warehouseName, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotBlank @Size(max = 32) String departurePortCode, + @Size(max = 128) String departurePortName, + @NotBlank @Size(max = 32) String destinationPortCode, + @Size(max = 128) String destinationPortName, + @NotBlank @Size(max = 128) String vesselName, + @NotBlank @Size(max = 64) String voyageNo, + @NotNull LocalDateTime etdAt, + LocalDateTime etaAt, + @NotNull LocalDateTime cutoffAt, + @Size(max = 64) String sourceType, + @Size(max = 128) String remark, + BigDecimal plannedCapacityCbm) {} + public record SailingCreateRequest(@Min(0) Integer expectedVersion, + @Positive Long templateId, + @Positive Long channelId, + @NotNull @Positive Long warehouseId, + @Size(max = 128) String warehouseName, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotBlank @Size(max = 32) String departurePortCode, + @Size(max = 128) String departurePortName, + @NotBlank @Size(max = 32) String destinationPortCode, + @Size(max = 128) String destinationPortName, + @NotBlank @Size(max = 128) String vesselName, + @NotBlank @Size(max = 64) String voyageNo, + @NotNull LocalDateTime etdAt, + LocalDateTime etaAt, + @NotNull LocalDateTime cutoffAt, + @Size(max = 64) String sourceType, + @Size(max = 128) String remark, + BigDecimal plannedCapacityCbm) { + public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName, + channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName, + vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); } + } + public record SailingUpdateRequest(@Min(0) Integer expectedVersion, + @Positive Long templateId, + @Positive Long channelId, + @NotNull @Positive Long warehouseId, + @Size(max = 128) String warehouseName, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 128) String channelName, + @NotBlank @Size(max = 32) String departurePortCode, + @Size(max = 128) String departurePortName, + @NotBlank @Size(max = 32) String destinationPortCode, + @Size(max = 128) String destinationPortName, + @NotBlank @Size(max = 128) String vesselName, + @NotBlank @Size(max = 64) String voyageNo, + @NotNull LocalDateTime etdAt, + LocalDateTime etaAt, + @NotNull LocalDateTime cutoffAt, + @Size(max = 64) String sourceType, + @Size(max = 128) String remark, + BigDecimal plannedCapacityCbm, + String status) { + public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName, + channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName, + vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); } + } + public record CalendarItem(Long id, String sailingNo, Long warehouseId, String warehouseName, + String channelCode, String channelName, String vesselName, String voyageNo, + String departurePortCode, String destinationPortCode, LocalDateTime etdAt, + LocalDateTime cutoffAt, String status, Integer version, + VolumeSummary volumeSummary) {} + public record VolumeSummary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, + BigDecimal riskUnmatchedCbm, int bookedContainerCount, + int recommendedContainerCount, int pendingContainerCount, + int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, + BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt, + BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {} + public record CalendarResult(String month, String timezone, List records, + Summary summary, FilterOptions filterOptions) { + public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm, + int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount, + int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount, + BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {} + public record FilterOptions(List