From 0bc752a2be4410c88009cac69075c88d7123163c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 09:46:50 +0800 Subject: [PATCH 001/112] fix: seed IT customer qualifications --- deploy/sql/头程数据库统一增量-MySQL8.sql | 16 ++++++++++++++++ iam/src/main/resources/db/phase1-admin-rbac.sql | 16 ++++++++++++++++ .../initialization-baseline-contract.test.mjs | 9 +++++++++ 3 files changed, 41 insertions(+) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 140df9482..1ff319d5e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1104,6 +1104,22 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; +-- The built-in IT customer must be immediately usable for order acceptance tests. +-- These approved metadata rows intentionally have no document URL: initialization must not +-- point at qualification files that do not exist in File service storage. +INSERT INTO iam_tenant_qualification ( + id, tenant_id, qualification_type, certificate_no, document_url, + valid_from, valid_to, status, review_comment, deleted +) VALUES + (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), + (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), + certificate_no = VALUES(certificate_no), status = 'APPROVED', + review_comment = VALUES(review_comment), deleted = 0; + INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index ad2f958a5..fcc5d4bde 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -38,6 +38,22 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; +-- The built-in IT customer must be immediately usable for order acceptance tests. +-- These approved metadata rows intentionally have no document URL: initialization must not +-- point at qualification files that do not exist in File service storage. +INSERT INTO iam_tenant_qualification ( + id, tenant_id, qualification_type, certificate_no, document_url, + valid_from, valid_to, status, review_comment, deleted +) VALUES + (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), + (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, + NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) +ON DUPLICATE KEY UPDATE + tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), + certificate_no = VALUES(certificate_no), status = 'APPROVED', + review_comment = VALUES(review_comment), deleted = 0; + INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index a80b76097..4923994c7 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') +const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-admin-rbac.sql', import.meta.url), 'utf8') test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) @@ -11,6 +12,14 @@ test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) }) +test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { + for (const source of [sql, iamSeed]) { + assert.match(source, /\(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,[\s\S]*?'APPROVED'/) + assert.match(source, /\(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,[\s\S]*?'APPROVED'/) + assert.match(source, /status = 'APPROVED'/) + } +}) + test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => { for (const marker of [ "'TP-001'", "'TP-002'", "'A1 普通库位'", "'A0 共享溢出位'", From 66089f9b992be1424c9ea4d474588997d8d764b2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 10:09:46 +0800 Subject: [PATCH 002/112] =?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 | 14 + .../PlatformContainerController.java | 6 +- .../PlatformLoadingListController.java | 4 +- .../service/InboundFeeDetailService.java | 42 +-- ...se2-published-freight-detail-migration.sql | 13 + .../ContainerControllerContractTest.java | 21 ++ .../LoadingListControllerContractTest.java | 14 + .../FeeItemMigrationSqlContractTest.java | 3 + .../service/FeeManagementServiceTest.java | 31 ++- scripts/README.md | 2 +- scripts/admin-test-env-acceptance.mjs | 14 + scripts/admin-test-env-modules/README.md | 2 + .../fee-items-contract.test.mjs | 16 +- scripts/admin-test-env-modules/fee-items.mjs | 32 ++- .../finance-full-fixtures-contract.test.mjs | 9 +- .../finance-full-fixtures.mjs | 14 +- .../finance-settlement-full-contract.test.mjs | 17 +- .../finance-settlement-full.mjs | 4 +- ...ual-state-ocean-trucking-contract.test.mjs | 6 + .../manual-state-ocean-trucking.mjs | 31 ++- .../admin-test-env-modules/ocean-bookings.mjs | 42 ++- ...-inbound-status-fixtures-contract.test.mjs | 22 +- .../order-inbound-status-fixtures.mjs | 64 +++-- .../phase3-container-overview.mjs | 7 +- .../admin-test-env-modules/phase3-loading.mjs | 3 +- .../phase3-order-splitting-contract.test.mjs | 34 +++ .../phase3-order-splitting.mjs | 98 ++++--- .../phase3-producer-chain-contract.test.mjs | 6 + .../client-notifications-contract.test.mjs | 44 ++++ scripts/client-test-env-acceptance.mjs | 241 +++++++++++++++++- scripts/local-db-full-acceptance.ps1 | 4 +- 31 files changed, 719 insertions(+), 141 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs create mode 100644 scripts/client-notifications-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1ff319d5e..0f0685910 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7058,6 +7058,15 @@ CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_pub 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; +-- 系统计费使用的固定公共费用项目;不属于费用类型.xls 的110条受控种子。 +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, + description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at) +SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL', 'FBA头程运费', + 0, 0, 0, 1, 0, '系统计费发布使用的固定应收费用项目', 'ACTIVE', + 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); + -- ============================================================================ -- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线 -- ============================================================================ @@ -14042,6 +14051,11 @@ BEGIN WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 + OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_fee_item + WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS ( SELECT 1 FROM baiyue_firstleg.fl_inbound_fee_detail detail diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 8b9090db0..f8226b3cc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -70,7 +70,7 @@ public class PlatformContainerController { @GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") public ApiResponse logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));} @PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") - public ApiResponse uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestPart("expectedVersion") @Min(0) int version,@RequestPart(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} + public ApiResponse uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} @GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") public ResponseEntity downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());} @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") @@ -127,8 +127,8 @@ public class PlatformContainerController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @RequestPart("file") MultipartFile file, - @RequestPart("expectedVersion") @Min(0) int expectedVersion, - @RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) { + @RequestParam("expectedVersion") @Min(0) int expectedVersion, + @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); return ApiResponse.success(loadingLists.uploadContainerClearance( id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index d9fc87a4c..f7f6788a0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -129,8 +129,8 @@ public class PlatformLoadingListController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, @RequestPart("file") MultipartFile file, - @RequestPart("expectedVersion") @Min(0) int expectedVersion, - @RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) { + @RequestParam("expectedVersion") @Min(0) int expectedVersion, + @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId, user.userId(), user.username(), key, expectedVersion, operationReason, file)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 442c19d28..e0a026355 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -22,7 +22,6 @@ import java.util.List; /** 入库单人工费用明细应用服务。 */ @Service public class InboundFeeDetailService { - private static final long PLATFORM_TENANT_ID = 0L; private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT"; @Resource private FeeItemRepository repository; @@ -59,7 +58,7 @@ public class InboundFeeDetailService { throw conflict("只有已发送状态的计费快照才能生成客户运费明细"); } LocalDateTime now = publishedAt == null ? LocalDateTime.now() : publishedAt; - FeeItemEntity feeItem = requiredFreightItem(operatorId, operatorName); + FeeItemEntity feeItem = requiredFreightItem(); InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, feeId); repository.supersedePublishedFreight(tenantId, inboundId, feeId, operatorId, now); @@ -240,42 +239,11 @@ public class InboundFeeDetailService { return value; } - private FeeItemEntity requiredFreightItem(long operatorId, String operatorName) { + private FeeItemEntity requiredFreightItem() { FeeItemEntity item = repository.findItemByCode(FREIGHT_FEE_CODE); - if (item != null) { - if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用"); - return item; - } - FeeItemEntity created = new FeeItemEntity(); - created.setTenantId(PLATFORM_TENANT_ID); - created.setCategory("头程运费"); - created.setTradeScope("INTERNATIONAL"); - created.setFeeCode(FREIGHT_FEE_CODE); - created.setFeeTypeName("FBA头程运费"); - created.setPickupPayable(false); - created.setPickupReceivable(false); - created.setWarehousePayable(false); - created.setWarehouseReceivable(true); - created.setAllocationFee(false); - created.setDescription("系统计费发布时自动维护,不允许用于应付费用"); - created.setStatus("ACTIVE"); - created.setCreatedBy(operatorId); - created.setUpdatedBy(operatorId); - created.setCreatedByName(operatorName); - created.setUpdatedByName(operatorName); - try { - repository.insertItem(created); - return created; - } catch (DuplicateKeyException exception) { - FeeItemEntity concurrent = repository.findItemByCode(FREIGHT_FEE_CODE); - if (concurrent != null) { - if (!"ACTIVE".equals(concurrent.getStatus())) { - throw conflict("系统费用项目FBA头程运费已停用"); - } - return concurrent; - } - throw exception; - } + if (item == null) throw conflict("系统费用项目FBA头程运费未初始化"); + if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用"); + return item; } private static java.math.BigDecimal effectiveUnitPrice(InboundFeeEntity fee) { diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 9390524ef..d5176a8dc 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -463,6 +463,15 @@ CALL phase2_publish_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_publishe DROP PROCEDURE IF EXISTS phase2_publish_add_index; +-- 系统计费使用的固定公共费用项目;不属于费用类型.xls 的110条受控种子。 +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, + description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at) +SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL', 'FBA头程运费', + 0, 0, 0, 1, 0, '系统计费发布使用的固定应收费用项目', 'ACTIVE', + 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); + DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema; DELIMITER $$ CREATE PROCEDURE phase2_publish_assert_fee_schema() @@ -483,6 +492,10 @@ BEGIN OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 + OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验'; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 84a04362a..3fab6fc74 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; @@ -62,6 +64,13 @@ class ContainerControllerContractTest { .doesNotContain("accountId"); } + @Test + @DisplayName("multipart 文本字段使用请求参数绑定") + void multipartScalarFieldsUseRequestParameters() { + assertMultipartBinding("uploadLogisticsDocument", 4, 5, 6); + assertMultipartBinding("uploadClearance", 3, 4, 5); + } + private static void assertPermission(String methodName, String authority) { Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) @@ -70,4 +79,16 @@ class ContainerControllerContractTest { .isEqualTo("hasAuthority('" + authority + "')"); } + private static void assertMultipartBinding(String methodName, int fileIndex, + int versionIndex, int reasonIndex) { + Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getParameters()[fileIndex].getAnnotation(RequestPart.class)).isNotNull(); + assertThat(method.getParameters()[versionIndex].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[versionIndex].getAnnotation(RequestPart.class)).isNull(); + assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestPart.class)).isNull(); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 4ac94979b..3a40a3057 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -13,6 +13,8 @@ import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -89,6 +91,18 @@ class LoadingListControllerContractTest { assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); } + @Test + void multipartScalarFieldsUseRequestParameters() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration", + AuthenticatedUser.class, long.class, long.class, String.class, + MultipartFile.class, int.class, String.class); + assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull(); + assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull(); + assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 05c24592f..e1451a307 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -22,6 +22,9 @@ class FeeItemMigrationSqlContractTest { .contains("LEFT JOIN tmp_fee_item_seed matched_seed") .contains("matched_seed.fee_code = NULLIF(TRIM(f.fee_code), '')") .contains("matched_seed.trade_scope = f.trade_scope") + .contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'") + .contains("fee_code = 'FBA_HEAD_FREIGHT'") + .contains("warehouse_receivable = 1") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_code") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic") .doesNotContain("OR (SELECT COUNT(*) FROM tmp_fee_item_seed") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 6971a2f77..4c6d3a06a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -183,13 +183,10 @@ class FeeManagementServiceTest { inbound.setCompanyName("客户A有限公司"); when(repository.findInbound(10001L, 21L)).thenReturn(inbound); when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); - when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); - doAnswer(invocation -> { - FeeItemEntity value = invocation.getArgument(0); - assertThat(value.getTenantId()).isZero(); - value.setId(11L); - return null; - }).when(repository).insertItem(any()); + FeeItemEntity freightItem = item(true, false); + freightItem.setId(11L); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); InboundFeeEntity fee = new InboundFeeEntity(); fee.setId(501L); @@ -226,6 +223,26 @@ class FeeManagementServiceTest { assertThat(saved.getSettlementIncluded()).isTrue(); } + @Test + @DisplayName("系统头程运费项目缺失时拒绝运行期补建") + void rejectsMissingSystemFreightItem() { + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); + fee.setInboundId(21L); + fee.setFeeStatus("SENT"); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + assertThatThrownBy(() -> details.publishFreight( + 10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("FBA头程运费未初始化"); + verify(repository, never()).insertItem(any()); + } + @Test @DisplayName("并发发布同一计费快照时返回已生成的运费明细") void reusesConcurrentFreightDetail() { diff --git a/scripts/README.md b/scripts/README.md index 16c309270..21fec795b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,7 @@ | 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 | |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | -| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库和权限数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务和客户侧权限链路 | +| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | | Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 | 执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 40a54a2ef..2e692db46 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -16,6 +16,20 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over export function normalizeModuleOrder(modules) { const normalized = [...modules] + if (normalized.includes('fee-items')) { + const prerequisites = ['merchant-qualification-fixture', 'orders'] + const feeItemsIndex = normalized.indexOf('fee-items') + const ordered = prerequisites.every((name, index) => { + const prerequisiteIndex = normalized.indexOf(name) + const previousIndex = index === 0 ? -1 : normalized.indexOf(prerequisites[index - 1]) + return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex + }) + if (!ordered) { + const requested = normalized.filter((name) => !prerequisites.includes(name)) + requested.splice(requested.indexOf('fee-items'), 0, ...prerequisites) + normalized.splice(0, normalized.length, ...requested) + } + } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 3854e9fa2..7459def79 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -7,6 +7,8 @@ - `system.mjs` - `inbound-weight-ocr.mjs` +`fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。 + 模块只需导出: ```js diff --git a/scripts/admin-test-env-modules/fee-items-contract.test.mjs b/scripts/admin-test-env-modules/fee-items-contract.test.mjs index e34bc070a..f418061a3 100644 --- a/scripts/admin-test-env-modules/fee-items-contract.test.mjs +++ b/scripts/admin-test-env-modules/fee-items-contract.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs' const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8') @@ -29,5 +30,18 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', () test('baseline checks run only after stale acceptance cleanup', () => { assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)') - < source.indexOf("'分页总数110且默认20条对应6页'")) + < source.indexOf("'正式目录111条且默认20条对应6页'")) +}) + +test('formal baseline includes the system FBA freight item in addition to the 110 controlled items', () => { + assert.match(source, /const BASELINE_COUNT = 111/) + assert.match(source, /const INTERNATIONAL_COUNT = 27/) + assert.match(source, /'FBA_HEAD_FREIGHT'/) +}) + +test('fee-items injects its same-marker merchant and order fixtures for focused execution', () => { + const expected = ['merchant-qualification-fixture', 'orders', 'fee-items'] + assert.deepEqual(normalizeModuleOrder(['fee-items']), expected) + assert.deepEqual(normalizeModuleOrder(['fee-items', 'orders']), expected) + assert.deepEqual(parseArguments(['--modules', 'fee-items']).modules, expected) }) diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index 4f488ebe4..72e3d9d9d 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -2,6 +2,9 @@ const MODULE = 'fee-items' const SCENARIO = 'platform-public-fee-items' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const BASE = '/api/platform/v1/fee-items' +const BASELINE_COUNT = 111 +const DOMESTIC_COUNT = 84 +const INTERNATIONAL_COUNT = 27 const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' @@ -42,7 +45,10 @@ function payload(suffix, overrides = {}) { } } function expectedCodes() { - return Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`) + return [ + 'FBA_HEAD_FREIGHT', + ...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`), + ].sort() } export function isAcceptanceFeeItem(row) { return /^ACPT_\d+$/.test(String(row?.feeCode || '')) @@ -84,8 +90,8 @@ export async function run(context) { const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 })) const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse, 'page=1,size=20', OPTIONS) - context.assert(MODULE, '分页总数110且默认20条对应6页', initialPageResponse, - records(initialPage).length === 20 && Number(initialPage?.total) === 110 + context.assert(MODULE, '正式目录111条且默认20条对应6页', initialPageResponse, + records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT && Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1, `records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse, @@ -97,13 +103,13 @@ export async function run(context) { const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC') const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL') const actualCodes = initialRows.map((row) => row.feeCode).sort() - context.assert(MODULE, '初始化110条全部属于平台且状态ACTIVE', initial.response, - initialRows.length === 110 && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), + context.assert(MODULE, '正式目录111条全部属于平台且状态ACTIVE', initial.response, + initialRows.length === BASELINE_COUNT && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), `rows=${initialRows.length}, platform=${initialRows.filter((row) => id(row.tenantId) === '0').length}, active=${initialRows.filter((row) => row.status === 'ACTIVE').length}`, OPTIONS) - context.assert(MODULE, '国内84条、国际26条范围正确', initial.response, - domestic.length === 84 && international.length === 26, + context.assert(MODULE, '国内84条、国际27条范围正确', initial.response, + domestic.length === DOMESTIC_COUNT && international.length === INTERNATIONAL_COUNT, `DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS) - context.assert(MODULE, '初始化编码完整覆盖 FEE_TYPE_001..FEE_TYPE_110', initial.response, + context.assert(MODULE, '正式编码完整覆盖110条受控项目及系统头程运费', initial.response, actualCodes.join(',') === expectedCodes().join(','), `codes=${actualCodes.length}, first=${actualCodes[0] || 'missing'}, last=${actualCodes.at(-1) || 'missing'}`, OPTIONS) @@ -115,7 +121,7 @@ export async function run(context) { Boolean(category) && records(categoryPage).length > 0 && records(categoryPage).every((row) => row.category === category), `records=${records(categoryPage).length}`, OPTIONS) - for (const [scope, expected] of [['DOMESTIC', 84], ['INTERNATIONAL', 26]]) { + for (const [scope, expected] of [['DOMESTIC', DOMESTIC_COUNT], ['INTERNATIONAL', INTERNATIONAL_COUNT]]) { const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 })) const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS) context.assert(MODULE, `${scope} 筛选数量和范围正确`, response, @@ -135,8 +141,8 @@ export async function run(context) { const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 })) const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS) - context.assert(MODULE, 'ACTIVE 筛选返回110条且无停用记录', activeResponse, - Number(activePage?.total) === 110 && records(activePage).every((row) => row.status === 'ACTIVE'), + context.assert(MODULE, 'ACTIVE 筛选返回111条且无停用记录', activeResponse, + Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { @@ -266,8 +272,8 @@ export async function run(context) { `feeItemId=${feeItemId}`, OPTIONS) } const final = await listAll(context) - context.assert(MODULE, '清理后恢复110条基线且无临时编码或名称', final.response, - final.rows.length === 110 && !final.rows.some((row) => row.feeCode === createPayload.feeCode + context.assert(MODULE, '清理后恢复111条正式基线且无临时编码或名称', final.response, + final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode || String(row.feeTypeName).includes(suffix)), `rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS) context.createdResources.feeItems = { diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 4b8f6254c..1f597555b 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { run } from './finance-full-fixtures.mjs' +import { buildContainerCostBody, run } from './finance-full-fixtures.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') @@ -204,6 +204,13 @@ test('fixture creates four real receivable manual sources before payable source' }) test('container cost uses loading plan identity and verifies snapshot fields before Finance', () => { + const body = buildContainerCostBody('RUN-1', '2088073097986560001', + { loadingPlanId: '2088073275689709570' }, '2088073280000000001') + assert.equal(body.customerId, '2088073097986560001') + assert.equal(body.containerId, '2088073275689709570') + assert.equal(JSON.parse(JSON.stringify(body)).customerId, '2088073097986560001') + assert.match(source, /customerId: String\(customerId\)/) + assert.doesNotMatch(source, /customerId: Number\(customerId\)/) assert.match(source, /containerId: String\(container\.loadingPlanId\)/) assert.doesNotMatch(source, /containerId: Number\(container\.loadingPlanId\)/) assert.match(source, /smart-loading\/plans\/\$\{encodeURIComponent\(container\.loadingPlanId\)\}/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 37b68e495..d0f55ac86 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -262,13 +262,17 @@ async function createMarkerContainer(context, resource) { } } -async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { - if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) - const body = { customerId: Number(customerId), costType: 'CONTAINER_SHARED', orderId: null, +export function buildContainerCostBody(marker, customerId, container, voucherFileId) { + return { customerId: String(customerId), costType: 'CONTAINER_SHARED', orderId: null, // Finance names this field containerId, but its Firstleg cost snapshot contract // resolves it as the loading plan ID, not the physical container booking ID. - containerId: String(container.loadingPlanId), expenseName: `${context.marker}-整柜成本`, amount: 300, - currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker } + containerId: String(container.loadingPlanId), expenseName: `${marker}-整柜成本`, amount: 300, + currency: 'CNY', voucherFileIds: [voucherFileId], remark: marker } +} + +async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { + if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) + const body = buildContainerCostBody(context.marker, customerId, container, voucherFileId) const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body) if (response?.body?.code === 'FIN_400') { const first = snapshotInput?.items?.[0] diff --git a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs index f96e5dcca..79b0525ae 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs @@ -1,7 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { run, values } from './finance-settlement-full.mjs' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { receiptBody, run, values } from './finance-settlement-full.mjs' +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'finance-settlement-full.mjs'), 'utf8') const response = (data, http = 200, code = '200') => ({ http, body: { code, data } }) function fakeContext(overrides = {}) { @@ -63,6 +68,16 @@ test('values helper supports nested arrays while run consumes frozen top-level a assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }]) }) +test('receipt body preserves 19-digit customer and voucher IDs through JSON', () => { + const body = receiptBody({ marker: 'RUN-1' }, '2088073097986560001', + '2088073285653299201', [{ billId: 13, amount: 1 }]) + const roundTrip = JSON.parse(JSON.stringify(body)) + assert.equal(roundTrip.customerId, '2088073097986560001') + assert.deepEqual(roundTrip.voucherFileIds, ['2088073285653299201']) + assert.match(source, /customerId: String\(customerId\)/) + assert.doesNotMatch(source, /customerId: Number\(customerId\)/) +}) + test('run 使用 receipts 完成应收部分、全额及多单分配,使用 transactions 完成 AP', async () => { const context = fakeContext() await run(context) diff --git a/scripts/admin-test-env-modules/finance-settlement-full.mjs b/scripts/admin-test-env-modules/finance-settlement-full.mjs index 53fb548b9..226e98d76 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full.mjs @@ -25,8 +25,8 @@ function gap(context, name, scope, reason, category = 'finance-settlement-fixtur context.addCoverageGap?.(MODULE, OPTIONS.scenario, scope, reason, category, { marker: context.marker }) } -function receiptBody(context, customerId, voucherFileId, allocations) { - return { customerId: Number(customerId), receivedDate: new Date().toISOString().slice(0, 10), +export function receiptBody(context, customerId, voucherFileId, allocations) { + return { customerId: String(customerId), receivedDate: new Date().toISOString().slice(0, 10), amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [voucherFileId], remark: context.marker, allocations } } diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index bd1ce3870..ba65e3d2b 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -5,11 +5,17 @@ import test from 'node:test' const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8') test('retains every ocean booking and truck appointment list status through real APIs', () => { + assert.match(source, /const OCEAN_BASE = ['"]\/api\/platform\/v1\/ocean-bookings['"]/) + assert.doesNotMatch(source, /\/api\/platform\/v1\/first-leg\/ocean-bookings/) for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED', 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED', 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) assert.match(source, /change-request/) assert.match(source, /\/reschedule\?plannedLoadingAt=/) assert.match(source, /page=1&size=100&status=/) + assert.match(source, /customerId: oceanBooking\.customerId/) + assert.match(source, /warehouseId: oceanBooking\.warehouseId/) + assert.doesNotMatch(source, /customerId: ['"]10001['"]/) + assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/) assert.doesNotMatch(source, /DELETE/) }) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 3c5328b1e..96f7214e5 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -1,6 +1,6 @@ const MODULE = 'manual-state-ocean-trucking' const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true } -const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings' +const OCEAN_BASE = '/api/platform/v1/ocean-bookings' const TRUCK_BASE = '/api/platform/v1/truck-appointments' const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED'] const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE', @@ -71,10 +71,12 @@ async function buildOcean(context, template, status, index) { throw new Error(`unsupported ocean status ${status}`) } -function truckPayload(context, oceanBookingId, index) { +function truckPayload(context, oceanBooking, index) { const value = suffix(context, index) - return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null, - warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户', + return { outboundOrderId: null, oceanBookingId: oceanBooking.id, + containerBookingId: null, loadingPlanId: null, + warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId, + customerName: oceanBooking.customerName, carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004', driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`, storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006', @@ -84,8 +86,8 @@ function truckPayload(context, oceanBookingId, index) { receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } } -async function createTruck(context, oceanBookingId, index) { - const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index)) +async function createTruck(context, oceanBooking, index) { + const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, index)) const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) return value @@ -99,8 +101,8 @@ async function truckAction(context, value, endpoint) { return next } -async function buildTruck(context, oceanBookingId, status, index) { - let value = await createTruck(context, oceanBookingId, index) +async function buildTruck(context, oceanBooking, status, index) { + let value = await createTruck(context, oceanBooking, index) if (status === 'DRAFT') return value if (status === 'RESCHEDULED') { const response = await post(context, @@ -139,16 +141,23 @@ export async function run(context, matrix = context.createdResources.manualState for (const [index, status] of OCEAN_STATUSES.entries()) { const value = await buildOcean(context, template, status, index) const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status - matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` } + matrix.pages.oceanBookings.fixtures[status] = { + id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}`, + customerId: id(value?.oceanBooking?.customerId), customerName: value?.oceanBooking?.customerName, + warehouseId: id(value?.oceanBooking?.warehouseId), + } context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status, `id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS) await verify(context, OCEAN_BASE, status, resourceId) } - const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id + const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED + if (!releasedOcean.id || !releasedOcean.customerId || !releasedOcean.warehouseId) { + throw new Error('RELEASED 订舱缺少拖车所需的客户或仓库归属') + } matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} } for (const [index, status] of TRUCK_STATUSES.entries()) { - const value = await buildTruck(context, releasedOceanId, status, index) + const value = await buildTruck(context, releasedOcean, status, index) const resourceId = id(value?.id) matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` } context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status, diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 367f6c7b6..0481250aa 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -56,7 +56,7 @@ export async function run(context) { context.apiSuccess(MODULE, '冲突后回查订舱', afterStale, '', OPTIONS) context.assert(MODULE, '订舱冲突未部分更新', afterStale, afterData?.oceanBooking?.shippingCompanyName === edited.shippingCompanyName, `shippingCompanyName=${afterData?.oceanBooking?.shippingCompanyName || 'missing'}`, OPTIONS) let version = Number(afterData?.oceanBooking?.version) - const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d } + const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d } await action('提交订舱', 'submit', version, 'marker submit') await action('确认订舱', 'confirm', version, 'marker confirm') const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) @@ -79,7 +79,7 @@ export async function run(context) { expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`, voyageNo: `RV${suffix}`, estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', reason: 'release', - }, { 'Idempotency-Key': `${context.marker}-release` }) + }, { 'Idempotency-Key': `${context.marker}-ocean-release` }) const releasedDetail = data(released) context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS) version = Number(releasedDetail?.oceanBooking?.version) @@ -89,12 +89,48 @@ export async function run(context) { fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName) && fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId), `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) - context.createdResources.oceanBooking = { + let downstreamBooking = { id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker, containerBookingId: allocatedContainerBookingId || null, loadingPlanId: null, finalStatus: fd?.oceanBooking?.status || null, } + const flowOceanBookingId = id(containerFlow?.oceanBookingId) + if (flowOceanBookingId && id(containerFlow?.containerBookingId) && id(containerFlow?.loadingPlanId)) { + const flowPath = `${BASE}/${flowOceanBookingId}` + let flowResponse = await request(context, 'GET', flowPath) + let flow = data(flowResponse) + context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, + `oceanBookingId=${flowOceanBookingId}`, OPTIONS) + const allocationMatches = flow?.containerAllocations?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, + allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), + `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { + const flowRelease = await request(context, 'POST', `${flowPath}/release`, { + expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, + vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) + context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) + flowResponse = await request(context, 'GET', flowPath) + flow = data(flowResponse) + } + context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, + flow?.oceanBooking?.status === 'RELEASED' + && flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) + if (flow?.oceanBooking?.status === 'RELEASED') { + downstreamBooking = { + id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, + containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), + finalStatus: 'RELEASED', + } + } + } + context.createdResources.oceanBooking = downstreamBooking const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`) } diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 1fea56218..da8c65d01 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -31,7 +31,24 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT assert.match(source, new RegExp(`'${status}'`)) } assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.match(source, /path\.replace\('\/approve', '\/reject'\)/) + assert.match(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/) +}) + +test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => { + assert.match(source, /const shipmentId = `MANUAL-/) + assert.match(source, /const referenceId = `STATUS-/) + assert.match(source, /const upcNo =/) + assert.match(source, /upcNo, shipmentId, referenceId/) +}) + +test('测量状态截停发生在正确的 HTTP 边界', () => { + assert.match(source, /includes\(summary\.mainStatus\)/) + assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) + assert.match(source, /target === 'MEASURING' && data\(response\)\?\.processStatus === 'MEASURING'/) + assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) + assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) + assert.match(source, /measurements\/reject/) + assert.match(source, /expectedVersion: version/) }) test('入库不可独立保留状态显式阻断', () => { @@ -42,7 +59,8 @@ test('入库不可独立保留状态显式阻断', () => { assert.match(source, /processStatus=WAIT_SPLIT/) assert.match(source, /查询存量 WAIT_SPLIT 样本/) assert.match(source, /没有作废入库单 API/) - assert.match(source, /context\.assert\(MODULE, `入库列表保留 \$\{status\}`/) + assert.match(source, /recordCoverageGap\(context, `入库列表保留 \$\{status\}`/) + assert.match(source, /context\.addCoverageGap\?\./) }) test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => { diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 37b291e3c..8a27e7114 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -29,6 +29,13 @@ function key(context, operation) { return `${context.marker}-manual-${operation} function query(value) { return encodeURIComponent(String(value)) } function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function recordCoverageGap(context, name, reason, category = 'fixture-unavailable') { + context.addCoverageGap?.(MODULE, OPTIONS.scenario, name, reason, category, { marker: context.marker }) + context.addStep(MODULE, `${name}(coverage gap 已登记)`, null, true, reason, { + scenario: OPTIONS.scenario, deploymentBlocker: false, + }) +} + async function merchantRequest(context, method, path, body, token, headers = {}) { const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers } const payload = body === undefined ? undefined : JSON.stringify(body) @@ -74,6 +81,10 @@ async function loadFixtureInputs(context, token) { function orderPayload(context, fixture, target) { const sample = `${context.marker}-${target}` + const shipmentId = `MANUAL-${target}-${context.marker}`.slice(0, 64) + const referenceId = `STATUS-${target}-${context.marker}`.slice(0, 64) + const upcNo = `${String(context.marker).replace(/\D/g, '').slice(-11)}${ORDER_TARGETS.findIndex(([status]) => status === target) + 1}` + .slice(-12).padStart(12, '0') return { customerId: null, customerName: fixture.customerName, transportMode: 'SEA', tradeTerm: 'FOB', destinationCountryCode: fixture.destination.countryCode || 'US', @@ -85,7 +96,7 @@ function orderPayload(context, fixture, target) { carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false, destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode, - shipmentId: `MANUAL-${target}-${context.marker}`.slice(0, 64), referenceId: `STATUS-${target}-${context.marker}`.slice(0, 64), + shipmentId, referenceId, sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000', email: 'it-test@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: `${sample} 测试地址`, postalCode: '518000' }, @@ -94,6 +105,7 @@ function orderPayload(context, fixture, target) { customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10, volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD', dangerousGoods: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64), + upcNo, shipmentId, referenceId, unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10, material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }], } @@ -132,7 +144,10 @@ async function waitForInbound(context, fixture, orderId) { for (let attempt = 0; attempt < 20; attempt += 1) { const response = await context.request('GET', path) const summary = data(response)?.summary - if (ok(response) && summary?.inboundId) return { response, summary } + if (ok(response) && summary?.inboundId + && ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT'].includes(summary.mainStatus)) { + return { response, summary } + } if (attempt < 19) await wait(500) } return { response: null, summary: null } @@ -157,16 +172,31 @@ async function prepareInboundStatus(context, target) { const inboundId = id(order.inboundId) const tenantId = id(order.tenantId) if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`) + const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}` context.request = async (method, path, body, token, headers) => { const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) - const isApprove = path.includes(`/inbounds/${inboundId}/measurements/approve`) - if (target === 'MEASURING' && isSubmit) throw stop - if (target === 'MEASURE_PENDING' && isApprove) throw stop - if (target === 'MEASURE_REJECTED' && isApprove) { - const response = await originalRequest.call(context, method, path.replace('/approve', '/reject'), - { ...body, reason: `${fixtureMarker} 保留审核驳回样本` }, token, headers) - if (!ok(response)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${response?.http}, code=${response?.body?.code}`) + const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`) + if (isMeasurementQuery) { + const response = await originalRequest.call(context, method, path, body, token, headers) + if (target === 'MEASURING' && data(response)?.processStatus === 'MEASURING') throw stop + return response + } + if (target === 'MEASURE_PENDING' && isSubmit) { + const response = await originalRequest.call(context, method, path, body, token, headers) + if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`) + throw stop + } + if (target === 'MEASURE_REJECTED' && isSubmit) { + const submitted = await originalRequest.call(context, method, path, body, token, headers) + if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`) + const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token) + const version = Number(data(latest)?.summary?.version) + const rejected = await originalRequest.call(context, 'POST', + path.replace('/measurement/submit', '/measurements/reject'), + { measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` }, + token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) }) + if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}`) throw stop } return originalRequest.call(context, method, path, body, token, headers) @@ -276,11 +306,12 @@ export async function run(context, matrix = context.createdResources.manualState } } - const legacyResponse = await context.request('GET', - `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) - const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS) - const legacyInbound = rows(legacyPage)[0] - if (legacyInbound?.id && legacyInbound?.orderId) { + if (!result.inbounds.WAIT_SPLIT) { + const legacyResponse = await context.request('GET', + `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) + const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS) + const legacyInbound = rows(legacyPage)[0] + if (legacyInbound?.id && legacyInbound?.orderId) { const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId, status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null } result.inbounds.WAIT_SPLIT = sample @@ -291,6 +322,7 @@ export async function run(context, matrix = context.createdResources.manualState matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = { id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`, } + } } const blockers = { @@ -300,7 +332,7 @@ export async function run(context, matrix = context.createdResources.manualState VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API', } for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) { - context.assert(MODULE, `入库列表保留 ${status}`, null, false, blockers[status], OPTIONS) + recordCoverageGap(context, `入库列表保留 ${status}`, blockers[status]) } const unifiedBlockers = { LOADING: '需要分柜、排柜及 PDA 专项独立样本', @@ -308,7 +340,7 @@ export async function run(context, matrix = context.createdResources.manualState DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API', } for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) { - context.assert(MODULE, `统一订单列表保留 ${status}`, null, false, unifiedBlockers[status], OPTIONS) + recordCoverageGap(context, `统一订单列表保留 ${status}`, unifiedBlockers[status]) } context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true, diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index bd45df178..d8e62cd86 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -39,14 +39,15 @@ export async function run(context) { if (overview?.pda) { const expectsPhotos = fixture === context.createdResources?.phase3Loading && Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0 + const requiredFields = ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] + const missingFields = requiredFields.filter((field) => overview.pda[field] === undefined) context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse, - ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] - .every((field) => overview.pda[field] !== undefined) + missingFields.length === 0 && Array.isArray(overview.pda.photoCounts) && Array.isArray(overview.pda.photos) && Array.isArray(overview.pda.exceptionReasons) && (!expectsPhotos || overview.pda.photos.length > 0), - `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS) + `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos?.length ?? 'not-array'}, missingFields=${missingFields.join('|') || 'none'}, photoCountsArray=${Array.isArray(overview.pda.photoCounts)}, photosArray=${Array.isArray(overview.pda.photos)}, exceptionReasonsArray=${Array.isArray(overview.pda.exceptionReasons)}`, OPTIONS) for (const [index, photo] of overview.pda.photos.entries()) { context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse, ['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined), diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index da25e570c..1f55b18f8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -809,7 +809,8 @@ export async function run(context) { missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version), postLoadedFileId, - loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, + oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId, + canonicalPdaTaskId: canonicalTaskId, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified, 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 new file mode 100644 index 000000000..8b6222262 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8') + +test('each remainder follows confirmed booking before scoped smart-loading candidates', () => { + assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/) + assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/submit/) + assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/confirm/) + assert.match(source, /containerAllocations\?\.\[0\]\?\.containerBookingId/) + assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) + assert.match(source, /`\$\{SMART\}\/plans`, \{ bookingId: containerId/) + assert.doesNotMatch(source, /request\(context, 'POST', CONTAINERS/) + assert.doesNotMatch(source, /candidates\?warehouseId=/) +}) + +test('PDA opens only after a real container number and uses stable plan identity', () => { + const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`") + const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`") + const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`") + assert.ok(pushIndex >= 0 && transportIndex > pushIndex && taskIndex > transportIndex) + assert.match(source, /containerNo: realContainerNo/) + assert.match(source, /id\(task\?\.loadingPlanId\) === planId/) + assert.match(source, /id\(task\?\.containerBookingId\) === containerId/) + assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) +}) + +test('booking freezes the source customer channel and warehouse scope', () => { + assert.match(source, /customerId: source\.tenantId/) + assert.match(source, /channelCode: source\.channelCode/) + assert.match(source, /warehouseId: source\.warehouseId/) + assert.match(source, /containerRequirements: \[\{ containerType: '40HQ'/) +}) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 6d1612664..558140976 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -116,25 +116,52 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) { && activeMatches && quantityConserved(tree) } -function containerPayload(marker, sequence, warehouseId, candidate) { +function bookingPayload(marker, sequence, source) { const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) return { - containerNo: `SPLIT-${suffix}`.slice(0, 64), cabinetNo: `SI-${suffix}`.slice(0, 64), - warehouseId, warehouseName: null, billOfLadingNo: `BL-${suffix}`.slice(0, 64), - vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), - containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), + customerId: source.tenantId, customerName: source.customerName, + channelCode: source.channelCode, channelName: source.channelName, + warehouseId: source.warehouseId, warehouseName: source.warehouseName, + shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), + bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - destinationWarehouseCode: candidate.destinationWarehouseCode || null, - destinationWarehouseName: candidate.destinationWarehouseName || null, - capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `SPLIT-TRUCK-${suffix}`.slice(0, 128), - loadingAt: '2026-09-10T08:00:00', estimatedSailingAt: '2026-09-11T08:00:00', - estimatedPortArrivalAt: '2026-09-28T08:00:00', estimatedWarehouseArrivalAt: '2026-09-29T08:00:00', + placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: marker, + parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` }, + { partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` }, + { partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, + maxWeightKg: 100000, quantity: 1 }], } } async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state - const candidatesResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`) + const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) + const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { + expectedVersion: 0, data: bookingPayload(context.marker, sequence, state), + }, { 'Idempotency-Key': `${operationBase}-booking-create` }) + const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS) + const oceanBookingId = id(bookingCreated?.oceanBooking?.id) + if (!oceanBookingId) return null + context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId) + + const bookingSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/submit`, { + expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${operationBase}-booking-submit` }) + const bookingSubmitted = context.apiSuccess(MODULE, `提交 ${sourceOrderNo} 的订舱`, bookingSubmit, '', OPTIONS) + const bookingConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/confirm`, { + expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${operationBase}-booking-confirm` }) + const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的订舱并自动分配柜次`, bookingConfirm, '', OPTIONS) + const containerId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId) + context.assert(MODULE, `${sourceOrderNo} 订舱确认后取得稳定柜次`, bookingConfirm, + isPositiveId(containerId) && id(bookingConfirmed?.oceanBooking?.id) === oceanBookingId, + `oceanBookingId=${oceanBookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS) + if (!containerId) return null + context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) + + const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}` + const candidatesResponse = await context.request('GET', candidatesPath) const candidates = context.apiSuccess(MODULE, `查询余量 ${sourceOrderNo} 的真实装柜候选`, candidatesResponse, '', OPTIONS) const candidate = Array.isArray(candidates) ? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId) @@ -145,20 +172,12 @@ async function loadOneFragment(context, state) { `orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!candidate?.inboundId || !candidate?.inboundDetailId) return null - const createContainer = await request(context, 'POST', CONTAINERS, - containerPayload(context.marker, sequence, warehouseId, candidate)) - const container = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建隔离柜次`, createContainer, '', OPTIONS) - const containerId = id(container?.id) - if (!containerId) return null - context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) - const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS) const planId = id(plan?.id) if (!planId) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId) - const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) const assignPayload = { inboundId: id(candidate.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(plan.version), operationId: `${operationBase}-assign`, @@ -180,16 +199,31 @@ async function loadOneFragment(context, state) { const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` }) - const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的真实 PDA 任务`, push, '', OPTIONS) - if (pushed?.status !== 'LOADING') return null + const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的预装计划`, push, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 未补真实柜号时计划保持 EDITING`, push, + pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS) + if (pushed?.status !== 'EDITING') return null + + const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const containerDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的自动柜次`, containerDetailResponse, '', OPTIONS) + const realContainerNo = `SPLIT-${operationBase.replace(/[^A-Za-z0-9]/g, '').slice(-18)}`.slice(0, 64) + const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${encodeURIComponent(containerId)}/transport`, { + containerNo: realContainerNo, voyageNo: `V-${sequence}`, + version: Number(containerDetail?.version), reason: context.marker, + }) + const transported = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 补充真实柜号并开放 PDA`, transportResponse, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 真实柜号已绑定到稳定柜次`, transportResponse, + transported?.containerNo === realContainerNo, + `containerBookingId=${containerId}, containerNo=${transported?.containerNo || 'missing'}`, OPTIONS) const taskResponse = await context.request('GET', - `${PDA}/tasks?containerNo=${encodeURIComponent(container.containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS) const taskId = id(task?.id) const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id)) context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse, - Boolean(taskId) && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1 + Boolean(taskId) && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId + && task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1 && Number(pickItem?.pickedPackageCount) === 0, `taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS) if (!taskId || !pickItem?.id) return null @@ -237,13 +271,13 @@ async function loadOneFragment(context, state) { const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId)) const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS) - const candidatesAfter = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`) + const candidatesAfter = await context.request('GET', candidatesPath) const candidateRowsAfter = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 不可重复消费`, candidatesAfter, '', OPTIONS) context.assert(MODULE, `来源 ${sourceOrderNo} 同时最多成功拆分一次`, candidatesAfter, Array.isArray(candidateRowsAfter) && !candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId), `sourceOrderId=${sourceOrderId}, stillCandidate=${Array.isArray(candidateRowsAfter) && candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId)}`, OPTIONS) - return { tree, treeResponse, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) } + return { tree, treeResponse, oceanBookingId, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) } } export async function run(context) { @@ -263,10 +297,15 @@ export async function run(context) { const inbound = context.apiSuccess(MODULE, '读取本轮来源入库单', inboundResponse, `tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS) const orderId = id(inbound?.summary?.orderId) + const channelCode = id(inbound?.summary?.channelCode || fixture?.channelCode) + const channelName = inbound?.summary?.channelName || fixture?.channelName || channelCode + const warehouseName = inbound?.summary?.warehouseName || fixture?.warehouseName + const customerName = inbound?.summary?.customerName || `验收客户-${tenantId}` context.assert(MODULE, '来源入库单归属本轮客户与根订单', inboundResponse, - isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId, - `orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}`, OPTIONS) - if (!orderId) return + isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId + && Boolean(channelCode && channelName && warehouseName), + `orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) + if (!orderId || !channelCode || !channelName || !warehouseName) return const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId)) const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse, @@ -340,7 +379,8 @@ export async function run(context) { const result = await loadOneFragment(context, { tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo, - warehouseId, sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix, + customerName, channelCode, channelName, warehouseId, warehouseName, + sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix, }) if (!result?.tree) return tree = result.tree diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 8f8116499..61c00b559 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -277,6 +277,12 @@ test('downstream producers prefer the phase3Loading container fixture', () => { assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/) assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/) assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/) + assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/) + assert.match(ocean, /ocean-flow-release/) + assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/) + assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) + assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/) + assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/) }) test('default acceptance order verifies loading evidence before finalizing LOADED', () => { diff --git a/scripts/client-notifications-contract.test.mjs b/scripts/client-notifications-contract.test.mjs new file mode 100644 index 000000000..18f361df9 --- /dev/null +++ b/scripts/client-notifications-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const scriptsDir = dirname(fileURLToPath(import.meta.url)) +const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8') + +test('Client full acceptance executes the real notification inbox flow', () => { + assert.match(source, /await isolationModule\(\)\s+await notificationInboxModule\(\)\s+await accountSecurityModule\(\)/) + assert.match(source, /async function notificationInboxModule\(\)/) + for (const permission of [ + 'merchant:notification:view', + 'merchant:notification:read', + 'merchant:notification:read-all', + ]) assert.match(source, new RegExp(`['"]${permission}['"]`)) +}) + +test('notification delivery targets only the newly registered customer owner', () => { + assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/) + assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/) + assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/) + assert.match(source, /await sendMessage\(1\)/) + assert.match(source, /await sendMessage\(2\)/) + assert.match(source, /other client cannot read first message detail/) + assert.match(source, /other client cannot see acceptance messages in inbox/) + assert.match(source, /\['A0403', 'A0404'\]\.includes\(String\(json\?\.code\)\)/) +}) + +test('notification flow covers unread, single read, read-all, and cleanup', () => { + for (const contract of [ + /status=UNREAD/, + /status=READ/, + /\/unread-count/, + /\$\{firstMessageId\}\/read/, + /\/read-all/, + /single-message read is idempotent/, + /cleanup keeps acceptance messages read/, + /cleanup leaves no unread acceptance messages/, + /targetStatus:\s*['"]DISABLED['"]/, + /retainedAsRead:\s*true/, + ]) assert.match(source, contract) +}) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 51c8b8ed8..034057e97 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -70,6 +70,8 @@ const report = { channelCode: null, roleId: null, testMembershipId: null, + notificationTemplateId: null, + notificationMessageIds: [], orderIds: [], }, steps: [], @@ -92,6 +94,7 @@ try { await rbacAndMemberModule() await orderLifecycleModule() await isolationModule() + await notificationInboxModule() await accountSecurityModule() report.success = report.steps.every((step) => step.passed) } catch (error) { @@ -547,7 +550,7 @@ async function rbacAndMemberModule() { assertSetEqual( visibleClientMenus(arrayData(ownerPermissions.json.data)), ['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view', - 'company:role:list', 'dashboard:view', 'order:first-leg:list'], + 'company:role:list', 'dashboard:view', 'notification:inbox:view', 'order:first-leg:list'], 'owner visible Client menus', ) @@ -1340,6 +1343,240 @@ async function isolationModule() { }, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code })) } +async function notificationInboxModule() { + const module = 'notification-inbox' + const templateBase = '/api/platform/v1/notification-templates' + const inboxBase = '/api/merchant/v1/notifications' + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-24).toUpperCase() + const templateCode = `CLIENT_INBOX_${suffix}`.slice(0, 64) + const category = 'CLIENT_ACCEPTANCE' + let template = null + + try { + const createTemplate = await apiStep(module, 'platform creates client inbox template', { + method: 'POST', path: templateBase, token: platformToken, + body: { + templateCode, + templateName: `客户消息闭环-${marker}`.slice(0, 128), + category, + titleTemplate: '客户消息验收 {{sequence}}', + bodyTemplate: '客户 {{marker}} 的第 {{sequence}} 条真实站内消息。', + variableWhitelist: ['marker', 'sequence'], + }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.id, 'notification template id') + assertEqual(json.data?.status, 'DRAFT', 'notification template status') + assertEqual(json.data?.channel, 'IN_APP', 'notification template channel') + }, ({ json }) => ({ templateId: json.data.id, templateCode: json.data.templateCode, status: json.data.status })) + template = createTemplate.json.data + report.resources.notificationTemplateId = template.id + + const activateTemplate = await apiStep(module, 'platform activates client inbox template', { + method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken, + body: { targetStatus: 'ACTIVE', version: template.version }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'notification template status') + }, ({ json }) => ({ templateId: json.data.id, status: json.data.status, version: json.data.version })) + template = activateTemplate.json.data + + await apiStep(module, 'client normalizes existing inbox messages to read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number(json.data?.updatedCount) >= 0, 'updatedCount must be non-negative') + }, ({ json }) => ({ updatedCount: json.data.updatedCount })) + + await apiStep(module, 'client unread baseline is zero', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread baseline') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + const sendMessage = async (sequence) => apiStep(module, `platform sends client message ${sequence}`, { + method: 'POST', path: '/api/platform/v1/notifications', token: platformToken, + body: { + tenantId: String(report.resources.tenantId), + templateCode, + recipientType: 'MEMBERSHIP', + recipientIds: [String(report.resources.membershipId)], + variables: { marker, sequence: String(sequence) }, + scheduledAt: null, + idempotencyKey: newIdempotencyKey(`client-notification-${sequence}`), + sourceEventId: `client-e2e-notification-${sequence}-${marker}`.slice(0, 128), + }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'SENT', 'notification task status') + assertEqual(Number(json.data?.recipientCount), 1, 'notification recipient count') + }, ({ json }) => ({ taskId: json.data.id, taskNo: json.data.taskNo, status: json.data.status, recipientCount: json.data.recipientCount })) + + await sendMessage(1) + + await apiStep(module, 'client unread count increments after first delivery', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 1, 'unread count after first delivery') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + const firstUnreadPage = await apiStep(module, 'client filters first message as unread', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const message = pageRecords(json.data).find((item) => item.title === `客户消息验收 1`) + assertPresent(message?.id, 'first unread notification id') + assertEqual(message.status, 'UNREAD', 'first notification status') + }, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) })) + const firstMessage = pageRecords(firstUnreadPage.json.data).find((item) => item.title === '客户消息验收 1') + const firstMessageId = firstMessage.id + report.resources.notificationMessageIds.push(firstMessageId) + + await apiStep(module, 'client reads first message detail while it is unread', { + method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), String(firstMessageId), 'first notification id') + assertEqual(json.data?.status, 'UNREAD', 'first notification detail status') + assertTrue(String(json.data?.body).includes(marker), 'first notification body must contain marker') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, category: json.data.category })) + + if (existingClientToken) { + await apiStep(module, 'other client cannot read first message detail', { + method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ messageId: firstMessageId, rejectionCode: json.code })) + } else { + recordSkipped(module, 'other client cannot read first message detail', 'Cross-client credentials were not supplied') + } + + await apiStep(module, 'client marks first message read', { + method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'first notification status after read') + assertPresent(json.data?.readAt, 'first notification readAt') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'single-message read is idempotent', { + method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'idempotent notification status') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'client filters first message as read', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=READ&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(pageRecords(json.data).some((item) => String(item.id) === String(firstMessageId) && item.status === 'READ'), + 'read filter must contain first notification') + }, ({ json }) => ({ total: json.data.total, readIds: pageRecords(json.data).map((item) => item.id) })) + + await apiStep(module, 'client unread count returns to zero after single read', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread count after single read') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + await sendMessage(2) + const secondUnreadPage = await apiStep(module, 'client receives second unread message', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const message = pageRecords(json.data).find((item) => item.title === '客户消息验收 2') + assertPresent(message?.id, 'second unread notification id') + assertEqual(message.status, 'UNREAD', 'second notification status') + }, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) })) + const secondMessage = pageRecords(secondUnreadPage.json.data).find((item) => item.title === '客户消息验收 2') + const secondMessageId = secondMessage.id + report.resources.notificationMessageIds.push(secondMessageId) + + if (existingClientToken) { + await apiStep(module, 'other client cannot see acceptance messages in inbox', { + method: 'GET', path: `${inboxBase}?page=1&size=200&category=${category}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + if (['A0403', 'A0404'].includes(String(json?.code))) return + requireSuccess(json) + const foreignIds = new Set(pageRecords(json.data).map((item) => String(item.id))) + assertTrue(!foreignIds.has(String(firstMessageId)) && !foreignIds.has(String(secondMessageId)), + 'other client inbox must exclude acceptance messages') + }, ({ json }) => ({ rejectionCode: json?.code === '200' ? null : json?.code, + visibleCount: json?.code === '200' ? pageRecords(json.data).length : null, + excludedIds: [firstMessageId, secondMessageId] })) + } else { + recordSkipped(module, 'other client cannot see acceptance messages in inbox', 'Cross-client credentials were not supplied') + } + + await apiStep(module, 'client unread count increments after second delivery', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 1, 'unread count after second delivery') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + await apiStep(module, 'client marks all messages read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.updatedCount), 1, 'read-all updatedCount') + }, ({ json }) => ({ updatedCount: json.data.updatedCount })) + + await apiStep(module, 'second message detail is read after read-all', { + method: 'GET', path: `${inboxBase}/${secondMessageId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'second notification status after read-all') + assertPresent(json.data?.readAt, 'second notification readAt') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'client unread count is zero after read-all', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread count after read-all') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + } finally { + if (merchantToken && report.resources.notificationMessageIds.length > 0) { + await softApiStep(module, 'cleanup keeps acceptance messages read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number(json.data?.updatedCount) >= 0, 'cleanup updatedCount must be non-negative') + }, ({ json }) => ({ updatedCount: json?.data?.updatedCount })) + await softApiStep(module, 'cleanup leaves no unread acceptance messages', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'cleanup unread count') + }, ({ json }) => ({ unreadCount: json?.data?.unreadCount })) + } + if (template?.status === 'ACTIVE') { + const disabled = await softApiStep(module, 'platform disables client inbox template', { + method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken, + body: { targetStatus: 'DISABLED', version: template.version }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'notification template final status') + }, ({ json }) => ({ templateId: json?.data?.id, status: json?.data?.status, version: json?.data?.version })) + if (disabled?.json?.data) template = disabled.json.data + } + report.notificationInbox = { + templateId: report.resources.notificationTemplateId, + templateCode, + templateStatus: template?.status ?? null, + messageIds: [...report.resources.notificationMessageIds], + retainedAsRead: true, + } + } +} + async function accountSecurityModule() { const consumedRefreshToken = merchantRefreshToken const rotated = await apiStep('account-security', 'rotate refresh token', { @@ -1830,6 +2067,7 @@ function requiredOwnerBackendPermissions() { 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status', 'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant', + 'merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all', ] } @@ -1840,6 +2078,7 @@ function visibleClientMenus(backendPermissions) { if (permissions.has('merchant:profile:view')) menus.push('company:profile:view') if (permissions.has('merchant:member:list')) menus.push('company:member:list') if (permissions.has('merchant:role:list')) menus.push('company:role:list', 'company:permission:list') + if (permissions.has('merchant:notification:view')) menus.push('notification:inbox:view') return menus.sort() } diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 27d0b69f5..26624757a 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = '9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899' -$ExpectedLines = 14812 +$ExpectedSha256 = '760747FCA4706FEBA7D1D69994633875D7BE5932BB4A6B4F30A6B9BAB719FAD8' +$ExpectedLines = 14940 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') From 91e781b3ff1f0386ade3adf50bdf17bdf19c4e79 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 10:57:05 +0800 Subject: [PATCH 003/112] =?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 | 20 +--- .../mapper/ContainerOverviewMapper.java | 4 +- .../db/phase3-channel-container-migration.sql | 10 +- ...ase3-simple-customs-document-migration.sql | 12 +-- .../db/phase3-smart-loading-schema.sql | 10 +- .../ContainerOverviewSqlContractTest.java | 10 ++ ...ChannelContainerMigrationContractTest.java | 6 +- .../SmartLoadingSqlContractTest.java | 10 +- .../admin-test-env-modules/ocean-bookings.mjs | 80 +++++++++------- ...-inbound-status-fixtures-contract.test.mjs | 5 +- .../order-inbound-status-fixtures.mjs | 8 +- .../phase3-order-splitting-contract.test.mjs | 23 +++++ .../phase3-order-splitting.mjs | 92 +++++++++++++------ .../phase3-port-schedules.mjs | 35 +++++-- .../phase3-producer-chain-contract.test.mjs | 7 ++ .../service-center-flow.mjs | 6 +- 16 files changed, 219 insertions(+), 119 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0f0685910..afdfb9ac4 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5226,10 +5226,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) @@ -5238,8 +5235,6 @@ BEGIN OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) - OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) @@ -5253,7 +5248,6 @@ BEGIN OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) @@ -5264,7 +5258,7 @@ BEGIN OR NOT (OLD.created_at <=> NEW.created_at) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; @@ -14349,17 +14343,13 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -14367,13 +14357,13 @@ BEGIN OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 964ef0eba..723f140e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -10,7 +10,9 @@ import java.util.List; @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); @Select("SELECT action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{bookingId} AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1") ExceptionRow exceptionOperation(@Param("bookingId") long bookingId,@Param("operatorId") long operatorId,@Param("operationId") String operationId); - @Select("SELECT * FROM fl_loading_pick_task WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") PickTaskRow pickTask(@Param("id") long id); + @Select("SELECT id,loading_plan_id AS loadingPlanId,container_booking_id AS containerBookingId," + + "task_status AS taskStatus,version FROM fl_loading_pick_task " + + "WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") PickTaskRow pickTask(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id}") int itemCount(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status IN ('PICKED','PARTIAL','SKIPPED')") int completedItemCount(@Param("id") long id); @Select("SELECT COUNT(*) FROM fl_loading_pick_item WHERE pick_task_id=#{id} AND item_status NOT IN ('PICKED','PARTIAL','SKIPPED')") int unresolvedItemCount(@Param("id") long id); diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index b89ba7da3..5688c6fec 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -90,17 +90,13 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -108,13 +104,13 @@ BEGIN OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 4bfe14ab4..00405a162 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -28,23 +28,19 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); --- Keep the container master immutable after loading; only the two document rows remain operable. +-- Keep container identity/loading facts immutable while allowing release transport data to arrive later. DROP TRIGGER IF EXISTS trg_container_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.id; - IF current_plan_status='COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status='LOADING' AND ( + IF current_plan_status IN ('LOADING','COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) @@ -53,14 +49,14 @@ BEGIN OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) + OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_at <=> NEW.created_at)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜中仅允许修改集装箱号与航次'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 1da47ad6f..e1218cd04 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -584,10 +584,7 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; - END IF; - IF current_plan_status = 'LOADING' AND ( + IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) @@ -596,8 +593,6 @@ BEGIN OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no) - OR NOT (OLD.vessel_name <=> NEW.vessel_name) OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) @@ -611,7 +606,6 @@ BEGIN OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at) OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) @@ -622,7 +616,7 @@ BEGIN OR NOT (OLD.created_at <=> NEW.created_at) ) THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次'; + SET MESSAGE_TEXT = '装柜作业开始后仅允许修改集装箱号与放舱运输数据'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index 1d92a7963..a1c384ab4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.repository; import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; @@ -10,6 +12,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("loading_plan_id AS loadingPlanId", + "container_booking_id AS containerBookingId", "task_status AS taskStatus", "version") + .doesNotContain("SELECT *"); + } + @Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception { String development = new ClassPathResource("db/phase3-smart-loading-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java index 9811a39df..58a17c51b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3ChannelContainerMigrationContractTest.java @@ -60,12 +60,16 @@ class Phase3ChannelContainerMigrationContractTest { .contains("@phase3_channel_message = MESSAGE_TEXT") .contains("渠道柜迁移失败:货柜只读触发器未恢复") .contains("渠道柜迁移失败:PDA任务只读触发器未恢复") - .contains("装柜完成后货柜全部只读,不允许修改") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") .contains("已完成PDA拣货任务不可修改") .contains("NOT (OLD.channel_code <=> NEW.channel_code)") .contains("NOT (OLD.channel_name <=> NEW.channel_name)") .contains("NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)") .contains("NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") .doesNotContain("p.plan_status='COMPLETED'"); assertThat(call).isGreaterThan(0); assertThat(containerRestore).isGreaterThan(call); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 4de276cb7..f1ce3f50f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -81,7 +81,8 @@ class SmartLoadingSqlContractTest { assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"), StandardCharsets.UTF_8)) .contains("CREATE TRIGGER trg_container_booking_before_update") - .contains("IF current_plan_status='COMPLETED' THEN") + .contains("current_plan_status IN ('LOADING','COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); } @@ -145,8 +146,11 @@ class SmartLoadingSqlContractTest { .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("装柜完成后的明细永久冻结") - .contains("装柜中仅允许修改集装箱号与航次") - .contains("装柜完成后货柜全部只读,不允许修改") + .contains("current_plan_status IN ('LOADING', 'COMPLETED')") + .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") + .doesNotContain("NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)") + .doesNotContain("NOT (OLD.vessel_name <=> NEW.vessel_name)") + .doesNotContain("NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)") .doesNotContain("preload_status") .doesNotContain("PRELOAD_PUSHED") .doesNotContain("PRELOADING") diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 0481250aa..9df23b3ee 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -19,10 +19,53 @@ function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) } function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } +async function releaseLoadingBooking(context, containerFlow, suffix) { + const flowOceanBookingId = id(containerFlow?.oceanBookingId) + if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null + const flowPath = `${BASE}/${flowOceanBookingId}` + let flowResponse = await request(context, 'GET', flowPath) + let flow = data(flowResponse) + context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, + `oceanBookingId=${flowOceanBookingId}`, OPTIONS) + const allocationMatches = flow?.containerAllocations?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, + allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), + `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { + const flowRelease = await request(context, 'POST', `${flowPath}/release`, { + expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, + vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) + context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) + flowResponse = await request(context, 'GET', flowPath) + flow = data(flowResponse) + } + context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, + flow?.oceanBooking?.status === 'RELEASED' + && flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) + return flow?.oceanBooking?.status === 'RELEASED' && allocationMatches ? { + id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, + containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), + finalStatus: 'RELEASED', + } : null +} + export async function run(context) { const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const suffix = context.marker.replace(/\D/g, '').slice(-10) + const existing = context.createdResources?.oceanBooking + if (existing?.marker === context.marker && existing?.finalStatus === 'RELEASED') { + if (!id(containerFlow?.containerBookingId) + || id(existing.containerBookingId) === id(containerFlow.containerBookingId)) return + const reconciled = await releaseLoadingBooking(context, containerFlow, suffix) + if (reconciled) context.createdResources.oceanBooking = reconciled + return + } const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) @@ -95,41 +138,8 @@ export async function run(context) { loadingPlanId: null, finalStatus: fd?.oceanBooking?.status || null, } - const flowOceanBookingId = id(containerFlow?.oceanBookingId) - if (flowOceanBookingId && id(containerFlow?.containerBookingId) && id(containerFlow?.loadingPlanId)) { - const flowPath = `${BASE}/${flowOceanBookingId}` - let flowResponse = await request(context, 'GET', flowPath) - let flow = data(flowResponse) - context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, - `oceanBookingId=${flowOceanBookingId}`, OPTIONS) - const allocationMatches = flow?.containerAllocations?.some?.((item) => - id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, - allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), - `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) - if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { - const flowRelease = await request(context, 'POST', `${flowPath}/release`, { - expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, - vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, - estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', - reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) - context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) - flowResponse = await request(context, 'GET', flowPath) - flow = data(flowResponse) - } - context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, - flow?.oceanBooking?.status === 'RELEASED' - && flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), - `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) - if (flow?.oceanBooking?.status === 'RELEASED') { - downstreamBooking = { - id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, - containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), - finalStatus: 'RELEASED', - } - } - } + const releasedLoadingBooking = await releaseLoadingBooking(context, containerFlow, suffix) + if (releasedLoadingBooking) downstreamBooking = releasedLoadingBooking context.createdResources.oceanBooking = downstreamBooking const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index da8c65d01..e3d5275d5 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -31,7 +31,8 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT assert.match(source, new RegExp(`'${status}'`)) } assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.match(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/) + assert.match(source, /`\/api\/platform\/v1\/inbounds\/\$\{query\(inboundId\)\}\/measurements\/reject\?tenantId=\$\{query\(tenantId\)\}`/) + assert.doesNotMatch(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/) }) test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => { @@ -44,7 +45,7 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', ( test('测量状态截停发生在正确的 HTTP 边界', () => { assert.match(source, /includes\(summary\.mainStatus\)/) assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) - assert.match(source, /target === 'MEASURING' && data\(response\)\?\.processStatus === 'MEASURING'/) + assert.match(source, /target === 'MEASURING' && ok\(response\) && data\(response\)\?\.measurementId/) assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) assert.match(source, /measurements\/reject/) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 8a27e7114..f1e87501f 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -179,7 +179,7 @@ async function prepareInboundStatus(context, target) { const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`) if (isMeasurementQuery) { const response = await originalRequest.call(context, method, path, body, token, headers) - if (target === 'MEASURING' && data(response)?.processStatus === 'MEASURING') throw stop + if (target === 'MEASURING' && ok(response) && data(response)?.measurementId) throw stop return response } if (target === 'MEASURE_PENDING' && isSubmit) { @@ -192,11 +192,11 @@ async function prepareInboundStatus(context, target) { if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`) const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token) const version = Number(data(latest)?.summary?.version) - const rejected = await originalRequest.call(context, 'POST', - path.replace('/measurement/submit', '/measurements/reject'), + const rejectPath = `/api/platform/v1/inbounds/${query(inboundId)}/measurements/reject?tenantId=${query(tenantId)}` + const rejected = await originalRequest.call(context, 'POST', rejectPath, { measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` }, token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) }) - if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}`) + if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}, requestId=${rejected?.requestId || 'missing'}, traceId=${rejected?.traceId || 'missing'}`) throw stop } return originalRequest.call(context, method, path, body, token, headers) 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 8b6222262..7c53f78de 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 @@ -26,6 +26,29 @@ test('PDA opens only after a real container number and uses stable plan identity assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) }) +test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => { + const claimIndex = source.indexOf("`${PDA}/tasks/${taskId}/claim`") + const snapshotIndex = source.indexOf('inventory-snapshot?warehouseId=') + const batchIndex = source.indexOf('const batchPath = `${PDA}/tasks/${taskId}/batch`') + const completeIndex = source.indexOf('const completePath = `${PDA}/tasks/${taskId}/complete`') + const treeIndex = source.indexOf('`回查 ${sourceOrderNo} 拆分后的根聚合`') + assert.ok(claimIndex >= 0 && snapshotIndex > claimIndex && batchIndex > snapshotIndex + && completeIndex > batchIndex && treeIndex > completeIndex) + assert.match(source, /loadingPlanId: planId, containerBookingId: containerId/) + assert.match(source, /expectedVersion: Number\(inventory\.taskVersion\)/) + assert.match(source, /inventorySnapshotToken: inventory\.snapshotToken/) + assert.match(source, /action: 'PICK', pickedPackageCount: 1/) + assert.match(source, /version: Number\(claimedItem\.version\), releaseMode: 'MANUAL', locationIds: \[\]/) + assert.match(source, /'Idempotency-Key': batchKey/) + assert.match(source, /differentReplay/) + assert.match(source, /workflowStatus === 'AWAITING_REVIEW'/) + assert.match(source, /expectedTaskVersion: Number\(picked\?\.version\)/) + assert.match(source, /operationId: `\$\{operationBase\}-complete`/) + assert.match(source, /completeReplay/) + assert.match(source, /workflowStatus === 'COMPLETED'/) + assert.doesNotMatch(source, /\/items\/\$\{[^}]+\}\/pick/) +}) + test('booking freezes the source customer channel and warehouse scope', () => { assert.match(source, /customerId: source\.tenantId/) assert.match(source, /channelCode: source\.channelCode/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 558140976..f25ab4945 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -232,43 +232,83 @@ async function loadOneFragment(context, state) { const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, { expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`, }) - context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS) + const claimed = context.apiSuccess(MODULE, `领取 ${sourceOrderNo} 的 PDA 任务`, claim, '', OPTIONS) + const claimedItem = claimed?.items?.find?.((row) => id(row.id) === id(pickItem.id)) || pickItem + const inventoryResponse = await context.request('GET', + `${PDA}/tasks/${taskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const inventory = context.apiSuccess(MODULE, `冻结 ${sourceOrderNo} 的 PDA 库存快照`, inventoryResponse, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 库存快照绑定稳定任务身份`, inventoryResponse, + id(inventory?.taskId) === taskId && id(inventory?.loadingPlanId) === planId + && id(inventory?.containerBookingId) === containerId && id(inventory?.warehouseId) === warehouseId + && Number(inventory?.taskVersion) === Number(claimed?.version) + && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64, + `taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, loadingPlanId=${id(inventory?.loadingPlanId) || 'missing'}, containerBookingId=${id(inventory?.containerBookingId) || 'missing'}`, OPTIONS) + if (!inventory?.snapshotToken) return null - const stalePick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1, - operationId: `${operationBase}-pick-stale`, - }) - context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧明细版本被拒绝`, stalePick, - isConflict(stalePick) && hasTrace(stalePick), - `HTTP=${stalePick.http}, code=${codeOf(stalePick)}, requestId=${stalePick.requestId || 'missing'}, traceId=${stalePick.traceId || 'missing'}`, OPTIONS) - - const pickPayload = { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version), - operationId: `${operationBase}-pick`, + const batchPath = `${PDA}/tasks/${taskId}/batch` + const batchKey = `${operationBase}-batch`.slice(0, 128) + const batchPayload = { + warehouseId, loadingPlanId: planId, containerBookingId: containerId, + expectedVersion: Number(inventory.taskVersion), inventorySnapshotToken: inventory.snapshotToken, + items: [{ + itemId: id(claimedItem.id), action: 'PICK', pickedPackageCount: 1, + version: Number(claimedItem.version), releaseMode: 'MANUAL', locationIds: [], + }], } - const pick = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) - const picked = context.apiSuccess(MODULE, `真实拣货并拆分 ${sourceOrderNo}`, pick, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 本分片只承接1箱`, pick, - picked?.items?.some?.((row) => id(row.id) === id(pickItem.id) - && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), + const staleBatch = await request(context, 'POST', batchPath, { + ...batchPayload, expectedVersion: Number(inventory.taskVersion) + 1, + }, { 'Idempotency-Key': `${operationBase}-batch-stale`.slice(0, 128) }) + context.addStep(MODULE, `余量 ${sourceOrderNo} 的旧任务版本被拒绝且不结算`, staleBatch, + isConflict(staleBatch) && hasTrace(staleBatch), + `HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}, requestId=${staleBatch.requestId || 'missing'}, traceId=${staleBatch.traceId || 'missing'}`, OPTIONS) + + const batch = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey }) + const picked = context.apiSuccess(MODULE, `原子提交 ${sourceOrderNo} 的完整 PDA 草稿`, batch, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 批次拣出1箱并等待人工确认`, batch, + picked?.taskStatus === 'LOADING' && picked?.workflowStatus === 'AWAITING_REVIEW' + && picked?.items?.some?.((row) => id(row.id) === id(claimedItem.id) + && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), `workflow=${picked?.workflowStatus || 'missing'}, taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS) - const replay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) - const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} 拆分幂等重放`, replay, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 幂等重放不重复累计`, replay, - id(replayed?.id) === taskId && replayed?.items?.some?.((row) => id(row.id) === id(pickItem.id) - && Number(row.pickedPackageCount) === 1), - `taskId=${id(replayed?.id) || 'missing'}, picked=${replayed?.items?.find?.((row) => id(row.id) === id(pickItem.id))?.pickedPackageCount ?? 'missing'}`, OPTIONS) + const replay = await request(context, 'POST', batchPath, batchPayload, { 'Idempotency-Key': batchKey }) + const replayed = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 批次幂等重放`, replay, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 批次重放不重复累计`, replay, + id(replayed?.id) === taskId && Number(replayed?.version) === Number(picked?.version) + && replayed?.items?.some?.((row) => id(row.id) === id(claimedItem.id) + && Number(row.pickedPackageCount) === 1), + `taskId=${id(replayed?.id) || 'missing'}, taskVersion=${replayed?.version ?? 'missing'}`, OPTIONS) if (sourcePackageCount > 1) { - const differentReplay = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { - ...pickPayload, pickedPackageCount: 2, - }) + const differentReplay = await request(context, 'POST', batchPath, { + ...batchPayload, + items: [{ ...batchPayload.items[0], pickedPackageCount: 2 }], + }, { 'Idempotency-Key': batchKey }) context.addStep(MODULE, `${sourceOrderNo} 同幂等键异参被拒绝`, differentReplay, isConflict(differentReplay) && hasTrace(differentReplay), `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) } + const completePath = `${PDA}/tasks/${taskId}/complete` + const completePayload = { + expectedTaskVersion: Number(picked?.version), operationId: `${operationBase}-complete`.slice(0, 128), + remark: context.marker, + } + const complete = await request(context, 'POST', completePath, completePayload) + const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete, + completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' + && completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id) + && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), + `workflow=${completed?.workflowStatus || 'missing'}, taskStatus=${completed?.taskStatus || 'missing'}`, OPTIONS) + if (completed?.taskStatus !== 'COMPLETED' || completed?.workflowStatus !== 'COMPLETED') return null + + const completeReplay = await request(context, 'POST', completePath, completePayload) + const completedReplay = context.apiSuccess(MODULE, `${sourceOrderNo} PDA 完成幂等重放`, completeReplay, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 完成重放保持同一已结算任务`, completeReplay, + id(completedReplay?.id) === taskId && Number(completedReplay?.version) === Number(completed?.version) + && completedReplay?.taskStatus === 'COMPLETED' && completedReplay?.workflowStatus === 'COMPLETED', + `taskId=${id(completedReplay?.id) || 'missing'}, taskVersion=${completedReplay?.version ?? 'missing'}`, OPTIONS) + const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId)) const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS) const candidatesAfter = await context.request('GET', candidatesPath) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index f8e223b3b..db2fe4da2 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -57,7 +57,18 @@ function oceanDraft(context, suffix, master) { } async function ensureOceanBooking(context, suffix, master) { - if (context.createdResources?.oceanBooking?.id) return context.createdResources.oceanBooking + if (context.createdResources?.oceanBooking?.id) { + const existingId = id(context.createdResources.oceanBooking.id) + const response = await request(context, 'GET', `${OCEAN}/${existingId}`) + const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response, + `oceanBookingId=${existingId}`, OPTIONS) + return detail?.oceanBooking ? { + id: existingId, + customerId: id(detail.oceanBooking.customerId), + allocatedContainerBookingIds: (detail.containerAllocations || []) + .map((item) => id(item?.containerBookingId)).filter(Boolean), + } : null + } const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, { 'Idempotency-Key': `${context.marker}-port-ocean`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, '创建本轮隔离船期关联订舱', response, '', OPTIONS) @@ -65,7 +76,11 @@ async function ensureOceanBooking(context, suffix, master) { context.assert(MODULE, '船期关联订舱客户归属明确', response, Boolean(oceanBookingId) && id(detail?.oceanBooking?.customerId) === id(master.customerId), `oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS) - return oceanBookingId ? { id: oceanBookingId, customerId: master.customerId } : null + return oceanBookingId ? { + id: oceanBookingId, customerId: id(detail.oceanBooking.customerId), + allocatedContainerBookingIds: (detail.containerAllocations || []) + .map((item) => id(item?.containerBookingId)).filter(Boolean), + } : null } function scheduleData(context, suffix, oceanBookingId, containerBookingId, customerId, expired = false) { @@ -109,9 +124,15 @@ export async function run(context) { || context.createdResources?.phase3ContainerLoading const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) ? id(flow.containerBookingId) : null - const knownAllocation = context.createdResources?.oceanBooking?.id === ocean.id && containerBookingId - ? containerBookingId : null - const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId), reason: context.marker } + const knownAllocation = containerBookingId + && ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null + if (containerBookingId && !knownAllocation) { + context.addStep(MODULE, '船期订舱真实分配包含本轮装货柜次', null, false, + `oceanBookingId=${ocean.id}, containerBookingId=${containerBookingId}, allocations=${ocean.allocatedContainerBookingIds?.join(',') || 'none'}`, + OPTIONS) + return + } + const payload = { expectedVersion: 0, data: scheduleData(context, suffix, ocean.id, knownAllocation, ocean.customerId), reason: context.marker } const createKey = `${context.marker}-port-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, payload, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '新增独立港务船期观测', createResponse, '', OPTIONS) @@ -135,7 +156,7 @@ export async function run(context) { `first=${scheduleId}, replay=${id(data(replay)?.portSchedule?.schedule?.id) || 'missing'}`, OPTIONS) for (const [name, query] of [ - ['按客户查询船期', `customerId=${encodeURIComponent(master.customerId)}`], + ['按客户查询船期', `customerId=${encodeURIComponent(ocean.customerId)}`], ['按订舱查询船期', `oceanBookingId=${ocean.id}`], ...(knownAllocation ? [['按柜次查询船期', `containerBookingId=${knownAllocation}`]] : []), ]) { @@ -225,7 +246,7 @@ export async function run(context) { const forged = await request(context, 'POST', BASE, { expectedVersion: 0, - data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, master.customerId), customerId: `${master.customerId}999` }, + data: { ...scheduleData(context, `${suffix}X`, ocean.id, null, ocean.customerId), customerId: `${ocean.customerId}999` }, reason: '伪造客户归属', }, { 'Idempotency-Key': `${context.marker}-port-forged-customer`.slice(0, 128) }) step(context, '伪造船期客户归属被拒绝', forged, diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 61c00b559..89870ba7d 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -279,10 +279,17 @@ test('downstream producers prefer the phase3Loading container fixture', () => { assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/) assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/) assert.match(ocean, /ocean-flow-release/) + assert.match(ocean, /existing\?\.marker === context\.marker && existing\?\.finalStatus === 'RELEASED'/) + assert.match(ocean, /const reconciled = await releaseLoadingBooking\(context, containerFlow, suffix\)/) assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/) assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/) assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/) + assert.match(serviceCenter, /if \(!allocationMatches\) return/) + const schedules = source('./phase3-port-schedules.mjs') + assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/) + assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) + assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) }) test('default acceptance order verifies loading evidence before finalizing LOADED', () => { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index f18753a07..181d33732 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -61,9 +61,11 @@ export async function run(context) { return } if (linkContainer) { - context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, - oceanDetail?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + const allocationMatches = oceanDetail?.containerAllocations?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, `containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${oceanDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (!allocationMatches) return } const payload = { outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, From ce6963a25e87fcb9487adb23363f480f1794b835 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 12:50:37 +0800 Subject: [PATCH 004/112] =?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 | 6 +- .../finance/client/IamCustomerClient.java | 24 ++++ .../FinancePhase1OperationController.java | 16 ++- .../FinanceCustomerAccountService.java | 124 ++++++++++++++++++ .../FinancePhase1OperationService.java | 6 +- .../FinanceCustomerAccountServiceTest.java | 53 ++++++++ .../FinancePhase1OperationBehaviorTest.java | 12 +- .../baiyue/firstleg/dto/ContainerDtos.java | 1 + .../baiyue/firstleg/dto/LoadingListDtos.java | 2 + .../firstleg/dto/TruckAppointmentDtos.java | 2 +- .../mapper/ContainerBookingMapper.java | 6 + .../mapper/LoadingListQueryMapper.java | 2 + .../mapper/TruckAppointmentMapper.java | 2 +- .../pricing/ActivePriceBookEstimator.java | 7 +- .../PriceBookWarehouseRegionResolver.java | 11 +- .../firstleg/service/ContainerService.java | 2 +- .../firstleg/service/FbaPricingService.java | 5 +- .../firstleg/service/LoadingListService.java | 4 +- .../resources/db/phase2-inbound-schema.sql | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 1 + .../resources/openapi/loading-lists-v1.yaml | 12 +- .../openapi/truck-appointments-v1.yaml | 2 + .../controller/FbaPricingContractTest.java | 2 + .../repository/LoadingListRepositoryTest.java | 4 +- .../repository/PriceBookSqlContractTest.java | 2 + .../service/FbaPricingServiceTest.java | 18 +++ .../service/LoadingListServiceTest.java | 8 +- .../service/MerchantPriceBookServiceTest.java | 22 +++- .../controller/InternalTenantController.java | 14 ++ .../controller/PlatformTenantController.java | 2 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 4 + .../CustomerListExportProviderRepository.java | 9 +- .../iam/repository/TenantRepository.java | 27 +++- .../com/baiyue/iam/service/TenantService.java | 9 ++ iam/src/main/resources/openapi/iam-v1.yaml | 45 ++++++- .../InternalCustomerExportContractTest.java | 13 ++ ...tomerListExportProviderRepositoryTest.java | 2 +- .../baiyue/iam/service/TenantServiceTest.java | 18 +++ scripts/admin-test-env-acceptance.mjs | 17 +++ .../manual-state-matrix-contract.test.mjs | 65 +++++++++ .../manual-state-matrix.mjs | 102 +++++++++++++- ...ual-state-ocean-trucking-contract.test.mjs | 8 ++ .../manual-state-ocean-trucking.mjs | 7 +- ...-inbound-status-fixtures-contract.test.mjs | 6 +- .../order-inbound-status-fixtures.mjs | 32 +++-- 46 files changed, 664 insertions(+), 76 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index afdfb9ac4..8c9c13d1f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2910,7 +2910,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', @@ -2962,6 +2962,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'; + CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java new file mode 100644 index 000000000..c0591cbe2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java @@ -0,0 +1,24 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "iam-service", contextId = "financeIamCustomerClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamCustomerClient { + @GetMapping("/finance-customers") + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam int page, + @RequestParam int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) Long customerId); + + record Customer(long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java index a9fd3597f..1d2b5aff5 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/FinancePhase1OperationController.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest; import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.service.FinanceCustomerAccountService; +import org.springframework.beans.factory.annotation.Autowired; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -24,15 +26,23 @@ import java.time.LocalDate; @RequestMapping("/api/platform/finance/v1") public class FinancePhase1OperationController { private final FinancePhase1OperationService service; - public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; } + private final FinanceCustomerAccountService customerAccountService; + + @Autowired + public FinancePhase1OperationController(FinancePhase1OperationService service, + FinanceCustomerAccountService customerAccountService) { + this.service = service; + this.customerAccountService = customerAccountService; + } @GetMapping("/accounts") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')") public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String status) { - return service.listDerivedCustomerAccounts(page, pageSize, keyword, status); + @RequestParam(required = false) String status, + @RequestParam(required = false) Long customerId) { + return customerAccountService.list(page, pageSize, keyword, status, customerId); } @GetMapping("/accounts/{customerId}/ledger") diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java new file mode 100644 index 000000000..55efe872a --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -0,0 +1,124 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class FinanceCustomerAccountService { + private final JdbcTemplate jdbc; + private final IamCustomerClient iamCustomerClient; + private final String internalToken; + + public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient, + @Value("${baiyue.security.internal-service-token:}") String internalToken) { + this.jdbc = jdbc; + this.iamCustomerClient = iamCustomerClient; + this.internalToken = internalToken; + } + + public PageEnvelope list(int page, int pageSize, String keyword, String status, Long customerId) { + validatePage(page, pageSize); + String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(); + if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) { + throw new FinanceEventException("FIN-REQ-400", "账户状态无效"); + } + ApiResponse> response; + try { + response = iamCustomerClient.page(internalToken, page, pageSize, blankToNull(keyword), normalizedStatus, customerId); + } catch (RuntimeException error) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + if (response == null || !response.successful() || response.data() == null) { + throw new FinanceEventException("FIN_503", "客户主数据暂不可用"); + } + PageResult customers = response.data(); + Map metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList()); + List rows = customers.records().stream().map(customer -> { + Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO); + return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(), + customer.contactName(), customer.contactPhone(), value.outstandingReceivable(), + value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(), + customer.status(), value.lastChangedAt()); + }).toList(); + return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total()); + } + + private Map loadMetrics(List customerIds) { + if (customerIds.isEmpty()) return Map.of(); + String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); + Map result = new HashMap<>(); + customerIds.forEach(id -> result.put(id, new MutableMetrics())); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "outstandingReceivable", "amount")); + jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version) GROUP BY customer_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "pendingBilling", "amount")); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "receivedInPeriod", "amount")); + jdbc.queryForList("SELECT tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray()) + .forEach(row -> metric(result, row, "outstandingPayable", "amount")); + Object[] changeArgs = new Object[customerIds.size() * 3]; + for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size()); + jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", changeArgs) + .forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at")); + Map frozen = new HashMap<>(); + result.forEach((id, value) -> frozen.put(id, value.freeze())); + return frozen; + } + + private static void metric(Map result, Map row, String field, String valueKey) { + Object id = row.get("customer_id"); + if (!(id instanceof Number number)) return; + MutableMetrics target = result.get(number.longValue()); + if (target == null) return; + Object value = row.get(valueKey); + if ("lastChangedAt".equals(field)) { + target.lastChangedAt = value instanceof Timestamp timestamp ? timestamp.toLocalDateTime() + : value instanceof LocalDateTime dateTime ? dateTime : null; + } else if (value != null) { + target.set(field, value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString())); + } + } + + private static void validatePage(int page, int pageSize) { + if (page < 1 || pageSize < 1 || pageSize > 200) throw new FinanceEventException("FIN-REQ-400", "分页参数无效"); + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private static final class MutableMetrics { + private BigDecimal outstandingReceivable = BigDecimal.ZERO; + private BigDecimal pendingBilling = BigDecimal.ZERO; + private BigDecimal receivedInPeriod = BigDecimal.ZERO; + private BigDecimal outstandingPayable = BigDecimal.ZERO; + private LocalDateTime lastChangedAt; + void set(String field, BigDecimal value) { + switch (field) { + case "outstandingReceivable" -> outstandingReceivable = value; + case "pendingBilling" -> pendingBilling = value; + case "receivedInPeriod" -> receivedInPeriod = value; + case "outstandingPayable" -> outstandingPayable = value; + default -> { } + } + } + Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); } + } + + private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling, + BigDecimal receivedInPeriod, BigDecimal outstandingPayable, + LocalDateTime lastChangedAt) { + private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index bafab4e56..0cc8821b4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -40,12 +40,12 @@ public class FinancePhase1OperationService { this.objectMapper = objectMapper; } - public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) { + public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { page = page(page); pageSize = pageSize(pageSize); String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT); if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效"); - String filter = " WHERE (? IS NULL OR CAST(c.customer_id AS CHAR) LIKE CONCAT('%',?,'%') OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)"; - Object[] filterArgs = {blankToNull(keyword), blankToNull(keyword), blankToNull(keyword), normalizedStatus, normalizedStatus}; + String filter = " WHERE (? IS NULL OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)"; + Object[] filterArgs = {blankToNull(customerName), blankToNull(customerName), normalizedStatus, normalizedStatus}; String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter; Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs); String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," + diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java new file mode 100644 index 000000000..60e14fd13 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -0,0 +1,53 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.finance.client.IamCustomerClient; +import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; +import com.baiyue.finance.service.FinanceCustomerAccountService; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceCustomerAccountServiceTest { + @Test + void listsMasterCustomersWithZeroFinanceMetricsAndForwardsSearchFields() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + IamCustomerClient iam = mock(IamCustomerClient.class); + when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of()); + when(iam.page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of( + new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")), + 1, 20, 1, 1))); + + var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token") + .list(1, 20, " Alice ", "active", 900000000000000001L); + + AccountSummary row = result.data().get(0); + assertThat(row.customerCode()).isEqualTo("TEN-001"); + assertThat(row.contactName()).isEqualTo("Alice"); + assertThat(row.contactPhone()).isEqualTo("13800138000"); + assertThat(row.outstandingReceivable()).isZero(); + assertThat(row.pendingBilling()).isZero(); + verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L); + } + + @Test + void rejectsUnsupportedFinanceAccountStatus() { + var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token"); + assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()) + .isEqualTo("FIN-REQ-400"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 26993080a..65f4a78f0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -7,6 +7,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.service.FinanceCustomerAccountService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -39,21 +40,22 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class FinancePhase1OperationBehaviorTest { @Mock FinancePhase1OperationService operationService; + @Mock FinanceCustomerAccountService customerAccountService; @Test void controllerDelegatesFiveFrozenOperationsAndUsesAuthenticatedOperator() { - FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService); + FinancePhase1OperationController controller = new FinancePhase1OperationController(operationService, customerAccountService); AuthenticatedUser user = new AuthenticatedUser(71L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); var review = new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0); var inclusion = new FinancePhase1Dtos.SettlementInclusionRequest(true, "进入本期结算", 1); - controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE"); + controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L); controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); controller.getFinanceFeeSource(88L); controller.reviewFinanceFeeSource(user, 88L, "review-key", review); controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion); - verify(operationService).listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE"); + verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L); verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any()); verify(operationService).getFinanceFeeSource(88L); verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review); @@ -93,6 +95,8 @@ class FinancePhase1OperationBehaviorTest { assertThat(result.page().total()).isEqualTo(1); assertThat(result.requestId()).isNotBlank(); assertThat(result.traceId()).isNotBlank(); + assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot"); + assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); } @Test @@ -234,6 +238,7 @@ class FinancePhase1OperationBehaviorTest { private static final class AccountJdbc extends JdbcTemplate { private final List ledgerCustomerIds = new ArrayList<>(); + private String accountListSql = ""; @Override public T queryForObject(String sql, Class requiredType, Object... args) { @@ -245,6 +250,7 @@ class FinancePhase1OperationBehaviorTest { @SuppressWarnings("unchecked") public List query(String sql, RowMapper rowMapper, Object... args) { if (sql.startsWith("SELECT c.customer_id")) { + accountListSql = sql; return (List) List.of(new FinancePhase1Dtos.AccountSummary( 9L, "9", "客户9", null, null, new BigDecimal("100.00"), new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 39b899631..1c6b42a8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -25,6 +25,7 @@ public final class ContainerDtos { @Size(max = 64) String cabinetNo, @Size(max = 128) String billOfLadingNo, @Size(max = 128) String keyword, + @Positive Long customerId, @Positive Long warehouseId, @Size(max = 32) String containerType, @Positive Long shippingCompanyId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index f72262371..6b68abf4a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -18,6 +18,7 @@ public final class LoadingListDtos { public record PreloadQuery( @Size(max = 128) String keyword, String status, + @Positive Long customerId, @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, @@ -32,6 +33,7 @@ public final class LoadingListDtos { public record ConfirmedQuery( @Size(max = 128) String keyword, + @Positive Long customerId, @Min(1) Long warehouseId, LocalDateTime createdFrom, LocalDateTime createdTo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index a51c8ab17..dcffc7ff4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} - public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } + public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index b13344e11..37b000929 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -24,6 +24,12 @@ public interface ContainerBookingMapper extends BaseMapperAND p.plan_status = #{query.status} + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') @@ -74,6 +75,7 @@ public interface LoadingListQueryMapper { JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 WHERE p.plan_status = 'COMPLETED' + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 538c8ebd7..76f34210b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ - @Select("") Page page(Page p,@Param("q") Query q); + @Select("") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 77a3b8a0c..e1733566e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -73,8 +73,9 @@ public class ActivePriceBookEstimator { } private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) { - if (request.destinationType() != PricingDestinationType.FBA) { - throw validation("海派渠道仅支持FBA目的仓参考估价"); + if (request.destinationType() != PricingDestinationType.FBA + && request.destinationType() != PricingDestinationType.OVERSEAS) { + throw validation("海派渠道仅支持平台FBA仓或海外中转仓参考估价"); } if (request.pricingTaxMode() == null) { throw validation("请选择包税或不包税报价"); @@ -95,7 +96,7 @@ public class ActivePriceBookEstimator { private PriceRegion resolveWarehouseRegion(Long warehouseId) { if (warehouseId == null || warehouseId <= 0) { - throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); + throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域"); } return warehouseRegionResolver.resolve(warehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java index 7123a2a3c..2d0189243 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java @@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */ +/** Resolves a platform destination warehouse to the fixed sea-parcel price region. */ @Component public class PriceBookWarehouseRegionResolver { @Resource private PriceBookWarehouseClient warehouses; @@ -20,7 +20,7 @@ public class PriceBookWarehouseRegionResolver { public PriceRegion resolve(long warehouseId) { if (warehouseId <= 0) { - throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域"); + throw validation("请提供目的仓区域、平台FBA仓或海外中转仓,以确定海派区域"); } try { ApiResponse response = warehouses.active(internalToken, 0L, warehouseId); @@ -28,12 +28,13 @@ public class PriceBookWarehouseRegionResolver { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用"); } if (!response.successful() || response.data() == null || response.data().warehouse() == null) { - throw validation("目的仓不是可用的平台FBA仓"); + throw validation("目的仓不是可用的平台目的仓"); } var warehouse = response.data().warehouse(); if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId()) - || !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) { - throw validation("目的仓不是可用的平台FBA仓"); + || !("FBA".equals(warehouse.warehouseType()) || "OVERSEAS".equals(warehouse.warehouseType())) + || !"ACTIVE".equals(warehouse.status())) { + throw validation("目的仓不是可用的平台FBA仓或海外中转仓"); } String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream() .filter(address -> address != null && StringUtils.hasText(address.postalCode())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index e9954f131..c3657cab2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -191,7 +191,7 @@ public class ContainerService { } private static Query normalize(Query q) { return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), - q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), + q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(), q.estimatedSailingTo(), q.page(), q.size()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 32474bab7..92885d7ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -227,9 +227,8 @@ public class FbaPricingService { || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) { throw conflict("审核通过的测量快照必须包含大于零的实重和体积"); } - if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null - || !StringUtils.hasText(inbound.getChannelCode())) { - throw conflict("入库单缺少客户、起运仓或渠道匹配维度"); + if (inbound.getWarehouseId() == null || !StringUtils.hasText(inbound.getChannelCode())) { + throw conflict("入库单缺少起运仓或渠道匹配维度"); } return inbound; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index c70c01d36..11881909a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -71,13 +71,13 @@ public class LoadingListService { if (StringUtils.hasText(status) && !PRELOAD_STATUSES.contains(status)) { throw badRequest("预装清单状态只能是 EDITING 或 LOADING"); } - return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, + return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status, query.customerId(), query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } public PageResult confirmedPage(ConfirmedQuery query) { validateRange(query.createdFrom(), query.createdTo()); - return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(), + return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.customerId(), query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size())); } diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index bb9bc4393..0eb74c458 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -566,7 +566,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照', tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 694f8417b..2f37808a3 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -130,7 +130,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: summary: 按审核通过测量快照试算 FBA 费用 - description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 + description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId、业务 customerId 或客户计费规则作为前置。区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index a1c6d1b88..e5fbf5b7e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -205,6 +205,7 @@ paths: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + - {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: containerNo, in: query, schema: {type: string, maxLength: 64}} - {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}} - {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 23c3b10a1..abc037a61 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -7,12 +7,13 @@ paths: /api/platform/v1/first-leg/loading-plans: get: summary: 分页查询预装清单 - description: 默认返回全部仓库的装柜计划;可按仓库筛选,客户账号仅用于展示货物归属。 + description: 默认返回全部仓库的装柜计划;可按客户和仓库筛选。 x-permission: platform:firstleg:preload:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/CustomerId'} - name: status in: query description: 预装状态 @@ -49,12 +50,13 @@ paths: /api/platform/v1/first-leg/loading-lists: get: summary: 分页查询装柜清单 - description: 默认返回全部仓库的 COMPLETED 装柜计划;可按仓库筛选,包含全部客户单据。 + description: 默认返回全部仓库的 COMPLETED 装柜计划;可按客户和仓库筛选。 x-permission: platform:firstleg:loading-list:list parameters: - {$ref: '#/components/parameters/Page'} - {$ref: '#/components/parameters/Size'} - {$ref: '#/components/parameters/Keyword'} + - {$ref: '#/components/parameters/CustomerId'} - {$ref: '#/components/parameters/OptionalWarehouseId'} - {$ref: '#/components/parameters/CreatedFrom'} - {$ref: '#/components/parameters/CreatedTo'} @@ -138,6 +140,12 @@ components: in: query description: 按预装单号、集装箱号或柜号模糊查询 schema: {type: string, maxLength: 128} + CustomerId: + name: customerId + in: query + required: false + description: 仅返回包含该客户货物的清单 + schema: {$ref: '#/components/schemas/LongId'} WarehouseId: name: warehouseId in: query diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 04c6ec185..1ea424e01 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -6,6 +6,8 @@ paths: /api/platform/v1/truck-appointments: get: summary: Query truck appointments + parameters: + - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': {description: Paged truck appointments} post: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index caa9d096f..9fa935393 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -49,6 +49,8 @@ class FbaPricingContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") .contains("Idempotency-Key") .contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态") + .contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域") + .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") .contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("不回退 WAIT_SPLIT 状态") .doesNotContain("应收") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index 86132d2aa..0052368b6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -51,9 +51,9 @@ class LoadingListRepositoryTest { confirmedPage.setTotal(1); when(mapper.selectConfirmedPage(any(), any())).thenReturn(confirmedPage); - var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, 1, 20)) + var preload = repository.preloadPage(new PreloadQuery(null, null, null, null, null, null, 1, 20)) .records().get(0); - var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)) + var confirmed = repository.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)) .records().get(0); assertThat(preload.planId()).isEqualTo(41L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java index 3ef7e2a0f..878d7df0a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -42,6 +42,8 @@ class PriceBookSqlContractTest { .contains("destination_name VARCHAR(128)") .contains("parse_issues_json JSON NULL") .contains("DEFAULT 'PENDING_CONFIRMATION'"); + assertThat(schema) + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'"); assertThat(schema) .contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001") .contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 40699bec3..e38f3be1a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -92,6 +92,24 @@ class FbaPricingServiceTest { assertThat(fee.getValue().getCarrierId()).isNull(); } + @Test + @DisplayName("平台公共渠道计费允许业务客户快照为空") + void calculatesWithoutOptionalBusinessCustomerSnapshot() { + readyRepository(); + InboundPricingEntity inbound = inbound("200.000", "0.2000"); + inbound.setCustomerId(null); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(any())).thenReturn( + estimate("KG", "200.000", "14.300000", "2860.00", "0.00")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer"); + + assertThat(response.totalAmount()).isEqualByComparingTo("2860.00"); + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getCustomerId()).isNull(); + } + @Test @DisplayName("不包税CBM使用生效价格表返回的计费量") void calculatesTaxExcludedCbmFromActivePriceBook() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 071387fa7..080e3632d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -61,7 +61,7 @@ class LoadingListServiceTest { @Test void shouldAcceptEditingStatusFromPreloadList() { - PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20); + PreloadQuery query = new PreloadQuery(null, "EDITING", null, 100L, null, null, 1, 20); service.preloadPage(query); @@ -184,7 +184,7 @@ class LoadingListServiceTest { @Test void confirmedPageAlwaysUsesRepositoryConfirmedProjection() { - ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20); + ConfirmedQuery query = new ConfirmedQuery(" plan ", null, 100L, null, null, 1, 20); when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); @@ -201,8 +201,8 @@ class LoadingListServiceTest { when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn( new PageResult<>(List.of(), 1, 20, 0, 0)); - service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, 1, 20)); - service.confirmedPage(new ConfirmedQuery(null, null, null, null, 1, 20)); + service.preloadPage(new PreloadQuery(null, "LOADING", null, null, null, null, 1, 20)); + service.confirmedPage(new ConfirmedQuery(null, null, null, null, null, 1, 20)); verify(repository).preloadPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> value.warehouseId() == null)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index ad4d849dd..c41872e2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -187,6 +187,26 @@ class MerchantPriceBookServiceTest { verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"); } + @Test + void shouldResolveTierRegionFromPlatformOverseasWarehousePostalCode() { + stubChannel(8L, "MATSON_MAX_PARCEL", PriceModel.REGION_WEIGHT_TIER); + when(repository.regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED")) + .thenReturn(tiers()); + var warehouse = new PriceBookWarehouseClient.Warehouse(901L, 0L, "US-LAX-01", "洛杉矶仓", + "OVERSEAS", "US", "ACTIVE"); + var address = new PriceBookWarehouseClient.Address("US", "CA", "Los Angeles", null, + "90001", "address", null); + when(warehouses.active("internal-token", 0L, 901L)) + .thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address)))); + + var result = service.estimate(10001L, + request("MATSON_MAX_PARCEL", PricingDestinationType.OVERSEAS, null, 901L, null, + PricingTaxMode.TAX_INCLUDED, "51", null)); + + assertThat(result.unitPrice()).isEqualByComparingTo("13"); + verify(repository).regionTiers(8L, 10L, "MATSON_MAX_PARCEL", "WEST", "TAX_INCLUDED"); + } + @Test void shouldRejectMissingRequiredEstimateFields() { stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE); @@ -206,7 +226,7 @@ class MerchantPriceBookServiceTest { request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null, PricingTaxMode.TAX_INCLUDED, "51", null))) .isInstanceOf(BizException.class) - .hasMessageContaining("目的仓区域或平台FBA仓"); + .hasMessageContaining("目的仓区域"); } @Test diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index e7def5792..bcc7206ba 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -16,6 +16,8 @@ import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -106,6 +108,18 @@ public class InternalTenantController { return ApiResponse.success(tenantService.orderEligibility(tenantId)); } + @GetMapping("/finance-customers") + public ApiResponse> financeCustomers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam(defaultValue = "1") @Min(1) long page, + @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status, + @RequestParam(required = false) Long customerId) { + verify(token); + return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId)); + } + @GetMapping("/exports/customers") public ApiResponse exportCustomers( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 0bf0ca592..4140dd770 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -49,7 +49,7 @@ public class PlatformTenantController { @GetMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:list')") - @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。") + @Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持客户名称和状态筛选。客户编号、联系人等仅作展示信息,不作为搜索条件。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"), diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 728925b1f..bc74467f3 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -124,4 +124,8 @@ public final class TenantDtos { LocalDate establishedDate, BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } + + /** Full customer contact summary for trusted internal finance read models. */ + public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) { } } diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java index 97360fc86..ae4f2d17e 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerListExportProviderRepository.java @@ -44,14 +44,7 @@ public class CustomerListExportProviderRepository { .eq(IamTenant::getTenantType, "MERCHANT") .eq(StringUtils.hasText(status), IamTenant::getStatus, status); if (StringUtils.hasText(keyword)) { - wrapper.and(condition -> condition - .like(IamTenant::getTenantCode, keyword) - .or().like(IamTenant::getTenantName, keyword) - .or().like(IamTenant::getLegalName, keyword) - .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) - .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword) - .or().like(IamTenant::getSalesOwnerName, keyword)); + wrapper.like(IamTenant::getTenantName, keyword); } return wrapper; } diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index f023f730b..a5257de94 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -91,15 +91,28 @@ public class TenantRepository { .eq(IamTenant::getTenantType, "MERCHANT") .eq(StringUtils.hasText(status), IamTenant::getStatus, status) .orderByDesc(IamTenant::getCreatedAt); + if (StringUtils.hasText(keyword)) { + wrapper.like(IamTenant::getTenantName, keyword); + } + return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); + } + + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(customerId != null, IamTenant::getId, customerId) + .in(!StringUtils.hasText(status), IamTenant::getStatus, List.of("ACTIVE", "DISABLED")) + .eq(StringUtils.hasText(status), IamTenant::getStatus, status) + .orderByDesc(IamTenant::getCreatedAt); if (StringUtils.hasText(keyword)) { wrapper.and(condition -> condition - .like(IamTenant::getTenantCode, keyword) - .or().like(IamTenant::getTenantName, keyword) - .or().like(IamTenant::getLegalName, keyword) - .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) - .or().like(IamTenant::getContactName, keyword) - .or().like(IamTenant::getContactPhone, keyword) - .or().like(IamTenant::getSalesOwnerName, keyword)); + .like(IamTenant::getTenantCode, keyword) + .or().like(IamTenant::getTenantName, keyword) + .or().like(IamTenant::getLegalName, keyword) + .or().like(IamTenant::getUnifiedSocialCreditCode, keyword) + .or().like(IamTenant::getContactName, keyword) + .or().like(IamTenant::getContactPhone, keyword) + .or().like(IamTenant::getSalesOwnerName, keyword)); } return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper)); } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index e84f24792..aa3bce84a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -21,6 +21,7 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; import com.baiyue.iam.dto.TenantDtos.RegisterRequest; import com.baiyue.iam.dto.TenantDtos.TenantResponse; +import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary; import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; @@ -198,6 +199,14 @@ public class TenantService { source.page(), source.size(), source.total(), source.pages()); } + public PageResult pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) { + PageResult source = repository.pageFinanceCustomers(page, size, keyword, status, customerId); + return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary( + tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getContactName(), tenant.getContactPhone())).toList(), + source.page(), source.size(), source.total(), source.pages()); + } + public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) { int boundedLimit = Math.min(Math.max(limit, 1), 500); List source = repository.exportMerchants(cursor, boundedLimit, keyword, status); diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 10709a991..00fccfde0 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -44,7 +44,7 @@ paths: parameters: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} - - {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字} + - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} responses: '200': @@ -740,6 +740,39 @@ paths: application/json: schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/finance-customers: + get: + tags: [IAM Resources / IAM 资源] + summary: 提供财务客户主数据摘要 + description: 仅供 Finance Service 调用;只返回正常/停用客户及财务账户所需的客户编号、名称和联系方式,不依赖财务表是否已有记录。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + - {name: keyword, in: query, required: false, schema: {type: string}} + - {name: status, in: query, required: false, schema: {type: string, enum: [ACTIVE, DISABLED]}} + - {name: customerId, in: query, required: false, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 财务客户主数据分页摘要 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/FinanceCustomerSummary'}} + page: {type: integer} + size: {type: integer} + total: {type: integer, format: int64} + pages: {type: integer} + '403': {$ref: '#/components/responses/Forbidden'} /internal/exports/v1/CUSTOMER_LIST/batches: post: tags: [Internal Export Provider] @@ -971,6 +1004,16 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + FinanceCustomerSummary: + type: object + required: [id, tenantCode, tenantName, status] + properties: + id: {type: string, pattern: '^\d+$'} + tenantCode: {type: string} + tenantName: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + contactName: {type: string, nullable: true} + contactPhone: {type: string, nullable: true} TenantAddressListApiResponse: allOf: - {$ref: '#/components/schemas/ApiResponse'} diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index 97345fce8..ca0d0306c 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -40,4 +40,17 @@ class InternalCustomerExportContractTest { assertEquals(ResultCode.SUCCESS.code(), response.code()); verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE"); } + + @Test + void exposesFinanceCustomerMasterDataOnlyWithInternalToken() { + BizException denied = assertThrows(BizException.class, + () -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE")); + assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); + + when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); + var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20, "Alice", "ACTIVE"); + assertEquals(ResultCode.SUCCESS.code(), response.code()); + verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null); + } } diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java index e97c8f356..0205e4283 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerListExportProviderRepositoryTest.java @@ -40,8 +40,8 @@ class CustomerListExportProviderRepositoryTest { assertThat(query.getSqlSegment()) .contains("tenant_type") .contains("status") - .contains("tenant_code") .contains("tenant_name") + .doesNotContain("tenant_code") .contains("id >") .contains("id <=") .contains("ORDER BY id ASC") diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 4b274ddec..37eacb10d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -93,6 +93,24 @@ class TenantServiceTest { verify(repository).pageMerchants(1, 10, "Example", "ACTIVE"); } + @Test + void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setTenantCode("TEN10001"); + tenant.setTenantName("Example Merchant"); + tenant.setContactName("Alice"); + tenant.setContactPhone("13800138000"); + when(repository.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null)) + .thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1)); + + var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + + assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("Alice", result.records().get(0).contactName()); + assertEquals("13800138000", result.records().get(0).contactPhone()); + verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); + } + @Test void platformCanMaintainAndDisableOnlyMerchantTenant() { IamTenant tenant = merchant(10001L, "ACTIVE"); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 2e692db46..361582d2b 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -16,6 +16,23 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over export function normalizeModuleOrder(modules) { const normalized = [...modules] + const manualMatrixIndex = normalized.indexOf('manual-state-matrix') + if (manualMatrixIndex >= 0) { + const internalGroupIndex = normalized.indexOf('order-inbound-status-fixtures') + if (internalGroupIndex >= 0) normalized.splice(internalGroupIndex, 1) + const prerequisites = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'phase3-customs', + 'phase3-loading-finalize', + ] + for (const prerequisite of prerequisites) { + if (!normalized.includes(prerequisite)) { + normalized.splice(normalized.indexOf('manual-state-matrix'), 0, prerequisite) + } + } + } if (normalized.includes('fee-items')) { const prerequisites = ['merchant-qualification-fixture', 'orders'] const feeItemsIndex = normalized.indexOf('fee-items') diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 4633d86a8..8f0f66925 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs' +import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8') @@ -11,14 +13,77 @@ test('default full acceptance includes the retained manual state matrix after li assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'")) }) +test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => { + const expected = [ + 'merchant-qualification-fixture', + 'phase3-container-loading', + 'phase3-loading', + 'phase3-customs', + 'phase3-loading-finalize', + 'manual-state-matrix', + ] + assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected) + assert.deepEqual(parseArguments([ + '--modules', 'order-inbound-status-fixtures,manual-state-matrix', + ]).modules, expected) +}) + test('matrix delegates bounded groups and asserts every declared status has a retained id', () => { for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']) assert.match(source, new RegExp(group)) assert.doesNotMatch(source, /manual-state-customs-dispatch/) assert.match(source, /createdResources\.manualStateMatrix/) + assert.match(source, /supportedStatuses/) assert.match(source, /requiredStatuses/) assert.match(source, /fixtures\?\.\[status\]\?\.id/) assert.match(source, /requiredActions/) assert.match(source, /actions\?\.\[action\]\?\.documentId/) assert.match(source, /actions\?\.\[action\]\?\.completedAt/) }) + +test('fixture-unavailable statuses stay supported without becoming hard requirements', () => { + const matrix = { pages: { + inbounds: { requiredStatuses: ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: { + WAIT_MEASURE: { id: '1' }, + } }, + unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: { + WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' }, + } }, + } } + + normalizeStateRequirements(matrix) + + assert.deepEqual(matrix.pages.inbounds.supportedStatuses, + ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) + assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE']) + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED']) + assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') + assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable') + assert.deepEqual(missingRequiredStateFixtures(matrix), []) +}) + +test('unknown missing status is never swallowed by the known coverage-gap allowlist', () => { + const matrix = { pages: { + unifiedOrders: { supportedStatuses: ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS'], requiredStatuses: [], fixtures: { + WAIT_SPLIT: { id: '1' }, + } }, + } } + + normalizeStateRequirements(matrix) + + assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS']) + assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:UNKNOWN_FUTURE_STATUS']) +}) + +test('LOADING and LOADED fixtures are resolved through inbound and unified-order details', () => { + assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3ContainerLoading', 'LOADING'\)/) + assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3Loading', 'LOADED'\)/) + assert.match(source, /\/api\/platform\/inbounds\/\$\{encodeURIComponent\(inboundId\)\}/) + assert.match(source, /id\(inbound\?\.summary\?\.id\) === inboundId/) + assert.match(source, /inbound\?\.summary\?\.processStatus === expectedStatus/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/) + assert.match(source, /unified\?\.summary\?\.mainStatus === expectedStatus/) + assert.match(source, /matrix\.pages\.unifiedOrders\.fixtures\[expectedStatus\]/) + assert.match(source, /clearResolvedProvisionalGap\(context, expectedStatus\)/) + assert.match(source, /gap\?\.name === `统一订单列表保留 \$\{status\}`/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 27bd9c67d..2bf5d0ff5 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -7,7 +7,100 @@ const GROUPS = [ 'manual-state-ocean-trucking', ] +const FIXTURE_UNAVAILABLE = { + inbounds: { + WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING', + FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路', + INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API', + VOIDED: '当前 Controller/Service 没有作废入库单的公开 API', + }, + unifiedOrders: { + DELIVERED: '当前后端没有完成派送并写入 DELIVERED 的公开业务 API', + }, +} + function rows(value) { return Array.isArray(value) ? value : [] } +function id(value) { return value == null ? '' : String(value) } + +export function normalizeStateRequirements(matrix) { + for (const [page, value] of Object.entries(matrix?.pages || {})) { + const supportedStatuses = [...new Set([ + ...rows(value?.supportedStatuses), ...rows(value?.requiredStatuses), + ])] + const unavailable = FIXTURE_UNAVAILABLE[page] || {} + value.supportedStatuses = supportedStatuses + value.requiredStatuses = supportedStatuses.filter((status) => !unavailable[status]) + if (Object.keys(unavailable).length > 0) { + value.coverageGaps = Object.fromEntries(Object.entries(unavailable).map(([status, reason]) => [status, { + category: 'fixture-unavailable', reason, + }])) + } + } + return matrix +} + +export function missingRequiredStateFixtures(matrix) { + return Object.entries(matrix?.pages || {}).flatMap(([page, value]) => rows(value?.requiredStatuses) + .filter((status) => !value?.fixtures?.[status]?.id) + .map((status) => `${page}:${status}`)) +} + +function registerFixtureUnavailableGaps(context, matrix) { + for (const [page, value] of Object.entries(matrix?.pages || {})) { + for (const [status, gap] of Object.entries(value?.coverageGaps || {})) { + if (value?.fixtures?.[status]?.id) continue + if (rows(context.coverageGaps).some((item) => item?.category === gap.category + && String(item?.name || '').includes(status))) continue + context.addCoverageGap?.(MODULE, OPTIONS.scenario, `${page}:${status}`, gap.reason, + gap.category, { marker: context.marker, page, status }) + } + } +} + +function clearResolvedProvisionalGap(context, status) { + if (!Array.isArray(context.coverageGaps)) return + for (let index = context.coverageGaps.length - 1; index >= 0; index -= 1) { + const gap = context.coverageGaps[index] + if (gap?.module === 'order-inbound-status-fixtures' + && gap?.scenario === 'manual-order-inbound-status-matrix' + && gap?.category === 'fixture-unavailable' + && gap?.name === `统一订单列表保留 ${status}`) context.coverageGaps.splice(index, 1) + } +} + +async function attachUnifiedOrderFixture(context, matrix, resourceName, expectedStatus) { + const resource = context.createdResources?.[resourceName] + const inboundId = id(resource?.inboundId) + const tenantId = id(resource?.tenantId) + context.assert(MODULE, `${expectedStatus} 专项资源已生成`, null, + Boolean(inboundId && tenantId), + `resource=${resourceName}, inboundId=${inboundId || 'missing'}, tenantId=${tenantId || 'missing'}`, OPTIONS) + if (!inboundId || !tenantId) return + + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`) + const inbound = context.apiSuccess(MODULE, `读取 ${expectedStatus} 专项入库详情`, inboundResponse, '', OPTIONS) + const orderId = id(inbound?.summary?.orderId) + context.assert(MODULE, `${expectedStatus} 入库详情身份与状态正确`, inboundResponse, + id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId + && Boolean(orderId) && inbound?.summary?.processStatus === expectedStatus, + `inboundId=${id(inbound?.summary?.id) || 'missing'}, orderId=${orderId || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`, OPTIONS) + if (!orderId || inbound?.summary?.processStatus !== expectedStatus) return + + const unifiedResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`) + const unified = context.apiSuccess(MODULE, `读取 ${expectedStatus} 统一订单详情`, unifiedResponse, '', OPTIONS) + const valid = id(unified?.summary?.orderId) === orderId && unified?.summary?.mainStatus === expectedStatus + context.assert(MODULE, `${expectedStatus} 统一订单详情可打开`, unifiedResponse, valid, + `orderId=${id(unified?.summary?.orderId) || 'missing'}, mainStatus=${unified?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (!valid) return + + matrix.pages.unifiedOrders.fixtures[expectedStatus] = { + id: orderId, status: expectedStatus, inboundId, + detailPath: `/orders/${orderId}`, + } + clearResolvedProvisionalGap(context, expectedStatus) +} export async function run(context) { const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } @@ -19,10 +112,13 @@ export async function run(context) { await extension.run(context, matrix) } + await attachUnifiedOrderFixture(context, matrix, 'phase3ContainerLoading', 'LOADING') + await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED') + normalizeStateRequirements(matrix) + registerFixtureUnavailableGaps(context, matrix) + const pages = Object.entries(matrix.pages) - const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses) - .filter((status) => !value?.fixtures?.[status]?.id) - .map((status) => `${page}:${status}`)) + const missing = missingRequiredStateFixtures(matrix) const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions) .filter((action) => !value?.actions?.[action]?.id || !value?.actions?.[action]?.documentId || !value?.actions?.[action]?.completedAt) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index ba65e3d2b..ad246eee5 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -11,6 +11,14 @@ test('retains every ocean booking and truck appointment list status through real 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED', 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) assert.match(source, /change-request/) + const draftPayload = source.slice(source.indexOf('function oceanPayload'), source.indexOf('async function createOcean')) + for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.doesNotMatch(draftPayload, new RegExp(`${field}:`)) + } + const lifecycle = source.slice(source.indexOf('async function buildOcean'), source.indexOf('function truckPayload')) + for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.match(lifecycle, new RegExp(`${field}:`)) + } assert.match(source, /\/reschedule\?plannedLoadingAt=/) assert.match(source, /page=1&size=100&status=/) assert.match(source, /customerId: oceanBooking\.customerId/) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 96f7214e5..099820204 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -17,18 +17,17 @@ async function post(context, path, body, operation) { } function oceanPayload(context, template, index) { - const value = suffix(context, index); const source = template.oceanBooking + const source = template.oceanBooking return { expectedVersion: 0, data: { customerId: source.customerId, customerName: source.customerName, channelCode: source.channelCode, channelName: source.channelName, warehouseId: source.warehouseId, warehouseName: source.warehouseName, - billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId, + shippingCompanyId: source.shippingCompanyId, shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, - bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`, + bookingAgentName: source.bookingAgentName, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, - estimatedSailingAt: '2026-09-15T08:00:00', estimatedArrivalAt: '2026-10-15T08:00:00', remark: `${context.marker} 人工列表状态 ${index}`, parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => ({ partyType, partyName, address, contactName, contactPhone })), diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index e3d5275d5..8a1324fdb 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -45,7 +45,11 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', ( test('测量状态截停发生在正确的 HTTP 边界', () => { assert.match(source, /includes\(summary\.mainStatus\)/) assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) - assert.match(source, /target === 'MEASURING' && ok\(response\) && data\(response\)\?\.measurementId/) + assert.match(source, /target === 'MEASURING'/) + assert.match(source, /\.\/inbound-weight-ocr\.mjs/) + assert.match(source, /await weightOcr\.run\(context\)/) + assert.match(source, /target !== 'MEASURING'/) + assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/) assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) assert.match(source, /measurements\/reject/) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index f1e87501f..d2e626fe4 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -174,14 +174,13 @@ async function prepareInboundStatus(context, target) { if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`) const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}` + if (target === 'MEASURING') { + const weightOcr = await import(new URL('./inbound-weight-ocr.mjs', import.meta.url)) + await weightOcr.run(context) + } + context.request = async (method, path, body, token, headers) => { const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) - const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`) - if (isMeasurementQuery) { - const response = await originalRequest.call(context, method, path, body, token, headers) - if (target === 'MEASURING' && ok(response) && data(response)?.measurementId) throw stop - return response - } if (target === 'MEASURE_PENDING' && isSubmit) { const response = await originalRequest.call(context, method, path, body, token, headers) if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`) @@ -201,13 +200,15 @@ async function prepareInboundStatus(context, target) { } return originalRequest.call(context, method, path, body, token, headers) } - try { - const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) - await measurement.run(context) - } catch (error) { - if (error !== stop) throw error - } finally { - context.request = originalRequest + if (target !== 'MEASURING') { + try { + const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) + await measurement.run(context) + } catch (error) { + if (error !== stop) throw error + } finally { + context.request = originalRequest + } } const response = await originalRequest.call(context, 'GET', @@ -232,6 +233,11 @@ async function prepareInboundStatus(context, target) { } export async function run(context, matrix = context.createdResources.manualStateMatrix) { + if (!matrix) { + matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } + context.createdResources.manualStateMatrix = matrix + } + matrix.pages ||= {} const loginResponse = await context.request('POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '') const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken From 06c264db0ac6776ddd7f1bbef5c53e37947a6605 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 13:03:09 +0800 Subject: [PATCH 005/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 ++- .../firstleg/mapper/ContainerBookingMapper.java | 5 +++++ .../ContainerBookingMapperContractTest.java | 16 ++++++++++++++++ .../LoadingListQueryMapperContractTest.java | 2 ++ .../TruckAppointmentMapperContractTest.java | 2 +- .../firstleg/service/LoadingListServiceTest.java | 6 +++--- .../InternalCustomerExportContractTest.java | 5 +++-- 7 files changed, 32 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 63a126216..39dcd403f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,12 +42,13 @@ ## 契约、数据库与验证入口 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 +- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 数据库只访问所属服务库。模块迁移位于 `/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 -- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 +- Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 ## 全量 HTTP 验收脚本路由 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 37b000929..77c8874d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -25,6 +25,11 @@ public interface ContainerBookingMapper extends BaseMapper(List.of(), 1, 20, 0, 0)); service.confirmedPage(query); verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value -> - "plan".equals(value.keyword()))); + "plan".equals(value.keyword()) && Long.valueOf(200L).equals(value.customerId()))); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java index ca0d0306c..ca4efc0c5 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/InternalCustomerExportContractTest.java @@ -44,12 +44,13 @@ class InternalCustomerExportContractTest { @Test void exposesFinanceCustomerMasterDataOnlyWithInternalToken() { BizException denied = assertThrows(BizException.class, - () -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE")); + () -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE", null)); assertEquals(ResultCode.FORBIDDEN, denied.getResultCode()); when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null)) .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); - var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20, "Alice", "ACTIVE"); + var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20, + "Alice", "ACTIVE", null); assertEquals(ResultCode.SUCCESS.code(), response.code()); verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null); } From 261a8bf2df9d9764beaf45f0cb1fb1efc5292063 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 14:26:56 +0800 Subject: [PATCH 006/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/audit/BaiyueAuditApplication.java | 3 + .../BaiyueAuditApplicationContractTest.java | 17 ++ .../service/AuditLogQueryServiceTest.java | 13 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 67 +++++--- .../InternalLoadingEvidenceController.java | 24 +++ .../PlatformLoadingEvidenceController.java | 11 +- ...InternalLoadingEvidenceControllerTest.java | 52 ++++++ firstleg/AGENTS.md | 2 +- .../client/LoadingEvidenceFileClient.java | 6 + .../InternalPriceBookController.java | 6 +- .../PlatformContainerController.java | 12 ++ .../PlatformFbaPricingController.java | 2 +- ...ormInboundMeasurementReviewController.java | 19 +-- .../domain/PriceBookChannelCatalog.java | 46 +++--- .../firstleg/dto/ContainerOverviewDtos.java | 2 +- .../baiyue/firstleg/dto/FbaPricingDtos.java | 5 +- .../dto/MeasurementApprovalResponse.java | 13 ++ .../dto/PriceBookChannelValidation.java | 6 +- .../entity/PriceChannelCatalogEntity.java | 2 + .../entity/TruckAppointmentEntity.java | 9 ++ .../firstleg/mapper/InboundOrderMapper.java | 7 +- .../firstleg/mapper/PriceBookQueryMapper.java | 10 ++ .../repository/FbaPricingRepository.java | 19 ++- .../repository/PriceBookQueryRepository.java | 4 + .../repository/PriceBookQueryRows.java | 3 + .../AutomaticInboundCompletionService.java | 70 -------- .../service/ContainerOverviewService.java | 43 ++++- .../firstleg/service/FbaPricingService.java | 51 +++--- .../InboundCompletionOrchestrator.java | 147 ----------------- .../service/InitialInboundPricingService.java | 40 +++++ .../service/MeasurementReviewService.java | 6 +- .../service/MerchantPriceBookService.java | 15 +- .../service/TruckAppointmentService.java | 11 +- .../resources/db/phase2-inbound-schema.sql | 39 +++-- .../resources/openapi/fba-pricing-v1.yaml | 10 +- .../main/resources/openapi/firstleg-v1.yaml | 10 +- .../openapi/phase3-container-overview-v1.yaml | 8 +- .../openapi/truck-appointments-v1.yaml | 53 ++++++- .../ContainerControllerContractTest.java | 4 +- .../controller/FbaPricingContractTest.java | 6 +- .../InboundMeasurementReviewContractTest.java | 45 ++---- .../controller/PriceBookContractTest.java | 6 + ...ruckAppointmentControllerContractTest.java | 1 + .../InboundOrderStatusSqlContractTest.java | 7 +- ...AutomaticInboundCompletionServiceTest.java | 92 ----------- .../service/ContainerOverviewServiceTest.java | 65 +++++++- .../service/FbaPricingServiceTest.java | 71 ++++++++- .../InboundCompletionOrchestratorTest.java | 150 ------------------ .../InitialInboundPricingServiceTest.java | 46 ++++++ .../service/MeasurementReviewServiceTest.java | 2 +- .../service/MerchantPriceBookServiceTest.java | 27 ++++ .../service/TruckAppointmentServiceTest.java | 33 +++- .../order/client/FirstlegPricingClient.java | 8 +- .../entity/InboundCodeSequenceEntity.java | 16 ++ .../mapper/InboundCodeSequenceMapper.java | 13 ++ .../order/repository/OrderRepository.java | 9 ++ .../service/OrderApplicationService.java | 41 +++-- .../service/OrderPricingChannelGuard.java | 13 +- order/src/main/resources/db/order.sql | 8 +- .../src/main/resources/openapi/order-v1.yaml | 8 +- .../OrderApplicationServiceStateTest.java | 28 +++- .../service/OrderPricingChannelGuardTest.java | 20 +-- .../phase3-container-overview.mjs | 35 +++- ...se3-container-regression-contract.test.mjs | 83 ++++++++++ .../truck-appointments.mjs | 10 +- 65 files changed, 1040 insertions(+), 670 deletions(-) create mode 100644 audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java create mode 100644 file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java delete mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java delete mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java create mode 100644 order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java create mode 100644 scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs diff --git a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java index a981e9876..4c9070ed9 100644 --- a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java +++ b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java @@ -1,8 +1,10 @@ package com.baiyue.audit; +import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; import org.springframework.scheduling.annotation.EnableScheduling; /** @@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; */ @MapperScan("com.baiyue.audit.mapper") @EnableScheduling +@Import(MybatisPlusConfiguration.class) @SpringBootApplication public class BaiyueAuditApplication { diff --git a/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java b/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java new file mode 100644 index 000000000..20debe129 --- /dev/null +++ b/audit-service/src/test/java/com/baiyue/audit/BaiyueAuditApplicationContractTest.java @@ -0,0 +1,17 @@ +package com.baiyue.audit; + +import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaiyueAuditApplicationContractTest { + @Test + void importsSharedMybatisPaginationConfiguration() { + Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class); + + assertThat(imported).isNotNull(); + assertThat(imported.value()).contains(MybatisPlusConfiguration.class); + } +} diff --git a/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java index 96cae3c54..d6f445396 100644 --- a/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java +++ b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java @@ -45,6 +45,19 @@ class AuditLogQueryServiceTest { .doesNotContain("beforeData", "afterData"); } + @Test + void pagePreservesTotalAndPagesForCustomerLogPagination() { + AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null, + null, null, null); + when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1)); + + PageResult result = service.page(query, 1, 20); + + assertThat(result.records()).hasSize(1); + assertThat(result.total()).isEqualTo(13); + assertThat(result.pages()).isEqualTo(1); + } + @Test void detailReturnsSanitizedSnapshots() { when(repository.findById(8L)).thenReturn(log(8L)); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8c9c13d1f..786107859 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -515,7 +515,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', + inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+全局票序', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -561,6 +561,12 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +CREATE TABLE IF NOT EXISTS ord_inbound_code_sequence ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '全平台入仓码票序', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '票序分配时间', + PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='入仓码全局票序;不同客户和物流渠道共用且不按日期清零'; + CREATE TABLE IF NOT EXISTS ord_order_party ( id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3441,6 +3447,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', 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 '最后更新时间', @@ -3449,6 +3457,26 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( KEY idx_price_channel_status (status, price_model) ) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录'; +DROP PROCEDURE IF EXISTS price_channel_add_column; +DELIMITER $$ +CREATE PROCEDURE price_channel_add_column(IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = 'baiyue_firstleg' AND TABLE_NAME = 'fl_price_channel_catalog' + AND COLUMN_NAME = p_column + ) THEN + SET @ddl = CONCAT('ALTER TABLE baiyue_firstleg.fl_price_channel_catalog ADD COLUMN ', p_definition); + PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL price_channel_add_column('inbound_code_prefix', + "inbound_code_prefix VARCHAR(6) NULL COMMENT '普通目的仓入仓码物流前缀' AFTER aliases_json"); +CALL price_channel_add_column('overseas_inbound_code_prefix', + "overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀' AFTER inbound_code_prefix"); +DROP PROCEDURE IF EXISTS price_channel_add_column; + CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID', @@ -3532,26 +3560,29 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel ( ) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'; INSERT INTO fl_price_channel_catalog - (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status) + (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, + inbound_code_prefix, overseas_inbound_code_prefix, status) VALUES - (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), - (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), - (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), - (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), - (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'), - (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'), - (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), - (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), - (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), - (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), - (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), - (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), - (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), - (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), - (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') + (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ZBYHK', 'ZBYHWK', 'ACTIVE'), + (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'BYHK', 'BYHWK', 'ACTIVE'), + (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'EXXHK', 'EXXHWK', 'ACTIVE'), + (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'HDHK', 'HDHWK', 'ACTIVE'), + (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'NYEXX', NULL, 'ACTIVE'), + (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'PCHK', 'PCHWK', 'ACTIVE'), + (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'PCKK', NULL, 'ACTIVE'), + (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'CPHT', NULL, 'ACTIVE'), + (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'HPHK', NULL, 'ACTIVE'), + (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'NPHK', 'NPHWK', 'ACTIVE'), + (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'SPHK', NULL, 'ACTIVE'), + (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ZBYHP', NULL, 'ACTIVE'), + (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'BYHP', NULL, 'ACTIVE'), + (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'HDHP', NULL, 'ACTIVE'), + (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'PCHP', NULL, 'ACTIVE') ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), price_model = VALUES(price_model), - source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status); + source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), + inbound_code_prefix = VALUES(inbound_code_prefix), + overseas_inbound_code_prefix = VALUES(overseas_inbound_code_prefix), status = VALUES(status); INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status) VALUES diff --git a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java index d40d27952..bd34ddc72 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalLoadingEvidenceController.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.*; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -43,9 +44,32 @@ public class InternalLoadingEvidenceController { return ApiResponse.success(service.validate(tenantId, inboundId, fileId)); } + @GetMapping("/{tenantId}/{inboundId}/{fileId}/content") + public ResponseEntity download( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { + requireToken(token); + if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); + var value = service.download(tenantId, inboundId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content()); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); } } + + private static MediaType safeImageMediaType(String value) { + if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM; + try { + MediaType mediaType = MediaType.parseMediaType(value); + return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM; + } catch (org.springframework.http.InvalidMediaTypeException exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } } diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java index 71b5261ef..b7901c4ea 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformLoadingEvidenceController.java @@ -36,7 +36,7 @@ public class PlatformLoadingEvidenceController { requirePlatform(user); requirePositive(tenantId, inboundId, fileId); var value = service.download(tenantId, inboundId, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) - .contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length) + .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content()); } @@ -51,4 +51,13 @@ public class PlatformLoadingEvidenceController { private static void requirePositive(long... ids) { for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0"); } + private static MediaType safeImageMediaType(String value) { + if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM; + try { + MediaType mediaType = MediaType.parseMediaType(value); + return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM; + } catch (InvalidMediaTypeException exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } } diff --git a/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java new file mode 100644 index 000000000..6fb7bfd37 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalLoadingEvidenceControllerTest.java @@ -0,0 +1,52 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.service.LoadingEvidenceService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalLoadingEvidenceController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class InternalLoadingEvidenceControllerTest { + @MockBean LoadingEvidenceService service; + @Resource InternalLoadingEvidenceController controller; + + @Test void downloadsOwnedEvidenceBehindInternalToken() { + when(service.download(81L, 91L, 101L)).thenReturn( + new LoadingEvidenceService.Download("door.jpg", "image/jpeg", new byte[]{1, 2, 3})); + + var response = controller.download("internal-token", 81L, 91L, 101L); + + assertThat(response.getBody()).containsExactly(1, 2, 3); + assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg"); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + verify(service).download(81L, 91L, 101L); + } + + @Test void rejectsInvalidTokenBeforeReadingEvidence() { + assertThatThrownBy(() -> controller.download("wrong", 81L, 91L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); + verifyNoInteractions(service); + } + + @Test void fallsBackForInvalidOrNonImageContentTypes() { + when(service.download(81L, 91L, 101L)).thenReturn( + new LoadingEvidenceService.Download("door.jpg", "not a/media/type", new byte[]{1})); + when(service.download(81L, 91L, 102L)).thenReturn( + new LoadingEvidenceService.Download("door.txt", "text/plain", new byte[]{2})); + + assertThat(controller.download("internal-token", 81L, 91L, 101L).getHeaders().getContentType()) + .isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + assertThat(controller.download("internal-token", 81L, 91L, 102L).getHeaders().getContentType()) + .isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 47b35feb5..15502383a 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -39,7 +39,7 @@ - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 -- 测量审核→计费确认→费用发布→入库完成不得跳过门禁;状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 +- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 ## 三期装柜与 PDA 资源结算 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java index ec120df69..7568c3de9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java @@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.http.ResponseEntity; /** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */ @FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient", @@ -15,5 +16,10 @@ public interface LoadingEvidenceFileClient { @PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId); + @GetMapping("/{tenantId}/{inboundId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long inboundId, + @PathVariable long fileId); + record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 0743eb962..362df4a5a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -31,12 +31,12 @@ public class InternalPriceBookController { } @PostMapping("/channels/validate") - public ApiResponse validateChannel( + public ApiResponse validateChannel( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PriceBookChannelValidation.Request request) { requireToken(token); - service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode()); - return ApiResponse.success(null); + return ApiResponse.success(service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), + request.channelCode(), request.destinationType())); } private void requireToken(String token) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index f8226b3cc..d061d5204 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -55,6 +55,18 @@ public class PlatformContainerController { public ApiResponse overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) { FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id)); } + @GetMapping("/{id}/pda/photos/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ResponseEntity downloadPdaPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @PathVariable @Positive long fileId) { + FirstlegScopeGuard.platform(user); + var value = overview.downloadPhoto(id, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline() + .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(value.contentType()).contentLength(value.content().length).body(value.content()); + } @PostMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") public ApiResponse markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index 12eea8342..57813a240 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -79,7 +79,7 @@ public class PlatformFbaPricingController { @PutMapping("/published") @PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')") @BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId", - description = "调整已发布入库费用,不回退入库状态") + description = "调整初算或已发布入库费用,不回退待分柜状态") public ApiResponse adjustPublished( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java index 5faba346d..de04be97c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementReviewController.java @@ -8,11 +8,11 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest; +import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.service.MeasurementReviewService; -import com.baiyue.firstleg.service.AutomaticInboundCompletionService; +import com.baiyue.firstleg.service.InitialInboundPricingService; import com.baiyue.firstleg.service.FirstlegScopeGuard; -import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -36,15 +36,15 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/platform/v1/inbounds") public class PlatformInboundMeasurementReviewController { @Resource private MeasurementReviewService service; - @Resource private AutomaticInboundCompletionService automaticCompletion; + @Resource private InitialInboundPricingService initialPricing; @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "测量审核通过") @Operation(summary = "测量审核通过", - description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。") + description = "客服确认测量数据后直接进入待分柜,并按已审核测量数据尝试生成初算费用;初算失败不阻塞状态流转。") @PostMapping("/{inboundId}/measurements/approve") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") - public ApiResponse approve( + public ApiResponse approve( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId, @@ -52,12 +52,9 @@ public class PlatformInboundMeasurementReviewController { @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody MeasurementReviewRequest request) { validateScope(user, tenantId); - // 审核服务事务在此调用返回时已提交;随后自动编排从新事务/新查询读取 WAIT_PRICING, - // 避免在未提交的审核事务中读取旧状态。自动流程失败时保留已审核状态,原样返回业务错误, - // 使用同一幂等键重试会从持久化阶段继续推进。 service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); - return ApiResponse.success(automaticCompletion.completeApproved( - tenantId, inboundId, user.userId(), user.username(), request.measurementId(), request.expectedVersion() + 1)); + return ApiResponse.success(initialPricing.tryCalculate(tenantId, user.userId(), inboundId, + request.measurementId(), request.expectedVersion() + 1)); } @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", @@ -80,7 +77,7 @@ public class PlatformInboundMeasurementReviewController { @BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND", resourceId = "#inboundId", description = "测量驳回异常放行") @Operation(summary = "测量驳回异常放行", - description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。") + description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_SPLIT。") @PostMapping("/{inboundId}/measurements/exception-release") @PreAuthorize("hasAuthority('firstleg:inbound:exception-release')") public ApiResponse exceptionRelease( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java index 6c14bd1ac..0766bf4f9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java @@ -8,29 +8,33 @@ public final class PriceBookChannelCatalog { private PriceBookChannelCatalog() {} public static final List CHANNELS = List.of( - fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"), - fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"), - fba("EXX_TRUCK", "EXX海卡", "EXX海卡"), - fba("HEDE_TRUCK", "合德海卡", "合德海卡"), - fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"), - fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"), - fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"), - fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"), - fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"), - fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"), - fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"), - parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"), - parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"), - parcel("HEDE_PARCEL", "合德海派", "海派"), - parcel("OA_PARCEL", "普船海派", "海派") + fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"), + fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"), + fba("EXX_TRUCK", "EXX海卡", "EXX海卡", "EXXHK", "EXXHWK"), + fba("HEDE_TRUCK", "合德海卡", "合德海卡", "HDHK", "HDHWK"), + fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁", "NYEXX", null), + fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡", "PCHK", "PCHWK"), + fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡", "PCKK", null), + fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁", "CPHT", null), + fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡", "HPHK", null), + fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡", "NPHK", "NPHWK"), + fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡", "SPHK", null), + parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派", "ZBYHP", null), + parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派", "BYHP", null), + parcel("HEDE_PARCEL", "合德海派", "海派", "HDHP", null), + parcel("OA_PARCEL", "普船海派", "海派", "PCHP", null) ); - private static ChannelDefinition fba(String code, String name, String sheet) { - return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name)); + private static ChannelDefinition fba(String code, String name, String sheet, + String inboundCodePrefix, String overseasInboundCodePrefix) { + return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name), + inboundCodePrefix, overseasInboundCodePrefix); } - private static ChannelDefinition parcel(String code, String name, String sheet) { - return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name)); + private static ChannelDefinition parcel(String code, String name, String sheet, + String inboundCodePrefix, String overseasInboundCodePrefix) { + return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name), + inboundCodePrefix, overseasInboundCodePrefix); } public record ChannelDefinition( @@ -38,6 +42,8 @@ public final class PriceBookChannelCatalog { String displayName, PriceModel priceModel, String sourceSheetName, - Set aliases + Set aliases, + String inboundCodePrefix, + String overseasInboundCodePrefix ) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 623a50887..8abce64c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -16,7 +16,7 @@ public final class ContainerOverviewDtos { String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, - Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, + String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, List photoCounts, List photos, List exceptionReasons) {} public record PhotoCount(String evidenceType, Integer count) {} public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index b425c98b3..dbec6b7cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -52,7 +53,9 @@ public final class FbaPricingDtos { public record CalculateRequest(LocalDateTime pricingAt) {} public record AdjustFeeRequest( - @NotNull @DecimalMin("0.00") BigDecimal subtotalAmount, + @NotNull @Positive Integer expectedFeeVersion, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, + @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotNull @DecimalMin("0.00") BigDecimal additionalAmount, @NotBlank @Size(max = 500) String reason ) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java new file mode 100644 index 000000000..84a4da9b9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementApprovalResponse.java @@ -0,0 +1,13 @@ +package com.baiyue.firstleg.dto; + +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; + +public record MeasurementApprovalResponse( + Long inboundId, + Long measurementId, + String processStatus, + String feeStatus, + boolean initialPricingSucceeded, + String initialPricingMessage, + FeeSnapshotResponse initialFee +) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java index 10c337bc9..8fc24341b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; public final class PriceBookChannelValidation { private PriceBookChannelValidation() {} @@ -10,6 +11,9 @@ public final class PriceBookChannelValidation { public record Request( @Min(1) long tenantId, @Min(1) long originWarehouseId, - @NotBlank @Size(max = 64) String channelCode + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType ) {} + + public record Response(String channelCode, String inboundCodePrefix) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java index 813ad1063..dba5884f6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java @@ -15,5 +15,7 @@ public class PriceChannelCatalogEntity { private String priceModel; private String sourceSheetName; private String aliasesJson; + private String inboundCodePrefix; + private String overseasInboundCodePrefix; private String status; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index c8afd1f65..0f9e86b9b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableField; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; @@ -53,4 +54,12 @@ public class TruckAppointmentEntity { private String remark; private LocalDateTime createdAt; private LocalDateTime updatedAt; + @TableField(exist = false) private String oceanBookingNo; + @TableField(exist = false) private String warehouseName; + @TableField(exist = false) private Long shippingCompanyId; + @TableField(exist = false) private String shippingCompanyName; + @TableField(exist = false) private Long bookingAgentId; + @TableField(exist = false) private String bookingAgentName; + @TableField(exist = false) private String cabinetNo; + @TableField(exist = false) private String containerNo; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index 424320b0a..922ab2fb5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update; public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED', + SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', actual_gross_weight_kg = (SELECT COALESCE(SUM(item.total_weight_kg), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), @@ -21,7 +21,7 @@ public interface InboundOrderMapper extends BaseMapper { volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), - updated_by = #{operatorId}, version = version + 1 + inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' AND version = #{expectedVersion} @@ -45,7 +45,8 @@ public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'WAIT_PRICING', updated_by = #{operatorId}, version = version + 1 + SET process_status = 'WAIT_SPLIT', inbounded_at = CURRENT_TIMESTAMP(3), + updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED' AND version = #{expectedVersion} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java index d6c69001c..f86961189 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -12,6 +13,15 @@ import java.util.List; /** Dedicated read model for merchant channel selection and reference estimates. */ @Mapper public interface PriceBookQueryMapper { + @Select(""" + SELECT channel_code AS channelCode, inbound_code_prefix AS inboundCodePrefix, + overseas_inbound_code_prefix AS overseasInboundCodePrefix + FROM fl_price_channel_catalog + WHERE channel_code = #{channelCode} AND status = 'ACTIVE' + LIMIT 1 + """) + ChannelPrefixRow findChannelPrefix(@Param("channelCode") String channelCode); + @Select(""" SELECT id FROM fl_price_book_version diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index e8b16f2d6..e0f5201de 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -192,15 +192,30 @@ public class FbaPricingRepository { .orderByAsc(InboundFeeItemEntity::getItemNo)); } - public int supersedeSent(long tenantId, long feeId) { + public int supersedeCurrent(long tenantId, long feeId, String feeStatus) { return fees.update(null, new LambdaUpdateWrapper() .eq(InboundFeeEntity::getTenantId, tenantId) .eq(InboundFeeEntity::getId, feeId) - .eq(InboundFeeEntity::getFeeStatus, "SENT") + .eq(InboundFeeEntity::getFeeStatus, feeStatus) .set(InboundFeeEntity::getFeeStatus, "SUPERSEDED") .set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now())); } + public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus, + String chargeUnit, java.math.BigDecimal chargeableQuantity) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getId, inboundId) + .eq(InboundPricingEntity::getProcessStatus, "WAIT_SPLIT") + .set(InboundPricingEntity::getFeeStatus, feeStatus); + if ("KG".equals(chargeUnit)) { + update.set(InboundPricingEntity::getChargeableWeightKg, chargeableQuantity); + } else { + update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeableQuantity); + } + return inbounds.update(null, update); + } + public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode, java.math.BigDecimal chargeable) { LambdaUpdateWrapper update = new LambdaUpdateWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java index d7c39b579..1161bf9e3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java @@ -46,4 +46,8 @@ public class PriceBookQueryRepository { return channels(versionId, originWarehouseId).stream() .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); } + + public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) { + return mapper.findChannelPrefix(channelCode); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java index 916fe8869..dc8395fc6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java @@ -10,6 +10,9 @@ import java.time.LocalDate; public final class PriceBookQueryRows { private PriceBookQueryRows() {} + public record ChannelPrefixRow(String channelCode, String inboundCodePrefix, + String overseasInboundCodePrefix) {} + @Getter @Setter public static class ChannelRow { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java deleted file mode 100644 index 6db70d765..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/AutomaticInboundCompletionService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.baiyue.firstleg.service; - -import jakarta.annotation.Resource; - -import com.baiyue.common.audit.BusinessAudit; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import org.springframework.stereotype.Service; - -@Service -public class AutomaticInboundCompletionService { - @Resource - private FbaPricingService pricing; - @Resource - private InboundWorkflowRepository workflow; - @Resource - private InboundCompletionOrchestrator completion; - - @BusinessAudit(event = "INBOUND_AUTO_COMPLETE", resourceType = "INBOUND", resourceId = "#inboundId", - description = "测量审核通过后自动计费、发送费用并完成入库", captureBefore = false) - public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId, - String operatorName, long measurementId, - int reviewedOrderVersion) { - String key = "auto-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion; - InboundOrderEntity order = requireOrder(tenantId, inboundId); - if (completed(order)) return pricing.latest(tenantId, inboundId); - - if ("WAIT_PRICING".equals(order.getProcessStatus()) && "UNPRICED".equals(order.getFeeStatus())) { - pricing.calculate(tenantId, operatorId, inboundId, null, key + "-calculate"); - order = requireOrder(tenantId, inboundId); - } - if ("FEE_CONFIRMING".equals(order.getProcessStatus()) - && "CALCULATED".equals(order.getFeeStatus())) { - pricing.confirm(tenantId, operatorId, inboundId, key + "-confirm"); - order = requireOrder(tenantId, inboundId); - } - - FeeSnapshotResponse fee = pricing.latest(tenantId, inboundId); - if ("FEE_CONFIRMING".equals(order.getProcessStatus()) - && "CONFIRMED".equals(order.getFeeStatus())) { - completion.beginSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId, - fee.feeId(), order.getVersion(), key + "-begin"); - order = requireOrder(tenantId, inboundId); - } - if ("INBOUNDING".equals(order.getProcessStatus()) - && "CONFIRMED".equals(order.getFeeStatus())) { - completion.completeSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId, - fee.feeId(), order.getVersion(), key + "-complete"); - order = requireOrder(tenantId, inboundId); - } - if (!completed(order)) { - throw new BizException(ResultCode.CONFLICT, "客服审核后的自动计费入库流程状态异常"); - } - return pricing.latest(tenantId, inboundId); - } - - private static boolean completed(InboundOrderEntity order) { - return "WAIT_SPLIT".equals(order.getProcessStatus()) - && "SENT".equals(order.getFeeStatus()); - } - - private InboundOrderEntity requireOrder(long tenantId, long inboundId) { - InboundOrderEntity order = workflow.findOwned(tenantId, inboundId); - if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - return order; - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 504170b88..1c0b769cd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -8,10 +8,14 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import feign.FeignException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -25,6 +29,8 @@ import java.util.List; public class ContainerOverviewService { @Resource private ContainerRepository containers; @Resource private ContainerOverviewMapper mapper; + @Resource private LoadingEvidenceFileClient evidenceFiles; + @Value("${baiyue.security.internal-service-token}") private String internalToken; public Overview overview(long id) { ContainerBookingEntity booking = required(id); @@ -94,18 +100,47 @@ public class ContainerOverviewService { private PdaSummary pda(ContainerOverviewMapper.PickTaskRow task) { List photos = mapper.photos(task.loadingPlanId()).stream().map(value -> new Photo(value.evidenceId(), value.tenantId(), value.inboundId(), value.evidenceType(), value.fileId(), value.fileName(), - value.status(), "/api/platform/v1/files/loading-evidence/" + value.tenantId() + '/' - + value.inboundId() + '/' + value.fileId(), value.uploadedAt())).toList(); + value.status(), "/api/platform/v1/containers/" + task.containerBookingId() + "/pda/photos/" + + value.fileId() + "/download", value.uploadedAt())).toList(); var counts = new LinkedHashMap(); photos.stream().filter(photo -> "ACTIVE".equals(photo.status())) .forEach(photo -> counts.merge(photo.evidenceType(), 1, Integer::sum)); List photoCounts = counts.entrySet().stream() .map(entry -> new PhotoCount(entry.getKey(), entry.getValue())).toList(); + int itemCount = mapper.itemCount(task.id()); + int unresolvedItemCount = mapper.unresolvedItemCount(task.id()); + String workflowStatus = "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" + : unresolvedItemCount == 0 ? "AWAITING_REVIEW" : "IN_PROGRESS"; return new PdaSummary(task.id(), task.loadingPlanId(), task.containerBookingId(), task.taskStatus(), - task.version(), mapper.itemCount(task.id()), mapper.completedItemCount(task.id()), - mapper.unresolvedItemCount(task.id()), photoCounts, photos, mapper.pickExceptionReasons(task.id())); + workflowStatus, task.version(), itemCount, mapper.completedItemCount(task.id()), + unresolvedItemCount, photoCounts, photos, mapper.pickExceptionReasons(task.id())); } + public PhotoDownload downloadPhoto(long containerBookingId, long fileId) { + ContainerBookingEntity booking = required(containerBookingId); + var task = mapper.pickTask(booking.getId()); + if (task == null) throw new BizException(ResultCode.NOT_FOUND, "货柜尚无PDA作业任务"); + var photo = mapper.photos(task.loadingPlanId()).stream() + .filter(value -> Long.valueOf(fileId).equals(value.fileId()) && "ACTIVE".equals(value.status())) + .findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "PDA照片不存在或不属于该货柜")); + final org.springframework.http.ResponseEntity response; + try { + response = evidenceFiles.download(internalToken, photo.tenantId(), photo.inboundId(), fileId); + } catch (FeignException.NotFound exception) { + throw new BizException(ResultCode.NOT_FOUND, "PDA照片文件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + byte[] content = response.getBody(); + if (!response.getStatusCode().is2xxSuccessful() || content == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "PDA照片下载失败"); + } + MediaType contentType = response.getHeaders().getContentType(); + return new PhotoDownload(photo.fileName(), contentType == null ? MediaType.APPLICATION_OCTET_STREAM : contentType, content); + } + + public record PhotoDownload(String fileName, MediaType contentType, byte[] content) {} + private List timeline(long id, Long taskId, List exceptionHistory) { var result = new ArrayList(); mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 92885d7ff..1be21ac71 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -144,14 +144,18 @@ public class FbaPricingService { AdjustFeeRequest request, String idempotencyKey) { InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - if (!isCompletedInbound(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) { - throw conflict("只能修改已完成入库且已发布的费用"); + if (!isCompletedInbound(inbound.getProcessStatus())) { + throw conflict("只能在待分柜阶段修改初算或已发布费用"); } InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); - if (current == null || !"SENT".equals(current.getFeeStatus())) { - throw conflict("入库单没有可修改的已发布费用"); + if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { + throw conflict("入库单没有可修改的初算或已发布费用"); } - String discriminator = current.getId() + ":" + request.subtotalAmount() + ":" + if (!request.expectedFeeVersion().equals(current.getFeeVersion())) { + throw conflict("费用版本已变化,请刷新后重试"); + } + String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":" + + request.chargeableQuantity() + ":" + request.unitPrice() + ":" + request.additionalAmount() + ":" + request.reason().trim(); InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); @@ -166,30 +170,33 @@ public class FbaPricingService { adjusted.setId(null); adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion); adjusted.setFeeVersion(nextVersion); - adjusted.setSubtotalAmount(request.subtotalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)); + adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP)); + adjusted.setConvertedQuantity(adjusted.getChargeableQuantity()); + adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice()) + .setScale(MONEY_SCALE, RoundingMode.HALF_UP)); adjusted.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP)); adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount())); - adjusted.setFeeStatus("SENT"); - adjusted.setReviewStatus("NOT_REQUIRED"); + boolean published = "SENT".equals(current.getFeeStatus()); + adjusted.setFeeStatus(published ? "SENT" : "CALCULATED"); + adjusted.setReviewStatus(published ? "NOT_REQUIRED" : "NOT_SUBMITTED"); adjusted.setCalculatedBy(operatorId); adjusted.setCalculatedAt(now); - adjusted.setConfirmedBy(operatorId); - adjusted.setConfirmedAt(now); - adjusted.setSentBy(operatorId); - adjusted.setSentAt(now); + adjusted.setConfirmedBy(published ? operatorId : null); + adjusted.setConfirmedAt(published ? now : null); + adjusted.setSentBy(published ? operatorId : null); + adjusted.setSentAt(published ? now : null); adjusted.setCreatedAt(now); adjusted.setUpdatedAt(now); - if (repository.supersedeSent(tenantId, current.getId()) != 1) { + if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) { throw conflict("费用已被其他操作员修改,请刷新后重试"); } repository.insertFee(adjusted); - BigDecimal quantity = current.getChargeableQuantity(); + BigDecimal quantity = adjusted.getChargeableQuantity(); InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费", adjusted.getSubtotalAmount(), now); base.setQuantity(quantity); base.setUnit(current.getChargeUnit()); - base.setUnitPrice(quantity.signum() == 0 ? BigDecimal.ZERO - : adjusted.getSubtotalAmount().divide(quantity, 6, RoundingMode.HALF_UP)); + base.setUnitPrice(request.unitPrice().setScale(6, RoundingMode.HALF_UP)); base.setAutomaticFlag(false); base.setRemark(request.reason().trim()); repository.insertItem(base); @@ -200,9 +207,15 @@ public class FbaPricingService { extra.setRemark(request.reason().trim()); repository.insertItem(extra); } - feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now); - financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, - tenantId, inboundId, adjusted.getId(), nextVersion, now); + if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(), + adjusted.getChargeUnit(), quantity) != 1) { + throw conflict("入库单状态已变化,费用调整未保存"); + } + if (published) { + feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now); + financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, + tenantId, inboundId, adjusted.getId(), nextVersion, now); + } complete(token, adjusted.getId(), now); return response(tenantId, adjusted.getId(), inbound.getProcessStatus()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java deleted file mode 100644 index c946f2e9e..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.baiyue.firstleg.service; - -import jakarta.annotation.Resource; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.InboundFeeEntity; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.time.LocalDateTime; - -/** - * 发送费用并完成入库的状态编排骨架。 - * - *

本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用; - * 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用 - * {@link #completeSendFeeAndInbound} 发布费用并完成入库。

- */ -@Service -public class InboundCompletionOrchestrator { - @Resource - private InboundWorkflowRepository repository; - @Resource - private InboundIdempotencyService idempotency; - @Resource - private InboundFeeDetailService feeDetails; - @Resource - private FinanceFeeSourceOutboxService financeOutbox; - - /** - * 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。 - */ - @Transactional - public void beginSendFeeAndInbound(long tenantId, long operatorId, String operatorName, - long inboundId, long feeId, int expectedVersion, - String idempotencyKey) { - String canonical = inboundId + "|" + feeId + "|" + expectedVersion; - InboundIdempotencyService.Context context = idempotency.begin( - tenantId, operatorId, inboundId, "SEND_FEE_BEGIN", idempotencyKey, canonical); - if (context.replayed()) { - return; - } - InboundOrderEntity order = requireOrder(tenantId, inboundId); - if (!"FEE_CONFIRMING".equals(order.getProcessStatus()) || !"CONFIRMED".equals(order.getFeeStatus())) { - throw new BizException(ResultCode.CONFLICT, "费用未确认或入库单不在待发送阶段"); - } - boolean released = measurementGatePassed(order, tenantId, inboundId); - InboundFeeEntity fee = repository.findConfirmedFee(tenantId, inboundId, feeId); - if (fee == null) { - throw new BizException(ResultCode.CONFLICT, "缺少已确认且不可变的费用快照"); - } - if (Boolean.TRUE.equals(fee.getExceptionRelease()) != released) { - throw new BizException(ResultCode.CONFLICT, "费用快照的异常放行标记与审核历史不一致"); - } - if (repository.countPreparedAllocations(tenantId, inboundId) <= 0) { - throw new BizException(ResultCode.CONFLICT, "尚未准备托盘和库位预占引用"); - } - if (repository.beginInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) { - throw concurrentConflict(); - } - repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "FEE_CONFIRMING", "INBOUNDING", "SEND_FEE_BEGIN", null, operatorId, operatorName)); - idempotency.complete(context, inboundId); - } - - /** - * 任务5对接方法二:仓库确认占用成功后,原子锁定费用、完成入库并写 Outbox。 - */ - @Transactional - public void completeSendFeeAndInbound(long tenantId, long operatorId, String operatorName, - long inboundId, long feeId, int expectedVersion, - String idempotencyKey) { - String canonical = inboundId + "|" + feeId + "|" + expectedVersion; - InboundIdempotencyService.Context context = idempotency.begin( - tenantId, operatorId, inboundId, "SEND_FEE_COMPLETE", idempotencyKey, canonical); - if (context.replayed()) { - return; - } - InboundOrderEntity order = requireOrder(tenantId, inboundId); - if (!"INBOUNDING".equals(order.getProcessStatus())) { - throw new BizException(ResultCode.CONFLICT, "入库单不在入库处理中"); - } - if (repository.completeInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) { - throw concurrentConflict(); - } - if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) { - throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确"); - } - var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId, - operatorName, LocalDateTime.now()); - repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "INBOUNDING", "WAIT_SPLIT", "SEND_FEE_COMPLETE", null, operatorId, operatorName)); - financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, tenantId, - inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now()); - idempotency.complete(context, inboundId); - } - - /** 任务5对接方法三:仅在仓库预占已释放后回退,不能回退已完成实物入库。 */ - @Transactional - public void rollbackSendFeeAndInbound(long tenantId, long operatorId, String operatorName, - long inboundId, int expectedVersion, String reason, - String idempotencyKey) { - if (!StringUtils.hasText(reason)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "入库回退必须记录失败原因"); - } - String canonical = inboundId + "|" + expectedVersion + "|" + (reason == null ? "" : reason.trim()); - InboundIdempotencyService.Context context = idempotency.begin( - tenantId, operatorId, inboundId, "SEND_FEE_ROLLBACK", idempotencyKey, canonical); - if (context.replayed()) { - return; - } - requireOrder(tenantId, inboundId); - if (repository.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) { - throw concurrentConflict(); - } - repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId, - "INBOUNDING", "FEE_CONFIRMING", "SEND_FEE_ROLLBACK", reason, operatorId, operatorName)); - idempotency.complete(context, inboundId); - } - - private boolean measurementGatePassed(InboundOrderEntity order, long tenantId, long inboundId) { - if ("APPROVED".equals(order.getMeasureReviewStatus())) { - return false; - } - if ("REJECTED".equals(order.getMeasureReviewStatus()) - && repository.hasExceptionRelease(tenantId, inboundId)) { - return true; - } - throw new BizException(ResultCode.CONFLICT, "测量未审核通过且无有效异常放行记录"); - } - - private InboundOrderEntity requireOrder(long tenantId, long inboundId) { - InboundOrderEntity order = repository.findOwned(tenantId, inboundId); - if (order == null) { - throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - } - return order; - } - - private static BizException concurrentConflict() { - return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试"); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java new file mode 100644 index 000000000..27e4dd346 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InitialInboundPricingService.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.service; + +import jakarta.annotation.Resource; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.MeasurementApprovalResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** Best-effort initial pricing; pricing availability never gates inbound workflow. */ +@Service +public class InitialInboundPricingService { + private static final Logger log = LoggerFactory.getLogger(InitialInboundPricingService.class); + @Resource + private FbaPricingService pricing; + + public MeasurementApprovalResponse tryCalculate(long tenantId, long operatorId, long inboundId, + long measurementId, int reviewedOrderVersion) { + String key = "initial-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion; + try { + FeeSnapshotResponse fee = pricing.calculate(tenantId, operatorId, inboundId, null, key); + return new MeasurementApprovalResponse(inboundId, measurementId, "WAIT_SPLIT", + fee.feeStatus(), true, null, fee); + } catch (BizException exception) { + log.info("入库初算未生成,主流程继续 tenantId={}, inboundId={}, reason={}", + tenantId, inboundId, exception.getMessage()); + return pending(inboundId, measurementId, exception.getMessage()); + } catch (RuntimeException exception) { + log.warn("入库初算异常,主流程继续 tenantId={}, inboundId={}", tenantId, inboundId, exception); + return pending(inboundId, measurementId, "初算暂未生成,请人工维护费用"); + } + } + + private static MeasurementApprovalResponse pending(long inboundId, long measurementId, String message) { + return new MeasurementApprovalResponse(inboundId, measurementId, "WAIT_SPLIT", "UNPRICED", + false, message, null); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index a5d30516f..b5a66c5b3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -21,7 +21,7 @@ import java.time.LocalDateTime; import java.math.BigDecimal; /** - * 测量审核应用服务。审核通过后进入内部计费编排,完成费用发布和入库后再进入 WAIT_SPLIT。 + * 测量审核应用服务。审核通过后直接进入 WAIT_SPLIT,费用初算和人工维护不阻塞主流程。 */ @Service public class MeasurementReviewService { @@ -99,7 +99,7 @@ public class MeasurementReviewService { repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", - approved ? "WAIT_PRICING" : "MEASURE_REJECTED", + approved ? "WAIT_SPLIT" : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } @@ -135,7 +135,7 @@ public class MeasurementReviewService { } repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED", null, true, request.reason(), true, operatorId, operatorName)); - repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_PRICING", + repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_SPLIT", "MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 09c2a5712..ba9a37c8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; +import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; @@ -47,14 +48,22 @@ public class MerchantPriceBookService { estimate.amount(), estimate.currency(), DISCLAIMER); } - public void requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode) { + public Response requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode, + String destinationType) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); Long versionId = repository.activeVersionId(); - if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, - normalizeChannel(channelCode))) { + String normalized = normalizeChannel(channelCode); + if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) { throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); } + var prefix = repository.channelPrefix(normalized); + if (prefix == null || !StringUtils.hasText(prefix.inboundCodePrefix())) { + throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); + } + String selected = "OVERSEAS".equals(destinationType) && StringUtils.hasText(prefix.overseasInboundCodePrefix()) + ? prefix.overseasInboundCodePrefix() : prefix.inboundCodePrefix(); + return new Response(normalized, selected); } private CurrentChannel toChannel(ChannelRow row, Map> taxModes) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index d082f19d5..c4f8c8295 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -35,20 +35,21 @@ public class TruckAppointmentService { @Resource TruckAppointmentFileClient files; @Resource ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") String internalToken; - public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);} + public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List attachments(long id){required(id);return repo.attachments(id);} @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(type);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(r.attachmentType());a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} - @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); validateBookingLink(r); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} - @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); validateBookingLink(r); copy(e,r); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} + @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); OceanBookingEntity booking=validateBookingLink(r); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,booking); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} + @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); OceanBookingEntity booking=validateBookingLink(r); copy(e,r,booking); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} - private void validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=oceanBookings.containerBooking(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);} + private OceanBookingEntity validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(b.getCustomerId()==null||!StringUtils.hasText(b.getCustomerName()))throw conflict("关联订舱缺少客户快照"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(StringUtils.hasText(r.customerName())&&!r.customerName().trim().equals(b.getCustomerName()))throw bad("customerName与订舱客户快照不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=oceanBookings.containerBooking(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);return b;} private void validateLoadingPlanLink(SaveRequest r){if(r.loadingPlanId()==null)return;if(r.loadingPlanId()<=0)throw bad("loadingPlanId必须大于0");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("关联装柜计划时containerBookingId不能为空");LoadingPlanEntity plan=loadingPlans.plan(r.loadingPlanId());if(plan==null)throw new BizException(ResultCode.NOT_FOUND,"关联装柜计划不存在");if(!Objects.equals(plan.getContainerBookingId(),r.containerBookingId()))throw bad("loadingPlanId与柜体不一致");} private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} - private void copy(TruckAppointmentEntity e,SaveRequest r){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(r.customerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private TruckAppointmentEntity enrich(TruckAppointmentEntity value){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=oceanBookings.containerBooking(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}return value;} + private void copy(TruckAppointmentEntity e,SaveRequest r,OceanBookingEntity booking){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(booking.getCustomerId());e.setCustomerName(booking.getCustomerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 0eb74c458..eb50fe04b 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -421,6 +421,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', 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 '最后更新时间', @@ -511,26 +513,29 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel ( ) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'; INSERT INTO fl_price_channel_catalog - (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status) + (id, channel_code, display_name, price_model, source_sheet_name, aliases_json, + inbound_code_prefix, overseas_inbound_code_prefix, status) VALUES - (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), - (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), - (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), - (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), - (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'), - (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'), - (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), - (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), - (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), - (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), - (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), - (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), - (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), - (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), - (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') + (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ZBYHK', 'ZBYHWK', 'ACTIVE'), + (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'BYHK', 'BYHWK', 'ACTIVE'), + (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'EXXHK', 'EXXHWK', 'ACTIVE'), + (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'HDHK', 'HDHWK', 'ACTIVE'), + (5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'NYEXX', NULL, 'ACTIVE'), + (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'PCHK', 'PCHWK', 'ACTIVE'), + (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'PCKK', NULL, 'ACTIVE'), + (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'CPHT', NULL, 'ACTIVE'), + (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'HPHK', NULL, 'ACTIVE'), + (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'NPHK', 'NPHWK', 'ACTIVE'), + (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'SPHK', NULL, 'ACTIVE'), + (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ZBYHP', NULL, 'ACTIVE'), + (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'BYHP', NULL, 'ACTIVE'), + (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'HDHP', NULL, 'ACTIVE'), + (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'PCHP', NULL, 'ACTIVE') ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), price_model = VALUES(price_model), - source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status); + source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), + inbound_code_prefix = VALUES(inbound_code_prefix), + overseas_inbound_code_prefix = VALUES(overseas_inbound_code_prefix), status = VALUES(status); INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status) VALUES diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 2f37808a3..ff1510eb8 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -153,8 +153,8 @@ paths: responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/published: put: - summary: 调整已发布费用 - description: 需要 firstleg:fba-pricing:adjust 权限;生成新 SENT 版本并将旧版本标记 SUPERSEDED,不回退 WAIT_SPLIT 状态。 + summary: 调整初算或已发布费用 + description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -175,9 +175,11 @@ components: schemas: AdjustFeeRequest: type: object - required: [subtotalAmount, additionalAmount, reason] + required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: - subtotalAmount: {type: number, minimum: 0, multipleOf: 0.01} + expectedFeeVersion: {type: integer, minimum: 1} + chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} CalculateRequest: diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e5fbf5b7e..3e1194788 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,7 +356,7 @@ paths: type: object required: [boxCode, length, width, height, machineNo, picture] properties: - boxCode: {type: string, maxLength: 128, example: 'RK-2085911321565003778#4-1'} + boxCode: {type: string, maxLength: 128, example: 'ZBYHK2608140897#4-1'} shipment_id: {type: string, maxLength: 64, example: 'FBA19L5KMY2CU000002', description: '可选;设备从Amazon FBA箱标实际扫描得到,有则原样上传;系统只保存,不生成且不覆盖客户下单值'} length: {type: number, format: decimal, minimum: 0.001} width: {type: number, format: decimal, minimum: 0.001} @@ -558,7 +558,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限。审核事务提交后服务端自动执行计费、费用发布和入库编排并返回费用快照;若后续编排失败,审核结果保持已提交,使用同一 Idempotency-Key 重试将从持久化阶段继续,接口不会伪造 WAIT_SPLIT。 + description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -575,7 +575,7 @@ paths: expectedVersion: {type: integer, minimum: 0} reason: {type: string, maxLength: 1000} responses: - '200': {description: 成功或业务失败,按响应体 code 判断;成功 data 为 FeeSnapshotResponse 且 nextStatus=WAIT_SPLIT;失败可能表示审核已提交但自动编排尚未完成、版本冲突或费用/仓库门禁失败,不得据此推断 WAIT_SPLIT} + '200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} /api/platform/v1/inbounds/{inboundId}/measurements/reject: post: summary: 驳回测量 @@ -982,7 +982,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string, nullable: true} - inboundCode: {type: string, minLength: 1, maxLength: 40, example: RK-2085308606829346818} + inboundCode: {type: string, minLength: 1, maxLength: 40, example: ZBYHK2608140897} customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} customerName: {type: string, minLength: 1} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1217,7 +1217,7 @@ components: eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001} externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001} warehouseCode: {type: string, maxLength: 50, example: CN-YW-01} - inboundCode: {type: string, maxLength: 40, example: RK202608010001} + inboundCode: {type: string, maxLength: 40, example: ZBYHK2608140897} inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID} deviceCode: {type: string, maxLength: 64, example: DEVICE-01} measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 3f262804d..946fd1fe9 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -11,6 +11,12 @@ paths: parameters: [{$ref: '#/components/parameters/ContainerId'}] post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}} delete: {operationId: cancelContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}} + /api/platform/v1/containers/{id}/pda/photos/{fileId}/download: + get: + operationId: downloadContainerPdaPhoto + description: '权限:platform:firstleg:container:list;Firstleg按柜次、装柜计划、入库单和文件归属代理下载,不暴露内部服务令牌' + parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: photo bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: photo missing or not owned by the container}} /api/platform/v1/containers/{id}/documents/{documentType}: get: operationId: getContainerDocument @@ -50,7 +56,7 @@ components: exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}} - PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} + PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string}, downloadPath: {type: string}, uploadedAt: {type: string, format: date-time}}} Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 1ea424e01..72d15f87e 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -24,7 +24,7 @@ paths: get: summary: Query a truck appointment responses: - '200': {description: Appointment detail} + '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -78,16 +78,20 @@ paths: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment + description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references} + '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope + description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference} + '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: summary: Upload and reference a truck appointment attachment + description: 'Requires platform:firstleg:truck-appointment:edit.' requestBody: required: true content: @@ -96,7 +100,7 @@ paths: type: object required: [file] properties: - attachmentType: {type: string, maxLength: 32} + attachmentType: {type: string, maxLength: 64} file: {type: string, format: binary} responses: '200': {description: Uploaded attachment reference} @@ -106,6 +110,7 @@ paths: - {$ref: '#/components/parameters/AttachmentId'} delete: summary: Delete an attachment reference owned by the appointment + description: 'Requires platform:firstleg:truck-appointment:edit. Deletes the appointment reference only; the File object is retained.' responses: '200': {description: Reference deleted} components: @@ -122,6 +127,26 @@ components: IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}} schemas: + AttachmentRequest: + type: object + required: [fileId] + properties: + fileId: {type: integer, format: int64, minimum: 1} + attachmentType: {type: string, maxLength: 64} + fileName: {type: string, maxLength: 255, description: Ignored; authoritative metadata comes from File service} + fileHash: {type: string, maxLength: 128, description: Ignored; authoritative metadata comes from File service} + Attachment: + type: object + properties: + id: {type: integer, format: int64} + appointmentId: {type: integer, format: int64} + fileId: {type: integer, format: int64} + attachmentType: {type: string} + fileName: {type: string} + fileHash: {type: string} + visibility: {type: string, enum: [PLATFORM]} + uploadedBy: {type: integer, format: int64} + uploadedAt: {type: string, format: date-time} ActionRequest: type: object required: [expectedVersion] @@ -137,3 +162,23 @@ components: loadingPlanId: {type: integer, format: int64, minimum: 1} plannedLoadingAt: {type: string, format: date-time} expectedVersion: {type: integer, minimum: 0} + DetailStableReferences: + type: object + description: Stable IDs plus server-resolved saved resource names returned by appointment detail. + properties: + oceanBookingId: {type: integer, format: int64} + oceanBookingNo: {type: string} + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string} + containerNo: {type: string, nullable: true} + loadingPlanId: {type: integer, format: int64} + warehouseId: {type: integer, format: int64} + warehouseName: {type: string} + customerId: {type: integer, format: int64} + customerName: {type: string, description: Frozen from the associated ocean booking; request input cannot override it} + shippingCompanyId: {type: integer, format: int64} + shippingCompanyName: {type: string} + bookingAgentId: {type: integer, format: int64} + bookingAgentName: {type: string} + carrierId: {type: integer, format: int64} + carrierName: {type: string} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 3fab6fc74..cfacd1cac 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -25,6 +25,7 @@ class ContainerControllerContractTest { assertPermission("updateTransport", "platform:firstleg:container:edit"); assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("overview", "platform:firstleg:container:list"); + assertPermission("downloadPdaPhoto", "platform:firstleg:container:list"); assertPermission("markException", "platform:firstleg:container:edit"); assertPermission("cancelException", "platform:firstleg:container:edit"); assertPermission("logisticsDocument", "platform:firstleg:container:list"); @@ -38,7 +39,8 @@ class ContainerControllerContractTest { assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", - "downloadPath:", "version: {type: integer, minimum: 0}") + "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", + "AWAITING_REVIEW", "version: {type: integer, minimum: 0}") .doesNotContain("?containerNo", "containerNo in: query"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 9fa935393..385785b92 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -36,7 +36,7 @@ class FbaPricingContractTest { } @Test - @DisplayName("契约声明二期边界计费、已发布费用调整和幂等头") + @DisplayName("契约声明二期边界计费、人工费用调整和幂等头") void documentsPricingLifecycle() throws Exception { String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); @@ -53,6 +53,10 @@ class FbaPricingContractTest { .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") .contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("不回退 WAIT_SPLIT 状态") + .contains("expectedFeeVersion") + .contains("chargeableQuantity") + .contains("unitPrice") + .doesNotContain("required: [subtotalAmount, additionalAmount, reason]") .doesNotContain("应收") .doesNotContain("应付") .doesNotContain("待客户确认"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index c563596f5..1347ad239 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -1,10 +1,10 @@ package com.baiyue.firstleg.controller; -import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ExceptionReleaseRequest; +import com.baiyue.firstleg.dto.MeasurementApprovalResponse; import com.baiyue.firstleg.dto.MeasurementReviewRequest; -import com.baiyue.firstleg.service.AutomaticInboundCompletionService; +import com.baiyue.firstleg.service.InitialInboundPricingService; import com.baiyue.firstleg.service.MeasurementReviewService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,50 +46,39 @@ class InboundMeasurementReviewContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") .contains("firstleg:inbound:exception-release") .contains("Idempotency-Key") - .contains("审核事务提交后服务端自动执行计费") - .contains("接口不会伪造 WAIT_SPLIT") - .contains("成功 data 为 FeeSnapshotResponse") - .contains("nextStatus=WAIT_SPLIT") + .contains("初算异常只返回非阻断提示") + .contains("data.processStatus 固定为 WAIT_SPLIT") + .contains("feeStatus=UNPRICED 且主流程仍成功") .contains("name: inboundId") .contains("schema: {type: string, pattern:") .doesNotContain("待客户确认"); } @Test - @DisplayName("审核事务完成后才启动自动编排并返回真实费用快照") - void shouldCompleteApprovedReviewAfterReviewTransactionReturns() { + @DisplayName("审核先提交待分柜,再执行不阻塞的初算") + void shouldApproveBeforeBestEffortInitialPricing() { MeasurementReviewService review = mock(MeasurementReviewService.class); - AutomaticInboundCompletionService completion = mock(AutomaticInboundCompletionService.class); + InitialInboundPricingService initialPricing = mock(InitialInboundPricingService.class); PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController(); ReflectionTestUtils.setField(controller, "service", review); - ReflectionTestUtils.setField(controller, "automaticCompletion", completion); + ReflectionTestUtils.setField(controller, "initialPricing", initialPricing); AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null, "客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review")); MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过"); - var fee = mock(com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse.class); - when(completion.completeApproved(10001L, 88L, 9L, "客服", 30L, 5)).thenReturn(fee); + var result = new MeasurementApprovalResponse(88L, 30L, "WAIT_SPLIT", "UNPRICED", + false, "待人工维护费用", null); + when(initialPricing.tryCalculate(10001L, 9L, 88L, 30L, 5)).thenReturn(result); var first = controller.approve(user, 10001L, 88L, "approve-1", request); var replay = controller.approve(user, 10001L, 88L, "approve-1", request); - assertThat(first.data()).isSameAs(fee); - assertThat(replay.data()).isSameAs(fee); - var ordered = inOrder(review, completion); + assertThat(first.data()).isSameAs(result); + assertThat(replay.data()).isSameAs(result); + var ordered = inOrder(review, initialPricing); ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); - ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5); + ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5); ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); - ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5); - } - - @Test - void shouldAuditAutomaticInboundCompletion() throws Exception { - Method method = AutomaticInboundCompletionService.class.getDeclaredMethod("completeApproved", - long.class, long.class, long.class, String.class, long.class, int.class); - BusinessAudit audit = method.getAnnotation(BusinessAudit.class); - assertThat(audit.event()).isEqualTo("INBOUND_AUTO_COMPLETE"); - assertThat(audit.resourceType()).isEqualTo("INBOUND"); - assertThat(audit.resourceId()).isEqualTo("#inboundId"); - assertThat(audit.captureBefore()).isFalse(); + ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5); } private void assertAuthority(String name, String expression, Class... types) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index 1b68274c3..ca577af36 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -23,6 +23,12 @@ class PriceBookContractTest { @DisplayName("固定十五个渠道且海派使用区域重量模型") void keepsStableChannelCatalog() { assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15); + assertThat(PriceBookChannelCatalog.CHANNELS) + .extracting(value -> value.channelCode() + ":" + value.inboundCodePrefix()) + .contains("MATSON_CLX_TRUCK:ZBYHK", "OA_LA_FULL_TRUCK:PCKK", "OA_CHICAGO_RAIL:CPHT"); + assertThat(PriceBookChannelCatalog.CHANNELS) + .filteredOn(value -> "OA_NEW_YORK_TRUCK".equals(value.channelCode())) + .singleElement().extracting(value -> value.overseasInboundCodePrefix()).isEqualTo("NPHWK"); assertThat(PriceBookChannelCatalog.CHANNELS.stream() .map(PriceBookChannelCatalog.ChannelDefinition::channelCode)) .containsExactly( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 1b2fdc53a..bddfd4463 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -19,6 +19,7 @@ class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} @Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");} @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} + @Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId]","maxLength: 64","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java index 59bd005ff..80a0b1e77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderStatusSqlContractTest.java @@ -11,10 +11,11 @@ import static org.assertj.core.api.Assertions.assertThat; class InboundOrderStatusSqlContractTest { @Test - void advancesThroughPricingBeforeOpeningWaitSplit() { + void opensWaitSplitImmediatelyAfterMeasurementApproval() { assertThat(sql("approveMeasurement")) - .contains("process_status = 'WAIT_PRICING'") - .doesNotContain("inbounded_at ="); + .contains("process_status = 'WAIT_SPLIT'") + .contains("inbounded_at = CURRENT_TIMESTAMP(3)") + .doesNotContain("process_status = 'WAIT_PRICING'"); assertThat(sql("completeInbound")) .contains("process_status = 'WAIT_SPLIT'") .contains("inbounded_at = CURRENT_TIMESTAMP(3)") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java deleted file mode 100644 index 3820f378f..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/AutomaticInboundCompletionServiceTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.baiyue.firstleg.service; - -import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@SpringJUnitConfig(AutomaticInboundCompletionService.class) -class AutomaticInboundCompletionServiceTest { - @MockBean private FbaPricingService pricing; - @MockBean private InboundWorkflowRepository workflow; - @MockBean private InboundCompletionOrchestrator completion; - @Resource private AutomaticInboundCompletionService service; - - @Test - void shouldHandleCase01() { - FeeSnapshotResponse fee = fee(501L); - when(pricing.latest(10001L, 88L)).thenReturn(fee); - when(workflow.findOwned(10001L, 88L)).thenReturn( - order("WAIT_PRICING", "UNPRICED", 4), - order("FEE_CONFIRMING", "CALCULATED", 5), - order("FEE_CONFIRMING", "CONFIRMED", 6), - order("INBOUNDING", "CONFIRMED", 7), - order("WAIT_SPLIT", "SENT", 8)); - - FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4); - - assertThat(result).isSameAs(fee); - verify(pricing).calculate(10001L, 9L, 88L, null, "auto-88-30-4-calculate"); - verify(pricing).confirm(10001L, 9L, 88L, "auto-88-30-4-confirm"); - verify(completion).beginSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 6, - "auto-88-30-4-begin"); - verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7, - "auto-88-30-4-complete"); - } - - @Test - void shouldHandleCase02() { - FeeSnapshotResponse fee = fee(501L); - when(workflow.findOwned(10001L, 88L)).thenReturn(order("WAIT_SPLIT", "SENT", 8)); - when(pricing.latest(10001L, 88L)).thenReturn(fee); - - assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee); - - verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(), - org.mockito.ArgumentMatchers.any(), anyString()); - verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString()); - verify(completion, never()).beginSendFeeAndInbound(anyLong(), anyLong(), anyString(), - anyLong(), anyLong(), org.mockito.ArgumentMatchers.anyInt(), anyString()); - } - - @Test - void shouldHandleCase03() { - FeeSnapshotResponse fee = fee(501L); - when(pricing.latest(10001L, 88L)).thenReturn(fee); - when(workflow.findOwned(10001L, 88L)).thenReturn( - order("INBOUNDING", "CONFIRMED", 7), order("WAIT_SPLIT", "SENT", 8)); - - service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4); - - verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7, - "auto-88-30-4-complete"); - verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(), - org.mockito.ArgumentMatchers.any(), anyString()); - verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString()); - } - - private static InboundOrderEntity order(String process, String feeStatus, int version) { - InboundOrderEntity order = new InboundOrderEntity(); - order.setProcessStatus(process); - order.setFeeStatus(feeStatus); - order.setVersion(version); - return order; - } - - private static FeeSnapshotResponse fee(long feeId) { - FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); - when(fee.feeId()).thenReturn(feeId); - return fee; - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index b2740c46f..6e42b1897 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -5,6 +5,10 @@ import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.repository.ContainerRepository; +import com.baiyue.firstleg.client.LoadingEvidenceFileClient; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -13,6 +17,7 @@ import org.mockito.MockitoAnnotations; import java.time.LocalDateTime; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -20,9 +25,67 @@ import static org.mockito.Mockito.*; class ContainerOverviewServiceTest { @Mock ContainerRepository containers; @Mock ContainerOverviewMapper mapper; + @Mock LoadingEvidenceFileClient evidenceFiles; @InjectMocks ContainerOverviewService service; - @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + @BeforeEach void setUp() { + MockitoAnnotations.openMocks(this); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test void overviewExposesAwaitingReviewWorkflowAndFirstlegPhotoDownloadPath() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(41L); booking.setVersion(3); booking.setPlanId(52L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.itemCount(61L)).thenReturn(2); + when(mapper.completedItemCount(61L)).thenReturn(2); + when(mapper.unresolvedItemCount(61L)).thenReturn(0); + when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()))); + when(mapper.documents(41L)).thenReturn(java.util.List.of()); + when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); + when(mapper.transport(41L)).thenReturn(java.util.List.of()); + when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); + when(mapper.pickExceptionReasons(61L)).thenReturn(java.util.List.of()); + + var overview = service.overview(41L); + + assertThat(overview.pda().taskStatus()).isEqualTo("LOADING"); + assertThat(overview.pda().workflowStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(overview.pda().photos().get(0).downloadPath()) + .isEqualTo("/api/platform/v1/containers/41/pda/photos/101/download"); + } + + @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow( + 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()))); + when(evidenceFiles.download("internal-token", 81L, 91L, 101L)).thenReturn(ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3})); + + var download = service.downloadPhoto(41L, 101L); + + assertThat(download.fileName()).isEqualTo("door.jpg"); + assertThat(download.contentType()).isEqualTo(MediaType.IMAGE_JPEG); + assertThat(download.content()).containsExactly(1, 2, 3); + } + + @Test void rejectsPhotoFromAnotherContainerBeforeCallingFileService() { + ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); + when(containers.find(41L)).thenReturn(booking); + when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow( + 61L, 52L, 41L, "LOADING", 7)); + when(mapper.photos(52L)).thenReturn(java.util.List.of()); + + assertThatThrownBy(() -> service.downloadPhoto(41L, 999L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜"); + verifyNoInteractions(evidenceFiles); + } @Test void markLocksContainerAndPersistsIdempotentImmutableHistory() { ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index e38f3be1a..adcdf4429 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -31,6 +31,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; @@ -302,7 +303,9 @@ class FbaPricingServiceTest { doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; }) .when(repository).insertIdempotency(any()); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); - when(repository.supersedeSent(TENANT, 501L)).thenReturn(1); + when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any())) + .thenReturn(1); doAnswer(invocation -> { InboundFeeEntity fee = invocation.getArgument(0); fee.setId(502L); @@ -314,18 +317,80 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1"); + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正"), "adjust-1"); assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeStatus()).isEqualTo("SENT"); assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT"); - verify(repository).supersedeSent(TENANT, 501L); + assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000"); + verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); } + @Test + @DisplayName("初算费用调整后保持初算状态且不发布财务来源") + void adjustsCalculatedFeeWithoutPublishingIt() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + inbound.setFeeStatus("CALCULATED"); + InboundFeeEntity current = savedFee("CALCULATED"); + AtomicReference inserted = new AtomicReference<>(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-calculated")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(902L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { + InboundFeeEntity fee = invocation.getArgument(0); + fee.setId(503L); + inserted.set(fee); + return null; + }).when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(503L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 503L)).thenAnswer(invocation -> inserted.get()); + + FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正"), "adjust-calculated"); + + assertThat(response.feeStatus()).isEqualTo("CALCULATED"); + assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); + verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("费用版本已变化时拒绝覆盖当前版本") + void rejectsAdjustmentWithStaleFeeVersion() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + current.setFeeVersion(2); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("12.5"), new BigDecimal("8.00"), + BigDecimal.ZERO, "客服修正"), "stale-adjust")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + + verify(repository, never()).insertIdempotency(any()); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java deleted file mode 100644 index 04bb68275..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.baiyue.firstleg.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.entity.InboundFeeEntity; -import com.baiyue.firstleg.entity.InboundOrderEntity; -import com.baiyue.firstleg.repository.InboundWorkflowRepository; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@DisplayName("发送费用并入库状态编排") -@SpringJUnitConfig(InboundCompletionOrchestrator.class) -class InboundCompletionOrchestratorTest { - @MockBean private FinanceFeeSourceOutboxService financeOutbox; - @MockBean - private InboundWorkflowRepository repository; - @MockBean - private InboundIdempotencyService idempotency; - @MockBean - private InboundFeeDetailService feeDetails; - @Resource - private InboundCompletionOrchestrator service; - @Test - @DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING") - void shouldBeginInboundAfterAllGuardsPass() { - Fixture fixture = fixture("APPROVED", false); - when(fixture.repository.beginInbound(10001L, 10L, 9L, 4)).thenReturn(1); - - fixture.service.beginSendFeeAndInbound(10001L, 9L, "操作员", 10L, 30L, 4, "send-1"); - - verify(fixture.repository).beginInbound(10001L, 10L, 9L, 4); - verify(fixture.repository).insertHistory(any()); - } - - @Test - @DisplayName("测量驳回且无异常放行记录时禁止发送费用") - void shouldBlockRejectedMeasurementWithoutRelease() { - Fixture fixture = fixture("REJECTED", true); - when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(false); - - assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 4, "send-1")) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - verify(fixture.repository, never()).beginInbound(anyLong(), anyLong(), anyLong(), anyInt()); - } - - @Test - @DisplayName("异常放行标记必须与费用快照一致") - void shouldRequireFeeSnapshotReleaseFlag() { - Fixture fixture = fixture("REJECTED", false); - when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(true); - - assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 4, "send-1")) - .isInstanceOf(BizException.class) - .hasMessageContaining("异常放行标记"); - } - - @Test - @DisplayName("完成阶段原子发送费用、完成入库并写待投递事件") - void shouldCompleteInboundAndWriteOutbox() { - Fixture fixture = fixture("APPROVED", false); - fixture.order.setProcessStatus("INBOUNDING"); - when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1); - when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1); - com.baiyue.firstleg.entity.InboundFeeDetailEntity published = - new com.baiyue.firstleg.entity.InboundFeeDetailEntity(); - published.setSourceFeeVersion(1); - when(feeDetails.publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class))).thenReturn(published); - - fixture.service.completeSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 5, "complete-1"); - - verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5); - verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L); - verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class)); - verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED), - eq(10001L), eq(10L), eq(30L), eq(1), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class)); - } - - @Test - @DisplayName("条件更新失败时报告并发冲突且不发送费用") - void shouldRejectConcurrentCompletion() { - Fixture fixture = fixture("APPROVED", false); - fixture.order.setProcessStatus("INBOUNDING"); - when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(0); - - assertThatThrownBy(() -> fixture.service.completeSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 30L, 5, "complete-1")) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong()); - verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), - org.mockito.ArgumentMatchers.anyInt(), - org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class)); - } - - @Test - @DisplayName("只有仓库预占已补偿后才通过显式方法回退") - void shouldRollbackOnlyInboundingState() { - Fixture fixture = fixture("APPROVED", false); - when(fixture.repository.rollbackInbound(10001L, 10L, 9L, 5)).thenReturn(1); - - fixture.service.rollbackSendFeeAndInbound( - 10001L, 9L, "操作员", 10L, 5, "仓库确认失败且已释放预占", "rollback-1"); - - verify(fixture.repository).rollbackInbound(10001L, 10L, 9L, 5); - verify(fixture.repository).insertHistory(any()); - } - - private Fixture fixture(String reviewStatus, boolean feeRelease) { - when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())) - .thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false)); - InboundOrderEntity order = new InboundOrderEntity(); - order.setProcessStatus("FEE_CONFIRMING"); - order.setFeeStatus("CONFIRMED"); - order.setMeasureReviewStatus(reviewStatus); - InboundFeeEntity fee = new InboundFeeEntity(); - fee.setExceptionRelease(feeRelease); - when(repository.findOwned(10001L, 10L)).thenReturn(order); - when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee); - when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L); - return new Fixture(service, repository, order); - } - - private record Fixture(InboundCompletionOrchestrator service, - InboundWorkflowRepository repository, - InboundOrderEntity order) { - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java new file mode 100644 index 000000000..24353fb70 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InitialInboundPricingServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InitialInboundPricingServiceTest { + private final FbaPricingService pricing = mock(FbaPricingService.class); + private final InitialInboundPricingService service = new InitialInboundPricingService(); + + InitialInboundPricingServiceTest() { + ReflectionTestUtils.setField(service, "pricing", pricing); + } + + @Test + void returnsCalculatedDraftWithoutChangingWaitSplit() { + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + when(fee.feeStatus()).thenReturn("CALCULATED"); + when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")).thenReturn(fee); + + var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.feeStatus()).isEqualTo("CALCULATED"); + assertThat(result.initialPricingSucceeded()).isTrue(); + assertThat(result.initialFee()).isSameAs(fee); + } + + @Test + void keepsWaitSplitWhenNoPriceMatches() { + when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")) + .thenThrow(new BizException("未匹配有效价格")); + + var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5); + + assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(result.feeStatus()).isEqualTo("UNPRICED"); + assertThat(result.initialPricingSucceeded()).isFalse(); + assertThat(result.initialPricingMessage()).contains("未匹配有效价格"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index dbe2ce599..3a35d68b1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -78,7 +78,7 @@ class MeasurementReviewServiceTest { ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); verify(fixture.repository).insertHistory(history.capture()); assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); - assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING"); + assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index c41872e2b..7f251c6e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; @@ -41,6 +42,32 @@ class MerchantPriceBookServiceTest { @MockBean private PriceBookWarehouseClient warehouses; @Resource private MerchantPriceBookService service; + @Test + void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelPrefix("MATSON_CLX_TRUCK")) + .thenReturn(new ChannelPrefixRow("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK")); + + assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA") + .inboundCodePrefix()).isEqualTo("ZBYHK"); + assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS") + .inboundCodePrefix()).isEqualTo("ZBYHWK"); + } + + @Test + void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { + when(repository.activeVersionId()).thenReturn(7L); + when(repository.channels(7L, 10L)).thenReturn(List.of( + channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); + when(repository.channelPrefix("OA_HOUSTON_TRUCK")) + .thenReturn(new ChannelPrefixRow("OA_HOUSTON_TRUCK", "HPHK", null)); + + assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS") + .inboundCodePrefix()).isEqualTo("HPHK"); + } + @Test void shouldReturnEmptyChannelsWithoutActiveVersion() { when(repository.activeVersionId()).thenReturn(null); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index f750c5f6a..bcc97f03b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -41,6 +41,28 @@ class TruckAppointmentServiceTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + @Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() { + TruckAppointmentEntity appointment = new TruckAppointmentEntity(); + appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L); + when(repository.find(51L)).thenReturn(appointment); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61"); + booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L); + booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co"); + when(oceanBookings.find(61L)).thenReturn(booking); + ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L); + container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567"); + when(oceanBookings.containerBooking(71L)).thenReturn(container); + + TruckAppointmentEntity detail = service.detail(51L); + + assertThat(detail.getOceanBookingNo()).isEqualTo("OB61"); + assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit"); + assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line"); + assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co"); + assertThat(detail.getCabinetNo()).isEqualTo("CAB-71"); + assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567"); + } + @Test void createRequiresZeroVersionAndInitialisesDraft() { SaveRequest invalid = request(1); assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class); @@ -63,10 +85,13 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.customerId()).thenReturn(7L); when(request.loadingPlanId()).thenReturn(null); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer"); when(oceanBookings.find(99L)).thenReturn(booking); service.create(7L, "admin", request); - verify(repository).insert(any(TruckAppointmentEntity.class)); + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getCustomerId()).isEqualTo(7L); + assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer"); verify(repository).history(any(TruckAppointmentHistoryEntity.class)); } @@ -76,7 +101,7 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer"); when(oceanBookings.find(99L)).thenReturn(booking); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); when(oceanBookings.containerBooking(88L)).thenReturn(container); @@ -97,7 +122,7 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer"); when(oceanBookings.find(99L)).thenReturn(booking); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); when(oceanBookings.containerBooking(88L)).thenReturn(container); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java index 3953bbc3d..81320e0db 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -10,8 +10,10 @@ import org.springframework.web.bind.annotation.RequestHeader; url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing") public interface FirstlegPricingClient { @PostMapping("/channels/validate") - ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody ChannelValidationRequest request); + ApiResponse validateChannel(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ChannelValidationRequest request); - record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {} + record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode, + String destinationType) {} + record ChannelValidationResponse(String channelCode, String inboundCodePrefix) {} } diff --git a/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java b/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java new file mode 100644 index 000000000..14f55aa6d --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java @@ -0,0 +1,16 @@ +package com.baiyue.order.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +/** 全平台入仓码票序;自增主键保证并发创建时不会重复。 */ +@Getter +@Setter +@TableName("ord_inbound_code_sequence") +public class InboundCodeSequenceEntity { + @TableId(type = IdType.AUTO) + private Long id; +} diff --git a/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java new file mode 100644 index 000000000..de1192f37 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.order.mapper; + +import com.baiyue.order.entity.InboundCodeSequenceEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; + +@Mapper +public interface InboundCodeSequenceMapper { + @Insert("INSERT INTO ord_inbound_code_sequence (created_at) VALUES (CURRENT_TIMESTAMP(3))") + @Options(useGeneratedKeys = true, keyProperty = "id") + int allocate(InboundCodeSequenceEntity value); +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 142929cec..7993a9be1 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -33,6 +33,15 @@ public class OrderRepository { @Resource private OutboxEventMapper outboxMapper; @Resource private OrderChildPurgeMapper childPurgeMapper; @Resource private OrderIdempotencyRecordMapper idempotencyMapper; + @Resource private InboundCodeSequenceMapper inboundCodeSequenceMapper; + + public long nextInboundCodeSequence() { + InboundCodeSequenceEntity value = new InboundCodeSequenceEntity(); + if (inboundCodeSequenceMapper.allocate(value) != 1 || value.getId() == null) { + throw new IllegalStateException("Failed to allocate inbound code sequence"); + } + return value.getId(); + } public void insertOrder(OrderEntity order) { orderMapper.insert(order); } public int saveOrder(long tenantId, OrderEntity order) { diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 19a5b558b..f6ace511a 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -33,9 +33,12 @@ import java.time.*; import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; @Service public class OrderApplicationService { + private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd"); @Resource private OrderRepository repository; @Resource private ObjectMapper json; @Resource private OrderEligibilityGuard eligibilityGuard; @@ -61,12 +64,15 @@ public class OrderApplicationService { private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { productImageGuard.requireOwned(tenantId, request.cargoes()); var snapshots = masterDataGuard.validate(tenantId, request); - pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode()); + String inboundCodePrefix = pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), + request.channelCode(), request.destinationType()); OrderEntity order = new OrderEntity(); long orderId = IdWorker.getId(); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); - order.setInboundCode("RK-" + orderId); order.setStatus(OrderStatus.DRAFT); - apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId); + order.setStatus(OrderStatus.DRAFT); + apply(order, request, snapshots); + order.setInboundCode(nextInboundCode(inboundCodePrefix)); + order.setCreatedBy(userId); order.setUpdatedBy(userId); repository.insertOrder(order); saveChildren(tenantId, userId, order.getId(), request); record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null); @@ -100,11 +106,9 @@ public class OrderApplicationService { && !(allowSubmitted && before == OrderStatus.SUBMITTED)) { throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料"); } - pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode()); + pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode(), + request.destinationType()); apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); - if (before == OrderStatus.REJECTED && !StringUtils.hasText(order.getInboundCode())) { - order.setInboundCode("RK-" + order.getId()); - } if (before == OrderStatus.SUBMITTED) { order.setStatus(OrderStatus.SUBMITTED); } @@ -122,23 +126,22 @@ public class OrderApplicationService { OrderStatus from = order.getStatus(); if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target); + String inboundCodePrefix = null; if (target == OrderStatus.SUBMITTED) { validatePersistedPrototypeFields(tenantId, order); masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); - pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode()); + inboundCodePrefix = pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), + order.getChannelCode(), order.getDestinationType()); } if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { throw new BizException("Reason is required"); } order.setStatus(target); order.setUpdatedBy(userId); if (target == OrderStatus.SUBMITTED) { - if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId()); + if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode(nextInboundCode(inboundCodePrefix)); order.setSubmittedAt(LocalDateTime.now()); } - if (target == OrderStatus.DRAFT && !StringUtils.hasText(order.getInboundCode())) { - order.setInboundCode("RK-" + order.getId()); - } if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now()); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); record(tenantId, id, from, target, target.name(), reason, userId, username); @@ -159,6 +162,20 @@ public class OrderApplicationService { public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } + String nextInboundCode(String prefix) { + return formatInboundCode(prefix, LocalDate.now(INBOUND_CODE_ZONE), repository.nextInboundCodeSequence()); + } + + static String formatInboundCode(String prefix, LocalDate date, long sequence) { + if (!StringUtils.hasText(prefix) || !prefix.matches("[A-Z]{4,6}")) { + throw new BizException(ResultCode.CONFLICT, "渠道入仓码前缀配置无效"); + } + if (date == null || sequence < 1) { + throw new BizException(ResultCode.CONFLICT, "入仓码票序生成失败"); + } + return prefix + date.format(INBOUND_CODE_DATE) + String.format(Locale.ROOT, "%04d", sequence); + } + public List countByTenantIds(List tenantIds) { var counts = repository.countByTenantIds(tenantIds); return tenantIds.stream().distinct() diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java index 4ccf3ed7c..2f8c40568 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -14,10 +14,12 @@ public class OrderPricingChannelGuard { @Resource private FirstlegPricingClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; - public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) { + public String requireAvailable(long tenantId, long originWarehouseId, String channelCode, + String destinationType) { try { - ApiResponse response = client.validateChannel(internalToken, - new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode)); + ApiResponse response = client.validateChannel(internalToken, + new FirstlegPricingClient.ChannelValidationRequest( + tenantId, originWarehouseId, channelCode, destinationType)); if (response == null) { throw unavailable(); } @@ -27,6 +29,11 @@ public class OrderPricingChannelGuard { ? code.message() : response.message(); throw new BizException(code, message); } + if (response.data() == null || response.data().inboundCodePrefix() == null + || response.data().inboundCodePrefix().isBlank()) { + throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); + } + return response.data().inboundCodePrefix(); } catch (BizException exception) { throw exception; } catch (FeignException exception) { diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 3bec81ee9..a3e568914 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空', + inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+全局票序', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -60,6 +60,12 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +CREATE TABLE IF NOT EXISTS ord_inbound_code_sequence ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '全平台入仓码票序', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '票序分配时间', + PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='入仓码全局票序;不同客户和物流渠道共用且不按日期清零'; + CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f32a3380a..c5081cec3 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -11,7 +11,7 @@ paths: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回 + 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+全局票序;有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, @@ -52,7 +52,7 @@ paths: /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 - description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;所有状态直接返回 Order 创建时生成的 inboundCode;平台可见内部备注。 parameters: - $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedTenantId' @@ -620,7 +620,7 @@ components: success: {type: boolean} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} - inboundCode: {type: string, pattern: '^RK-[A-Za-z0-9-]+$'} + inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}$', example: ZBYHK2608140897} errorCode: {type: string} errorMessage: {type: string} FirstLegOrderCargo: @@ -721,7 +721,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] UnifiedInboundCode: - {name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}} + {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: name: internalOrderStatus in: query diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 44c4bdc11..92b00fb77 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -22,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalTime; +import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.*; @@ -57,7 +58,7 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT - && ("RK-" + order.getId()).equals(order.getInboundCode()))); + && order.getInboundCode().matches("PCHWK\\d{6}0897"))); } @Test @@ -105,13 +106,14 @@ class OrderApplicationServiceStateTest { OrderRequests.SaveRequest request = request(); when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + when(pricing.requireAvailable(10001L, 8L, "CH", "OVERSEAS")).thenReturn("PCHWK"); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); service.createDraft(10001L, 2L, request, "validate-channel-on-create"); var ordered = inOrder(pricing, repository); - ordered.verify(pricing).requireAvailable(10001L, 8L, "CH"); + ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS"); ordered.verify(repository).insertOrder(argThat(order -> "CH".equals(order.getChannelCode()) && "TAX_INCLUDED".equals(order.getPricingTaxMode()))); @@ -133,12 +135,13 @@ class OrderApplicationServiceStateTest { source.amazonWarehouseCode(), source.shipmentId(), source.referenceId()); when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + when(pricing.requireAvailable(10001L, 8L, " ch ", "OVERSEAS")).thenReturn("PCHWK"); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); service.createDraft(10001L, 2L, request, "canonical-channel-code"); - verify(pricing).requireAvailable(10001L, 8L, " ch "); + verify(pricing).requireAvailable(10001L, 8L, " ch ", "OVERSEAS"); verify(repository).insertOrder(argThat(order -> "CH".equals(order.getChannelCode()) && "BUY_DECLARATION".equals(order.getTaxMode()) @@ -182,7 +185,7 @@ class OrderApplicationServiceStateTest { service.updateDraft(10001L, 2L, 1L, request); var ordered = inOrder(pricing, repository); - ordered.verify(pricing).requireAvailable(10001L, 8L, "CH"); + ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS"); ordered.verify(repository).saveOrder(10001L, order); } @@ -244,7 +247,7 @@ class OrderApplicationServiceStateTest { .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); - assertThat(order.getInboundCode()).isEqualTo("RK-1"); + assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0897"); assertThat(order.getSubmittedAt()).isNotNull(); verify(repository).saveOrder(20002L, order); } @@ -274,7 +277,7 @@ class OrderApplicationServiceStateTest { when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); when(repository.findParties(20002L, 1L)).thenReturn(List.of()); doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择")) - .when(pricing).requireAvailable(20002L, 8L, "CH"); + .when(pricing).requireAvailable(20002L, 8L, "CH", "OVERSEAS"); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); @@ -486,13 +489,24 @@ class OrderApplicationServiceStateTest { verify(repository, never()).saveOrder(anyLong(), any()); } + @Test + void inboundCodeUsesChannelPrefixShanghaiDateAndGlobalTicketSequence() { + assertThat(OrderApplicationService.formatInboundCode("ZBYHK", LocalDate.of(2026, 8, 14), 897L)) + .isEqualTo("ZBYHK2608140897"); + assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 13), 10000L)) + .isEqualTo("HDHK26081310000"); + } + private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, OrderMasterDataGuard masterData) { OrderApplicationService service = springService; ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility); ReflectionTestUtils.setField(service, "masterDataGuard", masterData); - ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class)); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK"); + when(repository.nextInboundCodeSequence()).thenReturn(897L); + ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class)); ReflectionTestUtils.setField(service, "json", new ObjectMapper()); return service; diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java index a68617c16..a00240828 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,21 +31,22 @@ class OrderPricingChannelGuardTest { @Test void forwardsCustomerOriginAndChannelToFirstleg() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); - when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(null)); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA"); + when(client.validateChannel("token", request)).thenReturn(ApiResponse.success( + new FirstlegPricingClient.ChannelValidationResponse("MATSON_TRUCK", "ZBYHK"))); - guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"); + assertThat(guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")).isEqualTo("ZBYHK"); verify(client).validateChannel("token", request); } @Test void removedOrForgedChannelKeepsFirstlegBusinessError() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE", "FBA"); when(client.validateChannel("token", request)).thenReturn( ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null)); - assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE")) + assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE", "FBA")) .isInstanceOf(BizException.class) .hasMessage("所选渠道已下线,请重新选择") .extracting(exception -> ((BizException) exception).getResultCode()) @@ -53,18 +55,18 @@ class OrderPricingChannelGuardTest { @Test void nullResponseMapsToServiceUnavailable() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA"); when(client.validateChannel("token", request)).thenReturn(null); - assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")); } @Test void downstreamHttpFailureMapsToServiceUnavailable() { - var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK", "FBA"); when(client.validateChannel("token", request)).thenThrow(feign(403)); - assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA")); } private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index d8e62cd86..de1ca2d1b 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -4,6 +4,20 @@ const CONTAINERS = '/api/platform/v1/containers' function id(value) { return value == null ? '' : String(value) } +export function controlledPhotoDownload(downloadPath, response) { + const controlledPath = typeof downloadPath === 'string' + && downloadPath.startsWith('/api/') + && !downloadPath.startsWith('//') + && !downloadPath.includes('://') + const contentType = String(response?.contentType || '').toLowerCase() + return controlledPath + && response?.http === 200 + && response?.bytes?.length > 0 + && Boolean(contentType) + && !contentType.includes('json') + && !contentType.includes('text/html') +} + export async function run(context) { const loadingRequested = context.options?.modules?.includes('phase3-loading') === true const fixture = loadingRequested @@ -39,7 +53,7 @@ export async function run(context) { if (overview?.pda) { const expectsPhotos = fixture === context.createdResources?.phase3Loading && Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0 - const requiredFields = ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] + const requiredFields = ['taskId', 'taskStatus', 'workflowStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] const missingFields = requiredFields.filter((field) => overview.pda[field] === undefined) context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse, missingFields.length === 0 @@ -48,10 +62,29 @@ export async function run(context) { && Array.isArray(overview.pda.exceptionReasons) && (!expectsPhotos || overview.pda.photos.length > 0), `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos?.length ?? 'not-array'}, missingFields=${missingFields.join('|') || 'none'}, photoCountsArray=${Array.isArray(overview.pda.photoCounts)}, photosArray=${Array.isArray(overview.pda.photos)}, exceptionReasonsArray=${Array.isArray(overview.pda.exceptionReasons)}`, OPTIONS) + const progressIsComplete = Number(overview.pda.itemCount) > 0 + && Number(overview.pda.completedItemCount) === Number(overview.pda.itemCount) + && Number(overview.pda.unresolvedItemCount) === 0 + context.assert(MODULE, 'overview PDA 100%进度区分作业中与待复核', overviewResponse, + !progressIsComplete || overview.pda.taskStatus !== 'LOADING' + || overview.pda.workflowStatus === 'AWAITING_REVIEW', + `taskStatus=${overview.pda.taskStatus || 'missing'}, workflowStatus=${overview.pda.workflowStatus || 'missing'}, completed=${overview.pda.completedItemCount ?? 'missing'}/${overview.pda.itemCount ?? 'missing'}`, OPTIONS) for (const [index, photo] of overview.pda.photos.entries()) { context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse, ['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined), `fileId=${id(photo.fileId)}`, OPTIONS) + const downloadPath = String(photo.downloadPath || '') + const controlledPath = downloadPath.startsWith('/api/') + && !downloadPath.startsWith('//') && !downloadPath.includes('://') + if (!controlledPath) { + context.assert(MODULE, `overview PDA 照片 ${index + 1} 下载路径受控`, overviewResponse, false, + `fileId=${id(photo.fileId)}, downloadPath=${downloadPath || 'missing'}`, OPTIONS) + continue + } + const download = await context.requestBinary('GET', downloadPath) + context.assert(MODULE, `overview PDA 照片 ${index + 1} 可鉴权下载`, download, + controlledPhotoDownload(downloadPath, download), + `fileId=${id(photo.fileId)}, bytes=${download?.bytes?.length ?? 0}, contentType=${download?.contentType || 'missing'}`, OPTIONS) } } diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs new file mode 100644 index 000000000..a8159c9f3 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { controlledPhotoDownload } from './phase3-container-overview.mjs' + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8') +const overviewOpenApi = read('../../firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml') +const oceanOpenApi = read('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +const overviewAcceptance = read('phase3-container-overview.mjs') +const oceanAcceptance = read('ocean-bookings.mjs') +const adminRoot = '../../../apps/admin-web/src/modules/' +const releaseView = read(`${adminRoot}ocean-bookings/pages/OceanBookingDetailView.vue`) +const releaseTypes = read(`${adminRoot}ocean-bookings/types.ts`) +const truckApi = read(`${adminRoot}trucking/api/platformTrucking.ts`) +const truckAttachmentDialog = read(`${adminRoot}trucking/components/TruckAttachmentDialog.vue`) +const truckList = read(`${adminRoot}trucking/pages/TruckAppointmentsView.vue`) +const truckForm = read(`${adminRoot}trucking/components/TruckAppointmentForm.vue`) +const containerList = read(`${adminRoot}container-loading/pages/ContainersView.vue`) +const containerLoadingAcceptance = read('phase3-container-loading.mjs') +const truckAcceptance = read('truck-appointments.mjs') +const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md') + +test('release request freezes all five transport fields and UI blocks incomplete submission', () => { + assert.match(oceanOpenApi, /required: \[expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt\]/) + for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.match(releaseTypes, new RegExp(`${field}: string`), field) + assert.match(releaseView, new RegExp(`releaseForm\\.${field}`), field) + assert.match(oceanAcceptance, new RegExp(`${field}:`), field) + } + assert.match(releaseView, /if \(!billOfLadingNo \|\| !vesselName \|\| !voyageNo \|\| !releaseForm\.estimatedSailingAt \|\| !releaseForm\.estimatedArrivalAt\)/) +}) + +test('overview regression requires workflowStatus and distinguishes 100 percent awaiting review', () => { + assert.match(overviewAcceptance, /'workflowStatus'/) + assert.match(overviewAcceptance, /workflowStatus === 'AWAITING_REVIEW'/) + assert.match(overviewOpenApi, /workflowStatus:/) + assert.doesNotMatch(matrix, /PDA overview `workflowStatus` 缺失[\s\S]*BLOCKED_CONTRACT/) +}) + +test('every overview photo uses its server-provided controlled downloadPath', () => { + assert.match(overviewOpenApi, /\/api\/platform\/v1\/containers\/\{id\}\/pda\/photos\/\{fileId\}\/download/) + assert.match(overviewAcceptance, /context\.requestBinary\('GET', downloadPath\)/) + assert.equal(controlledPhotoDownload('/api/platform/v1/files/1', { + http: 200, bytes: new Uint8Array([1]), contentType: 'image/png', + }), true) + for (const [path, response] of [ + ['https://example.test/file', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png' }], + ['/api/platform/v1/files/1', { http: 200, bytes: new Uint8Array([1]), contentType: 'application/json' }], + ['/api/platform/v1/files/1', { http: 404, bytes: new Uint8Array(), contentType: 'image/png' }], + ]) assert.equal(controlledPhotoDownload(path, response), false) +}) + +test('truck attachment API is frozen while Admin add-reference entry remains blocked', () => { + assert.match(truckApi, /referenceTruckAppointmentAttachment/) + assert.match(truckApi, /deleteTruckAppointmentAttachment/) + assert.doesNotMatch(truckAttachmentDialog, /referenceTruckAppointmentAttachment/) + assert.match(truckAcceptance, /删除后回查拖车预约附件列表/) + assert.match(truckAcceptance, /!remainingAttachments\.some/) + assert.match(matrix, /拖车附件新增入口缺失[\s\S]*BLOCKED_CONTRACT/) +}) + +test('existing container number replacement keeps container plan and PDA identities stable', () => { + assert.match(containerLoadingAcceptance, /replacementContainerNo/) + assert.match(containerLoadingAcceptance, /containerBookingId: containerId/) + assert.match(containerLoadingAcceptance, /loadingPlanId: planId/) + assert.match(containerLoadingAcceptance, /pdaTaskId: taskId/) + assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/) +}) + +test('customer public display uses tenantName without a legalName fallback', () => { + for (const source of [truckList, containerList]) { + assert.match(source, /label: customer\.tenantName/) + } + assert.match(truckForm, /return customer\.tenantName/) + for (const source of [truckList, containerList, truckForm]) + assert.doesNotMatch(source, /customer\.legalName\?\.trim\(\) \|\| customer\.tenantName/) + assert.match(matrix, /客户公开名称一致性[\s\S]*已统一 `tenantName`[\s\S]*`LANDED`/) +}) + +test('Chinese status dialogs and 390px layout remain explicit Admin contract blockers', () => { + assert.match(matrix, /中文状态、弹框与 390px 布局[\s\S]*BLOCKED_CONTRACT/) +}) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index bf397c297..6990a6ba8 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -136,7 +136,15 @@ export async function run(context) { context.assert(MODULE, '附件列表包含本轮上传引用', attachments, Array.isArray(attachmentList) && attachmentList.some((item) => id(item.id) === attachmentId), `attachments=${attachmentList?.length ?? 'n/a'}`, OPTIONS) const forged = await request(context, 'POST', `${path}/attachments`, { fileId: '999999999999999999', attachmentType: 'ACCEPTANCE_EVIDENCE', fileName: `${context.marker}-forged.txt`, fileHash: 'forged' }) context.addStep(MODULE, '伪造附件 fileId 安全拒绝', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) - if (attachmentId) { const removed = await request(context, 'DELETE', `${path}/attachments/${attachmentId}`); context.apiSuccess(MODULE, '删除本轮拖车预约附件引用', removed, `attachmentId=${attachmentId}`, OPTIONS) } + if (attachmentId) { + const removed = await request(context, 'DELETE', `${path}/attachments/${attachmentId}`) + context.apiSuccess(MODULE, '删除本轮拖车预约附件引用', removed, `attachmentId=${attachmentId}`, OPTIONS) + const afterRemoval = await request(context, 'GET', `${path}/attachments`) + const remainingAttachments = context.apiSuccess(MODULE, '删除后回查拖车预约附件列表', afterRemoval, '', OPTIONS) + context.assert(MODULE, '删除后附件引用不再返回', afterRemoval, + Array.isArray(remainingAttachments) && !remainingAttachments.some((item) => id(item.id) === attachmentId), + `attachmentId=${attachmentId}, remaining=${remainingAttachments?.length ?? 'n/a'}`, OPTIONS) + } let version = Number(appointment.version) const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) From 36a048a2bcaad9d08a58279a14d43cb7f354d4f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 14:50:39 +0800 Subject: [PATCH 007/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 +++- auth/AGENTS.md | 5 +- common-core/AGENTS.md | 2 +- common-event-contract/AGENTS.md | 9 +-- common-openapi/AGENTS.md | 2 +- common-security/AGENTS.md | 8 ++- common-web/AGENTS.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 14 +++-- event-bridge-service/AGENTS.md | 4 +- file/AGENTS.md | 1 + finance/AGENTS.md | 1 + firstleg/AGENTS.md | 15 ++--- .../main/resources/openapi/firstleg-v1.yaml | 6 +- .../InboundCartonLabelServiceTest.java | 34 +++++------ .../InboundOrderCreationServiceTest.java | 20 +++---- .../service/MerchantPriceBookServiceTest.java | 2 + .../MojiaMeasurementOpenApiServiceTest.java | 12 ++-- order/AGENTS.md | 3 + .../entity/InboundCodeSequenceEntity.java | 16 ----- .../mapper/InboundCodeSequenceMapper.java | 22 +++++-- .../order/repository/OrderRepository.java | 12 ++-- .../service/OrderApplicationService.java | 3 +- order/src/main/resources/db/order.sql | 14 +++-- .../src/main/resources/openapi/order-v1.yaml | 4 +- .../InboundCodeSequenceRepositoryTest.java | 35 +++++++++++ .../InboundCodeSequenceSqlContractTest.java | 59 +++++++++++++++++++ ...rchantInboundOrderQueryRepositoryTest.java | 2 + .../PlatformOrderPageRepositoryTest.java | 2 + .../OrderApplicationServiceStateTest.java | 20 ++++--- platform-bom/AGENTS.md | 2 +- scripts/README.md | 10 +++- scripts/admin-test-env-modules/README.md | 3 +- .../phase3-container-overview.mjs | 4 +- ...se3-container-regression-contract.test.mjs | 25 +++++--- 34 files changed, 263 insertions(+), 120 deletions(-) delete mode 100644 order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java create mode 100644 order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java create mode 100644 order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java diff --git a/AGENTS.md b/AGENTS.md index 39dcd403f..0a0e87928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,12 @@ - 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。 - `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。 +## 国内头程与海外仓协同 + +- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 +- 统一用户 Token 使用 RS256,海外服务通过 JWKS 公钥验证;国内服务可继续叠加 Redis 会话校验,海外服务不得访问国内 Redis,使用短时 Claims 完成本地认证和应用域/客户范围校验。 +- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 + ## 契约、数据库与验证入口 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 @@ -46,13 +52,13 @@ - 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 -- 数据库只访问所属服务库。模块迁移位于 `/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。 +- 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 ## 全量 HTTP 验收脚本路由 -- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。 +- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 2b49bd724..b1c685f90 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -12,7 +12,8 @@ |---|---| | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | -| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 | +| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`、JWKS 公钥发布 Controller;Auth 是国内与海外仓共用的唯一用户 Token 签发方 | +| 小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;JWT 只承载稳定身份、应用域和版本声明,国内服务用 Redis 会话校验,海外服务不访问国内 Redis | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | @@ -22,7 +23,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内小 JWT 的实时权限来自 Redis 会话快照,海外服务使用短时 Claims,不访问国内 Redis。修改 Claim、JWKS、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 56599c468..277a9fa7d 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -18,7 +18,7 @@ ## 修改与验证 -- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。 +- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。 - `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。 - `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 新能力只有被多个模块稳定复用时才放入本模块。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 0b00b4c7f..ec95783fb 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,12 +14,13 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | +| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` | | 契约说明 | `package-info.java` | -| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` | ## 修改与验证 -- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。 -- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。 -- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。 +- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 +- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 +- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index a98583deb..59eed2647 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -18,6 +18,6 @@ - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 -- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。 +- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 - 从父目录运行 `mvn -pl common-openapi -am test`。 - 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 666aab4ce..80232c3b5 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -9,17 +9,19 @@ | 功能 | 入口 | |---|---| -| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` | +| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | +| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | -| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | ## 修改与验证 -- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。 +- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer、Audience 和 JWKS;国内使用 `session-mode=redis` 叠加即时失效,海外使用 `session-mode=claims` 且不得访问国内 Redis。不得重新引入跨区域共享 HS256 密钥。 +- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index c19ff581f..23b2f7310 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -22,6 +22,6 @@ - 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。 - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 -- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 +- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 786107859..600871730 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -515,7 +515,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+全局票序', + inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+当天全平台票序', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -561,11 +561,13 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; -CREATE TABLE IF NOT EXISTS ord_inbound_code_sequence ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '全平台入仓码票序', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '票序分配时间', - PRIMARY KEY (id) -) ENGINE=InnoDB COMMENT='入仓码全局票序;不同客户和物流渠道共用且不按日期清零'; +CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence ( + business_date DATE NOT NULL COMMENT '上海时区业务日期', + current_value BIGINT UNSIGNED NOT 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 '最近分配时间', + PRIMARY KEY (business_date) +) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; CREATE TABLE IF NOT EXISTS ord_order_party ( id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index f54206504..59c29eeec 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -17,6 +17,7 @@ | Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` | | Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` | | 表结构 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | | 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 | @@ -28,5 +29,6 @@ - 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。 - Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。 - 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。 -- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。 +- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。 +- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。 - 从父目录运行 `mvn -pl event-bridge-service -am test`。 diff --git a/file/AGENTS.md b/file/AGENTS.md index f392a6bf6..fa856c528 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -12,6 +12,7 @@ | 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` | | 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` | | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | +| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 5e8227ef2..6eb33fee2 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -13,6 +13,7 @@ | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 一期账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | +| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM | | 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` | | 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` | | 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 15502383a..f930399c2 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责履约任务/分配、入库单、测量/OCR、审核、FBA 计费、完成入库编排、客户在途柜量统计及国内出仓履约链路。 +- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计及国内出仓履约链路。 - 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。 - 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。 @@ -15,7 +15,7 @@ | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` | | 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | -| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` | +| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | @@ -27,11 +27,12 @@ | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海运订舱与船期 | `oceanbooking/controller/PlatformOceanBookingController.java`、`portschedule/controller/PlatformCarrierScheduleQueryController.java`、`PlatformPortScheduleController.java` | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | -| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | -| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` | +| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | | SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | @@ -47,11 +48,11 @@ - 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。 +- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。 - 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。 - 部分拣货资源结算必须调用 Warehouse 的 `/internal/warehouse/v1/pallet-locations/settlements` 原子接口;禁止拆成独立释放和转移调用。全部释放但子单仍有数量时,由仓库服务重新选择同仓普通库位,不足才落 A0。 -- 数据库字段变更同时维护 `src/main/resources/db/phase3-pda-release-allocation-migration.sql` 和 `../deploy/sql/头程数据库统一增量-MySQL8.sql`。 +- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 ## 全量验收导航 -本模块承载一期履约、二期入库测量计费、三期订单编排/柜次/排柜/订舱/拖车/PDA/`LOADED` 门禁等全量链路;按本次验收范围读取对应阶段文档、Controller、DTO、OpenAPI、迁移和调用方,不以历史阶段终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 +本模块承载一期履约、二期入库测量计费、三期订单编排/柜次/排柜/订舱/拖车/PDA/`LOADED` 门禁等全量链路;按本次验收范围读取对应阶段文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史阶段终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 3e1194788..093b8d4f2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,7 +356,7 @@ paths: type: object required: [boxCode, length, width, height, machineNo, picture] properties: - boxCode: {type: string, maxLength: 128, example: 'ZBYHK2608140897#4-1'} + boxCode: {type: string, maxLength: 128, example: 'ZBYHK2608140001#4-1'} shipment_id: {type: string, maxLength: 64, example: 'FBA19L5KMY2CU000002', description: '可选;设备从Amazon FBA箱标实际扫描得到,有则原样上传;系统只保存,不生成且不覆盖客户下单值'} length: {type: number, format: decimal, minimum: 0.001} width: {type: number, format: decimal, minimum: 0.001} @@ -982,7 +982,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string, nullable: true} - inboundCode: {type: string, minLength: 1, maxLength: 40, example: ZBYHK2608140897} + inboundCode: {type: string, minLength: 1, maxLength: 40, example: ZBYHK2608140001} customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} customerName: {type: string, minLength: 1} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1217,7 +1217,7 @@ components: eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001} externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001} warehouseCode: {type: string, maxLength: 50, example: CN-YW-01} - inboundCode: {type: string, maxLength: 40, example: ZBYHK2608140897} + inboundCode: {type: string, maxLength: 40, example: ZBYHK2608140001} inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID} deviceCode: {type: string, maxLength: 64, example: DEVICE-01} measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index dd53f1372..868af9ece 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -60,8 +60,8 @@ class InboundCartonLabelServiceTest { assertThat(batch.totalCount()).isEqualTo(5); assertThat(batch.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); @@ -102,8 +102,8 @@ class InboundCartonLabelServiceTest { .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); verify(repository, never()).insert(any()); } @@ -122,7 +122,7 @@ class InboundCartonLabelServiceTest { assertThat(result.totalCount()).isEqualTo(2); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#2-1", "PCHK2608040174#2-2"); + .containsExactly("PCHK2608140001#2-1", "PCHK2608140001#2-2"); } @Test @@ -154,35 +154,35 @@ class InboundCartonLabelServiceTest { var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#1-1"); + .containsExactly("PCHK2608140001#1-1"); verify(repository, never()).insert(any()); } @Test void resolvesBoxCodeInsideCustomerBoundary() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); + var resolved = service.resolveByBoxCode(10001L, " PCHK2608140001#1-1 "); assertThat(resolved.inboundId()).isEqualTo(70001L); assertThat(resolved.inboundDetailId()).isEqualTo(80001L); - assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174"); + assertThat(resolved.inboundCode()).isEqualTo("PCHK2608140001"); assertThat(resolved.cartonIndex()).isEqualTo(1); assertThat(resolved.totalCartons()).isEqualTo(1); assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); - verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); + verify(repository).findByBoxCode(10001L, "PCHK2608140001#1-1"); } @Test void exposesDetailedResolutionForFirstlegConsumers() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608140001#1-1"); assertThat(resolved.orderId()).isEqualTo(60001L); - assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608140001#1-1"); assertThat(resolved.cargoItemNo()).isEqualTo(1); } @@ -190,9 +190,9 @@ class InboundCartonLabelServiceTest { void rejectsInactiveBoxCodeDuringScanResolution() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); label.setBatchStatus("VOID"); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#1-1")) + assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608140001#1-1")) .isInstanceOf(BizException.class) .hasMessage("箱码已失效,请重新打印"); } @@ -233,7 +233,7 @@ class InboundCartonLabelServiceTest { @Test void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() { var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( - 10001L, 60001L, "FL60001", "PCHK2608040174", 1L, "Customer", + 10001L, 60001L, "FL60001", "PCHK2608140001", 1L, "Customer", 41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null, null, null, null, null, null, false, "FBA", null, null, null, null, null, null, 2); @@ -251,7 +251,7 @@ class InboundCartonLabelServiceTest { inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setOrderId(60001L); - inbound.setInboundCode("PCHK2608040174"); + inbound.setInboundCode("PCHK2608140001"); inbound.setProcessStatus("WAIT_MEASURE"); inbound.setWarehouseName("MCC1"); inbound.setChannelName("洛杉矶普船海卡"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index c69848b76..c51f8619d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -48,12 +48,12 @@ class InboundOrderCreationServiceTest { @Test void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() { - OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77"); + OrderApprovedInboundCommand command = approvedCommand("PCHK2608140001"); InboundOrderEntity existing = new InboundOrderEntity(); existing.setId(900L); existing.setTenantId(10001L); existing.setOrderId(77L); - existing.setInboundCode("RK-CUSTOM-77"); + existing.setInboundCode("PCHK2608140001"); existing.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); @@ -64,7 +64,7 @@ class InboundOrderCreationServiceTest { ArgumentCaptor inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class); verify(orders, times(1)).insert(inboundCaptor.capture()); verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); - assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); + assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("PCHK2608140001"); assertThat(inboundCaptor.getValue().getTaskId()).isEqualTo(700L); assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); @@ -88,25 +88,25 @@ class InboundOrderCreationServiceTest { @Test void shouldConvergeOnConcurrentDuplicateInsertWinner() { - InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity winner = inbound(901L, "PCHK2608140001", "UNPRICED", null, null); winner.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert")) .when(orders).insert(any(InboundOrderEntity.class)); - assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L); + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(901L); verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); } @Test void shouldBackfillTaskLinkForInboundCreatedBeforeTaskProvisioning() { - InboundOrderEntity existing = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity existing = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); when(orders.update(any(), any(Wrapper.class))).thenReturn(1); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); - assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(900L); + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); assertThat(existing.getTaskId()).isEqualTo(700L); verify(orders).update(any(), any(Wrapper.class)); @@ -115,14 +115,14 @@ class InboundOrderCreationServiceTest { @Test void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() { - InboundOrderEntity prepared = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); prepared.setProcessStatus("PREPARING"); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared); when(orders.update(any(), any(Wrapper.class))).thenReturn(1); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); - long preparedId = service.prepareFromDraft(approvedCommand("RK-CUSTOM-77")); - long approvedId = service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77")); + long preparedId = service.prepareFromDraft(approvedCommand("PCHK2608140001")); + long approvedId = service.createFromApprovedOrder(approvedCommand("PCHK2608140001")); assertThat(preparedId).isNotNull(); assertThat(approvedId).isEqualTo(900L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 7f251c6e7..16f671151 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -45,6 +45,7 @@ class MerchantPriceBookServiceTest { @Test void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelPrefix("MATSON_CLX_TRUCK")) @@ -59,6 +60,7 @@ class MerchantPriceBookServiceTest { @Test void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelPrefix("OA_HOUSTON_TRUCK")) 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 bb99f3f61..d35d4a7a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -50,7 +50,7 @@ class MojiaMeasurementOpenApiServiceTest { } @Test - void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception { + void generatedDailyInboundCartonCodeFlowsThroughMojiaMeasurement() throws Exception { MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); CartonMeasurementService cartons = mock(CartonMeasurementService.class); MeasurementImageClient images = mock(MeasurementImageClient.class); @@ -62,17 +62,17 @@ class MojiaMeasurementOpenApiServiceTest { when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of())); when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult( - 1L, 1, 2L, "RK-001#4-1", "FBA19L5KMY2CU000002", 1, + 1L, 1, 2L, "PCHK2608140001#5-1", "FBA19L5KMY2CU000002", 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true)); UploadResponse result = service.receive(headers, json()); assertThat(result.matched()).isTrue(); - assertThat(result.boxCode()).isEqualTo("RK-001#4-1"); + assertThat(result.boxCode()).isEqualTo("PCHK2608140001#5-1"); ArgumentCaptor command = ArgumentCaptor.forClass(CartonMeasurementCommand.class); verify(cartons).upsert(eq(10001L), eq(90001L), command.capture()); - assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1"); + assertThat(command.getValue().boxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002"); assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1"); assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg"); @@ -108,7 +108,7 @@ class MojiaMeasurementOpenApiServiceTest { ArgumentCaptor receipt = ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); verify(receipts).insert(receipt.capture()); - assertThat(receipt.getValue().getBoxCode()).isEqualTo("RK-001#4-1"); + assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(receipt.getValue().getScannedShipmentId()).isNull(); assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); @@ -117,7 +117,7 @@ class MojiaMeasurementOpenApiServiceTest { private String json() { return """ - {"boxCode":"RK-001#4-1","shipment_id":"FBA19L5KMY2CU000002", + {"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002", "length":60.1,"width":40.2,"height":30.3, "weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} """; diff --git a/order/AGENTS.md b/order/AGENTS.md index 0e414ffb1..d2430c64c 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -12,6 +12,9 @@ |---|---| | 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | +| 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` | +| 入库号生成 | `entity/InboundCodeSequenceEntity.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java` | +| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | | 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 | | 货物内部查询 | `controller/InternalOrderCargoController.java` | | 快照/Outbox | `service/`、`repository/`、`event/` | diff --git a/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java b/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java deleted file mode 100644 index 14f55aa6d..000000000 --- a/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.order.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Getter; -import lombok.Setter; - -/** 全平台入仓码票序;自增主键保证并发创建时不会重复。 */ -@Getter -@Setter -@TableName("ord_inbound_code_sequence") -public class InboundCodeSequenceEntity { - @TableId(type = IdType.AUTO) - private Long id; -} diff --git a/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java index de1192f37..87443a9ac 100644 --- a/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java @@ -1,13 +1,25 @@ package com.baiyue.order.mapper; -import com.baiyue.order.entity.InboundCodeSequenceEntity; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; @Mapper public interface InboundCodeSequenceMapper { - @Insert("INSERT INTO ord_inbound_code_sequence (created_at) VALUES (CURRENT_TIMESTAMP(3))") - @Options(useGeneratedKeys = true, keyProperty = "id") - int allocate(InboundCodeSequenceEntity value); + @Insert(""" + INSERT INTO ord_inbound_code_daily_sequence + (business_date, current_value, created_at, updated_at) + VALUES + (#{businessDate}, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE + current_value = current_value + 1, + updated_at = CURRENT_TIMESTAMP(3) + """) + int increment(@Param("businessDate") LocalDate businessDate); + + @Select("SELECT current_value FROM ord_inbound_code_daily_sequence WHERE business_date = #{businessDate}") + Long currentValue(@Param("businessDate") LocalDate businessDate); } diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 7993a9be1..cc2aa189a 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -13,6 +13,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.List; @@ -35,12 +37,14 @@ public class OrderRepository { @Resource private OrderIdempotencyRecordMapper idempotencyMapper; @Resource private InboundCodeSequenceMapper inboundCodeSequenceMapper; - public long nextInboundCodeSequence() { - InboundCodeSequenceEntity value = new InboundCodeSequenceEntity(); - if (inboundCodeSequenceMapper.allocate(value) != 1 || value.getId() == null) { + @Transactional(propagation = Propagation.MANDATORY) + public long nextInboundCodeSequence(java.time.LocalDate businessDate) { + if (businessDate == null || inboundCodeSequenceMapper.increment(businessDate) < 1) { throw new IllegalStateException("Failed to allocate inbound code sequence"); } - return value.getId(); + Long value = inboundCodeSequenceMapper.currentValue(businessDate); + if (value == null || value < 1) throw new IllegalStateException("Failed to read inbound code sequence"); + return value; } public void insertOrder(OrderEntity order) { orderMapper.insert(order); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index f6ace511a..75b7693b4 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -163,7 +163,8 @@ public class OrderApplicationService { public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } String nextInboundCode(String prefix) { - return formatInboundCode(prefix, LocalDate.now(INBOUND_CODE_ZONE), repository.nextInboundCodeSequence()); + LocalDate businessDate = LocalDate.now(INBOUND_CODE_ZONE); + return formatInboundCode(prefix, businessDate, repository.nextInboundCodeSequence(businessDate)); } static String formatInboundCode(String prefix, LocalDate date, long sequence) { diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index a3e568914..0bb0438f9 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+全局票序', + inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+当天全平台票序', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -60,11 +60,13 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; -CREATE TABLE IF NOT EXISTS ord_inbound_code_sequence ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '全平台入仓码票序', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '票序分配时间', - PRIMARY KEY (id) -) ENGINE=InnoDB COMMENT='入仓码全局票序;不同客户和物流渠道共用且不按日期清零'; +CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence ( + business_date DATE NOT NULL COMMENT '上海时区业务日期', + current_value BIGINT UNSIGNED NOT 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 '最近分配时间', + PRIMARY KEY (business_date) +) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c5081cec3..d07b022f4 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -11,7 +11,7 @@ paths: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+全局票序;有入库单时同时返回 + 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+当天全平台票序;每日按上海日期从0001重新开始;有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, @@ -620,7 +620,7 @@ components: success: {type: boolean} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} - inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}$', example: ZBYHK2608140897} + inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}$', example: ZBYHK2608140001} errorCode: {type: string} errorMessage: {type: string} FirstLegOrderCargo: diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java new file mode 100644 index 000000000..e4f722ba5 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java @@ -0,0 +1,35 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.InboundCodeSequenceMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundCodeSequenceRepositoryTest { + @Mock private InboundCodeSequenceMapper mapper; + + @Test + void incrementsAndReadsTheCounterForTheSameShanghaiBusinessDate() { + LocalDate businessDate = LocalDate.of(2026, 8, 14); + OrderRepository repository = new OrderRepository(); + ReflectionTestUtils.setField(repository, "inboundCodeSequenceMapper", mapper); + when(mapper.increment(businessDate)).thenReturn(2); + when(mapper.currentValue(businessDate)).thenReturn(2L); + + assertThat(repository.nextInboundCodeSequence(businessDate)).isEqualTo(2L); + + InOrder ordered = inOrder(mapper); + ordered.verify(mapper).increment(businessDate); + ordered.verify(mapper).currentValue(businessDate); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java new file mode 100644 index 000000000..2daa67983 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java @@ -0,0 +1,59 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.InboundCodeSequenceMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeSequenceSqlContractTest { + @Test + void mapperAtomicallyIncrementsOneCounterPerBusinessDate() throws Exception { + Method increment = InboundCodeSequenceMapper.class.getMethod("increment", LocalDate.class); + String incrementSql = normalize(String.join(" ", increment.getAnnotation(Insert.class).value())); + Method current = InboundCodeSequenceMapper.class.getMethod("currentValue", LocalDate.class); + String currentSql = normalize(String.join(" ", current.getAnnotation(Select.class).value())); + + assertThat(incrementSql) + .contains("business_date, current_value") + .contains("values (#{businessdate}, 1") + .contains("on duplicate key update") + .contains("current_value = current_value + 1"); + assertThat(currentSql) + .contains("where business_date = #{businessdate}") + .doesNotContain("channel_code") + .doesNotContain("tenant_id"); + } + + @Test + void baselineAndUnifiedSqlDefineTheShanghaiDailyCounter() throws Exception { + String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(resolveUnified(), StandardCharsets.UTF_8); + + for (String sql : java.util.List.of(baseline, unified)) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence") + .contains("business_date DATE NOT NULL") + .contains("current_value BIGINT UNSIGNED NOT NULL") + .contains("PRIMARY KEY (business_date)") + .contains("按上海日期重新从1开始"); + } + } + + private static String normalize(String sql) { + return sql.replaceAll("\\s+", " ").trim().toLowerCase(); + } + + private static Path resolveUnified() { + Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java index c58744782..e82c22c66 100644 --- a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.repository; import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.mapper.InboundCodeSequenceMapper; import com.baiyue.order.mapper.OrderCargoMapper; import com.baiyue.order.mapper.OrderChildPurgeMapper; import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; @@ -38,6 +39,7 @@ class MerchantInboundOrderQueryRepositoryTest { @MockBean private OutboxEventMapper outboxMapper; @MockBean private OrderChildPurgeMapper purgeMapper; @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; @Resource private OrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) diff --git a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java index 3fac52237..8aea2d775 100644 --- a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.repository; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.InboundCodeSequenceMapper; import com.baiyue.order.mapper.OrderCargoMapper; import com.baiyue.order.mapper.OrderChildPurgeMapper; import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; @@ -34,6 +35,7 @@ class PlatformOrderPageRepositoryTest { @MockBean private OutboxEventMapper outboxMapper; @MockBean private OrderChildPurgeMapper purgeMapper; @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; @Resource private OrderRepository repository; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 92b00fb77..505d38937 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -58,7 +58,7 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT - && order.getInboundCode().matches("PCHWK\\d{6}0897"))); + && order.getInboundCode().matches("PCHWK\\d{6}0001"))); } @Test @@ -247,7 +247,7 @@ class OrderApplicationServiceStateTest { .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); - assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0897"); + assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0001"); assertThat(order.getSubmittedAt()).isNotNull(); verify(repository).saveOrder(20002L, order); } @@ -490,11 +490,15 @@ class OrderApplicationServiceStateTest { } @Test - void inboundCodeUsesChannelPrefixShanghaiDateAndGlobalTicketSequence() { - assertThat(OrderApplicationService.formatInboundCode("ZBYHK", LocalDate.of(2026, 8, 14), 897L)) - .isEqualTo("ZBYHK2608140897"); - assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 13), 10000L)) - .isEqualTo("HDHK26081310000"); + void inboundCodeUsesChannelPrefixShanghaiDateAndDailyTicketSequence() { + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 1L)) + .isEqualTo("PCHK2608140001"); + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 2L)) + .isEqualTo("PCHK2608140002"); + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 15), 1L)) + .isEqualTo("PCHK2608150001"); + assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 14), 10000L)) + .isEqualTo("HDHK26081410000"); } private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, @@ -505,7 +509,7 @@ class OrderApplicationServiceStateTest { ReflectionTestUtils.setField(service, "masterDataGuard", masterData); OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK"); - when(repository.nextInboundCodeSequence()).thenReturn(897L); + when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class)); ReflectionTestUtils.setField(service, "json", new ObjectMapper()); diff --git a/platform-bom/AGENTS.md b/platform-bom/AGENTS.md index 052a3daa0..76ff7aa0a 100644 --- a/platform-bom/AGENTS.md +++ b/platform-bom/AGENTS.md @@ -16,5 +16,5 @@ ## 修改与验证 - 新增条目前先确认制品确实需要被其他仓库按固定版本消费;业务服务依赖不要放入 BOM。 -- 版本变化会影响所有 BOM 消费者,并可能需要尾程仓同步升级;保持已发布坐标兼容。 +- 版本变化会影响所有 BOM 消费者;当前开发阶段直接同步本仓与尾程仓消费者,不保留旧坐标或旧版本兼容。 - 从父目录运行 `mvn -pl platform-bom -am validate`。 diff --git a/scripts/README.md b/scripts/README.md index 21fec795b..003fe3b7b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,7 +6,7 @@ |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | | 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | -| Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 | +| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | 执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。 @@ -23,6 +23,14 @@ | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | +Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: + +| 合同范围 | 入口 | +|---|---| +| Admin 订单、箱码、三期装柜/PDA、订舱、拖车、通知 | `scripts/admin-test-env-modules/*contract.test.mjs`;模块映射见 `scripts/admin-test-env-modules/README.md` | +| Client 消息中心调用 | `scripts/client-notifications-contract.test.mjs` | +| 验收入口与模块注册 | `scripts/full-regression-entry-contract.test.mjs`、`scripts/controller-pipeline-contract.test.mjs` | + ## 后端全量回归 ## 入口 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7459def79..8bc936577 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -45,7 +45,8 @@ export async function run(context) { | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | -| 三期柜次、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 三期柜次概览、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-container-regression-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index de1ca2d1b..1deeaebff 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -6,9 +6,7 @@ function id(value) { return value == null ? '' : String(value) } export function controlledPhotoDownload(downloadPath, response) { const controlledPath = typeof downloadPath === 'string' - && downloadPath.startsWith('/api/') - && !downloadPath.startsWith('//') - && !downloadPath.includes('://') + && /^\/api\/platform\/v1\/containers\/[1-9]\d*\/pda\/photos\/[1-9]\d*\/download$/.test(downloadPath) const contentType = String(response?.contentType || '').toLowerCase() return controlledPath && response?.http === 200 diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index a8159c9f3..802b1fec9 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -17,6 +17,7 @@ const truckAttachmentDialog = read(`${adminRoot}trucking/components/TruckAttachm const truckList = read(`${adminRoot}trucking/pages/TruckAppointmentsView.vue`) const truckForm = read(`${adminRoot}trucking/components/TruckAppointmentForm.vue`) const containerList = read(`${adminRoot}container-loading/pages/ContainersView.vue`) +const containerOverviewPanel = read(`${adminRoot}container-loading/components/ContainerOverviewPanel.vue`) const containerLoadingAcceptance = read('phase3-container-loading.mjs') const truckAcceptance = read('truck-appointments.mjs') const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md') @@ -41,23 +42,27 @@ test('overview regression requires workflowStatus and distinguishes 100 percent test('every overview photo uses its server-provided controlled downloadPath', () => { assert.match(overviewOpenApi, /\/api\/platform\/v1\/containers\/\{id\}\/pda\/photos\/\{fileId\}\/download/) assert.match(overviewAcceptance, /context\.requestBinary\('GET', downloadPath\)/) - assert.equal(controlledPhotoDownload('/api/platform/v1/files/1', { + assert.equal(controlledPhotoDownload('/api/platform/v1/containers/7/pda/photos/23/download', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png', }), true) for (const [path, response] of [ ['https://example.test/file', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png' }], - ['/api/platform/v1/files/1', { http: 200, bytes: new Uint8Array([1]), contentType: 'application/json' }], - ['/api/platform/v1/files/1', { http: 404, bytes: new Uint8Array(), contentType: 'image/png' }], + ['/api/platform/v1/files/loading-evidence/21/22/23', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png' }], + ['/api/platform/v1/containers/7/pda/photos/23/download', { http: 200, bytes: new Uint8Array([1]), contentType: 'application/json' }], + ['/api/platform/v1/containers/7/pda/photos/23/download', { http: 404, bytes: new Uint8Array(), contentType: 'image/png' }], ]) assert.equal(controlledPhotoDownload(path, response), false) }) -test('truck attachment API is frozen while Admin add-reference entry remains blocked', () => { +test('truck attachment API and Admin upload-reference-delete flow are frozen', () => { assert.match(truckApi, /referenceTruckAppointmentAttachment/) + assert.match(truckApi, /uploadTruckAppointmentAttachment/) assert.match(truckApi, /deleteTruckAppointmentAttachment/) - assert.doesNotMatch(truckAttachmentDialog, /referenceTruckAppointmentAttachment/) + assert.match(truckAttachmentDialog, /uploadTruckAppointmentAttachment/) + assert.match(truckAttachmentDialog, /上传并关联/) + assert.match(truckAttachmentDialog, /confirmButtonText: '确认', cancelButtonText: '取消'/) assert.match(truckAcceptance, /删除后回查拖车预约附件列表/) assert.match(truckAcceptance, /!remainingAttachments\.some/) - assert.match(matrix, /拖车附件新增入口缺失[\s\S]*BLOCKED_CONTRACT/) + assert.match(matrix, /拖车附件闭环[\s\S]*`LANDED`/) }) test('existing container number replacement keeps container plan and PDA identities stable', () => { @@ -78,6 +83,10 @@ test('customer public display uses tenantName without a legalName fallback', () assert.match(matrix, /客户公开名称一致性[\s\S]*已统一 `tenantName`[\s\S]*`LANDED`/) }) -test('Chinese status dialogs and 390px layout remain explicit Admin contract blockers', () => { - assert.match(matrix, /中文状态、弹框与 390px 布局[\s\S]*BLOCKED_CONTRACT/) +test('Chinese status dialogs and 390px layout are landed', () => { + assert.match(releaseView, /确认放舱/) + assert.match(containerOverviewPanel, /AWAITING_REVIEW: '待人工复核'/) + assert.match(containerOverviewPanel, /PHOTO_STEP_EMPTY_CONTAINER: '空柜照片'/) + assert.match(matrix, /中文状态、弹框与 390px 布局[\s\S]*`LANDED`/) + assert.doesNotMatch(matrix, /拖车附件新增入口缺失|中文状态、弹框与 390px 布局[^\n]*BLOCKED_CONTRACT/) }) From 87eb1aa72f4b720dee6cbfab5e80ae8a674bc98a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 14:54:48 +0800 Subject: [PATCH 008/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- auth/AGENTS.md | 7 ++++--- common-security/AGENTS.md | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a0e87928..cbee3f977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ ## 国内头程与海外仓协同 - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 -- 统一用户 Token 使用 RS256,海外服务通过 JWKS 公钥验证;国内服务可继续叠加 Redis 会话校验,海外服务不得访问国内 Redis,使用短时 Claims 完成本地认证和应用域/客户范围校验。 +- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 - 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 ## 契约、数据库与验证入口 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index b1c685f90..bc91720f2 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -12,8 +12,9 @@ |---|---| | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | -| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`、JWKS 公钥发布 Controller;Auth 是国内与海外仓共用的唯一用户 Token 签发方 | -| 小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;JWT 只承载稳定身份、应用域和版本声明,国内服务用 Redis 会话校验,海外服务不访问国内 Redis | +| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | +| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | +| 海外访问 Token 签发 | 登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience 并携带海外 API 所需权限快照;前端统一认证层负责按区域 API 自动选择 | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | @@ -23,7 +24,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内小 JWT 的实时权限来自 Redis 会话快照,海外服务使用短时 Claims,不访问国内 Redis。修改 Claim、JWKS、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 80232c3b5..5829c1000 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -13,14 +13,14 @@ | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | -| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外本地短时 Claims)、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | ## 修改与验证 -- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer、Audience 和 JWKS;国内使用 `session-mode=redis` 叠加即时失效,海外使用 `session-mode=claims` 且不得访问国内 Redis。不得重新引入跨区域共享 HS256 密钥。 +- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 From 87e919f3c48cb59d7fa1eb5f9d895c8eef65688d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 16:06:27 +0800 Subject: [PATCH 009/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/audit/dto/AuditLogResponses.java | 1 + .../audit/service/AuditLogQueryService.java | 2 +- ...latformAuditLogControllerContractTest.java | 13 ++ .../common/audit/BusinessAuditAspectTest.java | 28 +++- .../audit/SensitiveDataSanitizerTest.java | 14 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 8 +- .../finance/service/FinanceService.java | 46 +++++++ .../baiyue/finance/FinanceContractTest.java | 3 + .../finance/FinanceServiceValidationTest.java | 54 ++++++++ .../PlatformLoadingPdaController.java | 3 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 9 +- .../repository/OceanBookingRepository.java | 4 + .../service/OceanBookingService.java | 4 + .../firstleg/service/ContainerService.java | 15 +-- .../service/InboundMeasurementService.java | 4 + .../firstleg/service/LoadingPickService.java | 43 +++--- .../service/MerchantPriceBookService.java | 4 +- .../firstleg/service/SmartLoadingService.java | 9 +- .../resources/db/phase2-inbound-schema.sql | 4 +- .../main/resources/openapi/firstleg-v1.yaml | 20 +++ .../openapi/phase3-pda-convergence-v1.yaml | 2 - .../PdaConvergenceOpenApiContractTest.java | 13 ++ ...tformLoadingPdaControllerContractTest.java | 2 +- .../controller/PriceBookContractTest.java | 30 ++++- .../service/OceanBookingServiceTest.java | 21 +++ .../service/ContainerServiceTest.java | 7 +- .../InboundMeasurementServiceTest.java | 22 +++ .../service/LoadingPickServiceTest.java | 87 ++++++++---- .../service/SmartLoadingServiceTest.java | 4 +- .../GatewayFileRouteConfigurationTest.java | 9 +- .../controller/InternalTenantController.java | 2 +- .../controller/PlatformTenantController.java | 2 +- .../PlatformNotificationController.java | 2 + .../notification/dto/NotificationDtos.java | 12 +- .../mapper/NotificationTaskMapper.java | 68 +++++++++- .../repository/NotificationRepository.java | 48 +++++-- ...otificationDispatchTransactionService.java | 74 ++++++++++ .../service/NotificationService.java | 76 ++++++++--- .../CustomerListExportProviderService.java | 3 +- .../com/baiyue/iam/service/TenantService.java | 12 +- iam/src/main/resources/db/iam.sql | 2 +- iam/src/main/resources/openapi/iam-v1.yaml | 4 +- .../resources/openapi/notification-v1.yaml | 24 +++- .../PlatformTenantControllerContractTest.java | 2 +- ...icationDispatchTransactionServiceTest.java | 126 ++++++++++++++++++ ...tificationInboxPermissionContractTest.java | 10 ++ .../NotificationOpenApiContractTest.java | 10 ++ .../NotificationSchedulerRecoveryTest.java | 38 +++++- .../notification/NotificationServiceTest.java | 77 +++++++++-- .../baiyue/iam/service/TenantServiceTest.java | 36 ++++- .../phase3-container-loading.mjs | 29 +++- ...se3-container-regression-contract.test.mjs | 8 ++ .../phase3-producer-chain-contract.test.mjs | 7 +- .../phase3-warehouse-rbac.mjs | 2 +- 54 files changed, 984 insertions(+), 175 deletions(-) create mode 100644 iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java create mode 100644 iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java index def500edf..8f43954e7 100644 --- a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java +++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java @@ -24,6 +24,7 @@ public final class AuditLogResponses { String username, String appCode, String clientType, + String clientIp, String requestId, String traceId, String serviceName, diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java index b16fec9e1..d89b6527d 100644 --- a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java +++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java @@ -41,7 +41,7 @@ public class AuditLogQueryService { return new AuditLogResponses.ListItem( entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(), entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(), - entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(), + entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(), entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt()); } diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java index 95f9dc7c9..b7f14cbab 100644 --- a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java +++ b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.security.access.prepost.PreAuthorize; import java.util.List; import java.util.Set; @@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest { verify(service).detail(8L); } + @Test + void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException { + String expected = "hasAuthority('platform:audit:list')"; + + assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class, + long.class, long.class, Long.class, Long.class, String.class, String.class, String.class, + Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo(expected); + } + private static AuthenticatedUser user(String appCode) { return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list")); } diff --git a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java index b76468b14..792347408 100644 --- a/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java +++ b/common-audit/src/test/java/com/baiyue/common/audit/BusinessAuditAspectTest.java @@ -48,7 +48,26 @@ class BusinessAuditAspectTest { assertThat(event.operatorTenantId()).isEqualTo(10001L); } + @Test + void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable { + AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L, + "platform.admin", "PLATFORM", "WEB", Set.of()), 10001L); + + assertThat(event.success()).isFalse(); + assertThat(event.errorCode()).isEqualTo("IllegalStateException"); + assertThat(event.beforeData()).isEqualTo("[10001]"); + assertThat(event.afterData()).isNull(); + } + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable { + return invoke(user, requestedTenantId, null); + } + + private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable { + return invoke(user, requestedTenantId, new IllegalStateException("validation failed")); + } + + private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable { AuditSink sink = mock(AuditSink.class); BusinessAuditAspect aspect = new BusinessAuditAspect(); ReflectionTestUtils.setField(aspect, "sink", sink); @@ -68,9 +87,14 @@ class BusinessAuditAspectTest { when(joinPoint.getSignature()).thenReturn(signature); when(joinPoint.getTarget()).thenReturn(target); when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId}); - when(joinPoint.proceed()).thenReturn("ok"); + if (failure == null) when(joinPoint.proceed()).thenReturn("ok"); + else when(joinPoint.proceed()).thenThrow(failure); - aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); + try { + aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); + } catch (Throwable error) { + if (failure == null) throw error; + } ArgumentCaptor event = ArgumentCaptor.forClass(AuditEvent.class); verify(sink).publish(event.capture()); diff --git a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java index 76be507e3..ab9ee8e3a 100644 --- a/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java +++ b/common-audit/src/test/java/com/baiyue/common/audit/SensitiveDataSanitizerTest.java @@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest { assertThat(parsed.path("_truncated").asBoolean()).isTrue(); assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40); } + + @Test + void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception { + SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024); + + String sanitized = sanitizer.sanitize(Map.of( + "profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"), + "contacts", java.util.List.of(Map.of("emailAddress", "user@example.com")))); + JsonNode parsed = mapper.readTree(sanitized); + + assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******"); + assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******"); + assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******"); + } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 600871730..24dbaba03 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3449,8 +3449,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', - inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀', - overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', 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 '最后更新时间', @@ -3474,9 +3474,9 @@ BEGIN END$$ DELIMITER ; CALL price_channel_add_column('inbound_code_prefix', - "inbound_code_prefix VARCHAR(6) NULL COMMENT '普通目的仓入仓码物流前缀' AFTER aliases_json"); + "inbound_code_prefix VARCHAR(6) NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用' AFTER aliases_json"); CALL price_channel_add_column('overseas_inbound_code_prefix', - "overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀' AFTER inbound_code_prefix"); + "overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀' AFTER inbound_code_prefix"); DROP PROCEDURE IF EXISTS price_channel_add_column; CREATE TABLE IF NOT EXISTS fl_price_origin_alias ( diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 917a1bc1a..5df93c394 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -16,8 +16,10 @@ import java.sql.PreparedStatement; import java.sql.Statement; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** 首期人工财务台账:账单、收款和成本,不承载钱包、发票或复杂核销。 */ @Service @@ -116,6 +118,7 @@ public class FinanceService { if (replay != null) return bill(tenantId, replay); if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细"); request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); }); + validateBillSources(tenantId, request.lines()); BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() < 0) throw bad("账单金额不能为负数"); Object credit = customerTerms(tenantId).get("credit_days"); @@ -139,6 +142,49 @@ public class FinanceService { return jdbc.queryForMap("SELECT * FROM fin_bill WHERE id=?", billId); } + /** Initial statements may only snapshot approved, included Finance sources; the client cannot choose an amount. */ + private void validateBillSources(long tenantId, List lines) { + Set seen = new HashSet<>(); + for (BillLine line : lines) { + if (!seen.add(line.sourceId() + ":" + line.sourceVersion())) throw bad("同一费用来源版本不能重复加入账单"); + List> rows = jdbc.queryForList( + "SELECT customer_id,source_fee_version,direction,currency_code,amount,status,review_status,settlement_included " + + "FROM fin_fee_source WHERE id=? FOR UPDATE", line.sourceId()); + if (rows.size() != 1) throw bad("费用来源不存在"); + Map source = rows.get(0); + if (number(source, "customer_id") != tenantId + || number(source, "source_fee_version") != line.sourceVersion() + || !"RECEIVABLE".equals(source.get("direction")) + || !"CNY".equals(source.get("currency_code")) + || !"AVAILABLE".equals(source.get("status")) + || !"APPROVED".equals(source.get("review_status")) + || !truth(source.get("settlement_included"))) { + throw bad("费用来源未审核、未参与结算或不属于当前客户"); + } + BigDecimal sourceAmount = decimal(source, "amount"); + if (sourceAmount.compareTo(line.amount()) != 0) throw bad("账单金额必须等于费用来源金额"); + Integer used = jdbc.queryForObject( + "SELECT COUNT(*) FROM fin_bill_line l JOIN fin_bill b ON b.id=l.bill_id " + + "WHERE l.tenant_id=? AND l.source_id=? AND l.source_version=? AND b.status<>'VOIDED'", + Integer.class, tenantId, line.sourceId(), line.sourceVersion()); + if (used != null && used > 0) throw bad("费用来源已被有效账单占用"); + } + } + + private static long number(Map row, String key) { + Object value = row.get(key); + return value instanceof Number n ? n.longValue() : Long.parseLong(String.valueOf(value)); + } + + private static BigDecimal decimal(Map row, String key) { + Object value = row.get(key); + return value instanceof BigDecimal decimal ? decimal : new BigDecimal(String.valueOf(value)); + } + + private static boolean truth(Object value) { + return Boolean.TRUE.equals(value) || "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value)); + } + @Transactional public Map issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) { requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion)); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 7ad137d03..2cdebed9a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -24,6 +24,9 @@ class FinanceContractTest { assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo")); assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求")); assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); + assertTrue(service.contains("validateBillSources(tenantId, request.lines())")); + assertTrue(service.contains("账单金额必须等于费用来源金额")); + assertTrue(service.contains("source_fee_version") && service.contains("settlement_included")); String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java")); assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile")); assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 971a49cf2..5c2495798 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -9,10 +9,13 @@ import com.baiyue.common.core.exception.BizException; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -21,6 +24,45 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class FinanceServiceValidationTest { + @Test + void acceptsOnlyApprovedIncludedReceivableSourceAtItsRecordedAmount() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L))) + .thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50"))); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), + eq(9L), eq(81L), eq(3))).thenReturn(0); + FinanceService service = service(jdbc); + + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "validateBillSources", 9L, + List.of(billLine(81L, 3, "125.50")))); + } + + @Test + void rejectsClientAmountThatDiffersFromFinanceSourceSnapshot() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L))) + .thenReturn(List.of(source(9L, 3, "AVAILABLE", "APPROVED", true, "125.50"))); + FinanceService service = service(jdbc); + + BizException error = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service, + "validateBillSources", 9L, List.of(billLine(81L, 3, "99.00")))); + + assertEquals("账单金额必须等于费用来源金额", error.getMessage()); + verify(jdbc, never()).queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), + eq(Integer.class), any(), any(), any()); + } + + @Test + void rejectsSourceThatIsNotApprovedAndIncludedForSettlement() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForList(startsWith("SELECT customer_id,source_fee_version"), eq(81L))) + .thenReturn(List.of(source(9L, 3, "AVAILABLE", "PENDING", false, "125.50"))); + FinanceService service = service(jdbc); + + assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(service, + "validateBillSources", 9L, List.of(billLine(81L, 3, "125.50")))); + } + @Test void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() { var rows = List.of( @@ -240,4 +282,16 @@ class FinanceServiceValidationTest { return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(), mock(FinanceVoucherValidator.class)); } + + private static FinanceService.BillLine billLine(long sourceId, int version, String amount) { + return new FinanceService.BillLine("FIRSTLEG_FEE", sourceId, version, null, 31L, 41L, + "FREIGHT", "运费", new BigDecimal(amount)); + } + + private static Map source(long customerId, int version, String status, + String reviewStatus, boolean included, String amount) { + return Map.of("customer_id", customerId, "source_fee_version", version, + "direction", "RECEIVABLE", "currency_code", "CNY", "amount", new BigDecimal(amount), + "status", status, "review_status", reviewStatus, "settlement_included", included); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 872bbfdf1..269c7c457 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -45,12 +45,11 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String containerNo, @RequestParam(required = false) String cabinetNo, @RequestParam(required = false) Long loadingPlanId, @RequestParam @Positive long warehouseId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.task(containerNo, cabinetNo, loadingPlanId, warehouseId)); + return ApiResponse.success(service.task(cabinetNo, loadingPlanId, warehouseId)); } @GetMapping("/tasks/pending") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index f08139693..a888e52b7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -60,17 +60,10 @@ public final class LoadingPickDtos { @NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Long loadingPlanId, @NotNull @Min(1) Long containerBookingId, - @Size(max = 64) String containerNo, @NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max = 64) String inventorySnapshotToken, @NotEmpty @Size(max = 500) List<@Valid BatchItemRequest> items - ) { - @Deprecated - public BatchRequest(Long warehouseId, String containerNo, Integer expectedVersion, - String inventorySnapshotToken, List items) { - this(warehouseId, null, null, containerNo, expectedVersion, inventorySnapshotToken, items); - } - } + ) {} public record BatchItemRequest( @NotNull @Min(1) Long itemId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 7f4519d6f..05753f42b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -59,6 +59,10 @@ public class OceanBookingRepository { public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); + } public List histories(long id) { return histories.selectList(new LambdaQueryWrapper().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); } public void replaceParties(long id, List values) { parties.delete(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); } public void replaceRequirements(long id, List values) { requirements.delete(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 926b32f25..9eb7c19fc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.service.LoadingPickService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -31,6 +32,7 @@ public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); @Resource private OceanBookingRepository repository; @Resource private ObjectMapper objectMapper; + @Resource private LoadingPickService loadingPickService; public PageResult page(Query query) { String status = trim(query.status()); @@ -106,6 +108,8 @@ public class OceanBookingService { requireStatus(before, OceanBookingStatus.CONFIRMED); if (repository.release(id, request, operatorId, name) != 1) throw concurrent(); repository.syncReleasedTransport(id, request, operatorId, name); + repository.allocations(id).forEach(allocation -> + loadingPickService.beginIfReady(allocation.getContainerBookingId(), operatorId, name)); OceanBookingEntity after = required(id); addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index c3657cab2..ed18731ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -104,23 +104,10 @@ public class ContainerService { history.setOperatorId(operatorId); history.setOperatorName(operatorName); if (transportHistories != null) transportHistories.insert(history); ContainerBookingEntity updated = required(id); - beginPdaTaskAfterContainerBound(updated, containerNo, operatorId, operatorName); + loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName); return updated; } - /** 预装清单先推送、后录入集装箱号时,补建对应的 PDA 拣货任务。 */ - private void beginPdaTaskAfterContainerBound(ContainerBookingEntity booking, String containerNo, - long operatorId, String operatorName) { - if (containerNo == null) return; - LoadingPlanEntity plan = smartLoadingRepository.planByBooking(booking.getId()); - if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; - // The PDA task and its item versions are stable work identities. A later transport-number - // correction only changes the booking display value and must not resynchronize the task. - if (loadingPickRepository.taskByPlan(plan.getId()) != null) return; - loadingPickService.begin(plan.getId(), containerNo, operatorId, operatorName, - smartLoadingRepository.items(plan.getId())); - } - private ContainerBookingEntity required(long id) { ContainerBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index ed3aa7aca..199f96517 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -174,8 +174,12 @@ public class InboundMeasurementService { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId); requireVersion(request.version(), weight.getVersion()); + boolean wasConfirmed = WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()); applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(), request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName); + if (wasConfirmed) { + recalculateConfirmedWeight(inbound, operatorId); + } return toWeight(weight); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index c2ab1987d..4c620349c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -33,6 +33,7 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import jakarta.annotation.Resource; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; @@ -65,6 +66,8 @@ public class LoadingPickService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource + private OceanBookingRepository oceanBookingRepository; + @Resource private LoadingListService loadingListService; @Resource private PdaPickSettlementService settlementService; @@ -73,6 +76,22 @@ public class LoadingPickService { @Value("${baiyue.security.internal-service-token:test-internal-service-token}") private String internalToken; + /** Opens the field task only after planning, transport identity and ocean release have converged. */ + @Transactional + public void beginIfReady(long containerBookingId, long operatorId, String operatorName) { + var booking = smartLoadingRepository.booking(containerBookingId); + if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return; + var plan = smartLoadingRepository.planByBooking(containerBookingId); + if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; + var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); + if (allocation == null) return; + var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); + if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) return; + if (repository.taskByPlan(plan.getId()) != null) return; + begin(plan.getId(), booking.getContainerNo(), operatorId, operatorName, + smartLoadingRepository.items(plan.getId())); + } + @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, List planItems) { @@ -111,29 +130,14 @@ public class LoadingPickService { } } - public Task task(String containerNo, long warehouseId) { - if (!StringUtils.hasText(containerNo)) { - throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空"); - } - if (warehouseId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); - } - LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId); - if (task == null) { - throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); - } - return view(task, warehouseId); - } - - public Task task(String containerNo, String cabinetNo, Long loadingPlanId, long warehouseId) { - int selectors = (StringUtils.hasText(containerNo) ? 1 : 0) + (StringUtils.hasText(cabinetNo) ? 1 : 0) + public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) { + int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0) + (loadingPlanId != null ? 1 : 0); if (selectors != 1) throw new BizException(ResultCode.BAD_REQUEST, - "containerNo、cabinetNo、loadingPlanId必须且只能提供一个"); + "cabinetNo、loadingPlanId必须且只能提供一个"); LoadingPickTaskEntity task = StringUtils.hasText(cabinetNo) ? repository.taskByCabinet(cabinetNo.trim(), warehouseId) - : loadingPlanId != null ? repository.taskByPlan(loadingPlanId, warehouseId) - : repository.taskByContainer(containerNo.trim(), warehouseId); + : repository.taskByPlan(loadingPlanId, warehouseId); if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务"); return view(task, warehouseId); } @@ -854,6 +858,7 @@ public class LoadingPickService { } StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|') .append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|') + .append(request.containerBookingId()).append('|') .append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim()); request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId, Comparator.nullsFirst(Long::compareTo))) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index ba9a37c8c..7ec7922d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -49,7 +49,7 @@ public class MerchantPriceBookService { } public Response requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode, - String destinationType) { + String destinationType) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); Long versionId = repository.activeVersionId(); @@ -59,7 +59,7 @@ public class MerchantPriceBookService { } var prefix = repository.channelPrefix(normalized); if (prefix == null || !StringUtils.hasText(prefix.inboundCodePrefix())) { - throw new BizException(ResultCode.CONFLICT, "所选渠道未配置入仓码前缀"); + throw new BizException(ResultCode.CONFLICT, "所选渠道未配置进仓编码"); } String selected = "OVERSEAS".equals(destinationType) && StringUtils.hasText(prefix.overseasInboundCodePrefix()) ? prefix.overseasInboundCodePrefix() : prefix.inboundCodePrefix(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 335660bea..7e0969b11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -225,21 +225,22 @@ public class SmartLoadingService { String key = trim(idempotencyKey); if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key"); LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); - if (key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId())); + if (key.equals(plan.getPushIdempotencyKey())) { + loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName); + return view(plan, requireBooking(plan.getContainerBookingId())); + } if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送"); if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配"); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List items = repository.items(planId); if (items.isEmpty()) throw validation("装柜明细不能为空"); checkPlanCapacity(booking, items); - if (StringUtils.hasText(booking.getContainerNo())) { - loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, items); - } try { if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName); LoadingPlanEntity pushed = repository.plan(planId); + loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName); return view(pushed, requireBooking(pushed.getContainerBookingId())); } diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index eb50fe04b..3fc7fa9cb 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -421,8 +421,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', aliases_json JSON NOT NULL COMMENT '上传名称别名数组', - inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓入仓码物流前缀', - overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转入仓码物流前缀;为空时使用普通前缀', + inbound_code_prefix VARCHAR(6) NOT NULL COMMENT '普通目的仓进仓编码前缀;包税与不包税共用', + overseas_inbound_code_prefix VARCHAR(6) NULL COMMENT '海外仓中转进仓编码前缀;包税与不包税共用,为空时使用普通前缀', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', 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 '最后更新时间', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 093b8d4f2..b7054cf21 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -49,6 +49,26 @@ paths: - {name: initializationKey, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} responses: '200': {description: 幂等补偿完成} + /internal/firstleg/v1/pricing/channels/validate: + post: + summary: 校验订单渠道并返回进仓编码前缀 + description: 仅供订单服务调用;前缀按渠道和目的仓类型选择,同一渠道的包税与不包税订单共用编码前缀。 + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tenantId, originWarehouseId, channelCode, destinationType] + properties: + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + channelCode: {type: string, minLength: 1, maxLength: 64} + destinationType: {type: string, enum: [FBA, OVERSEAS, PRIVATE]} + responses: + '200': {description: 成功时 data 返回 channelCode 和 inboundCodePrefix;未配置对应进仓编码时返回业务冲突} /api/platform/v1/first-leg/customer-dimension-config: get: summary: 查询客户固定四箱规尺寸配置 diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index aebdd7383..8074f91ad 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -7,7 +7,6 @@ paths: get: summary: Find the active container picking task in one platform warehouse parameters: - - {name: containerNo, in: query, required: false, deprecated: true, description: Compatibility lookup only; taskId is the stable identity, schema: {type: string}} - {name: cabinetNo, in: query, required: false, schema: {type: string}} - {name: loadingPlanId, in: query, required: false, schema: {type: integer, format: int64}} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -195,7 +194,6 @@ components: warehouseId: {type: integer, format: int64, minimum: 1} loadingPlanId: {type: integer, format: int64, minimum: 1} containerBookingId: {type: integer, format: int64, minimum: 1} - containerNo: {type: string, maxLength: 64, nullable: true, deprecated: true, description: Accepted during compatibility period but ignored for identity and idempotency} expectedVersion: {type: integer, minimum: 0} inventorySnapshotToken: {type: string, minLength: 64, maxLength: 64} items: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 58af100bc..33f05f9a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -31,5 +31,18 @@ class PdaConvergenceOpenApiContractTest { .contains("changing it never resynchronizes the task or item versions") .contains("expectedTaskVersion") .contains("CompleteLoadingRequest"); + + String taskLookup = yaml.substring( + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks:"), + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/pending:")); + String batchRequest = yaml.substring( + yaml.indexOf(" BatchRequest:"), + yaml.indexOf(" BatchItemRequest:")); + assertThat(taskLookup) + .contains("name: cabinetNo", "name: loadingPlanId") + .doesNotContain("name: containerNo"); + assertThat(batchRequest) + .contains("loadingPlanId:", "containerBookingId:") + .doesNotContain("containerNo:"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index a173b51db..553b49f95 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class PlatformLoadingPdaControllerContractTest { @Test void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception { - authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, String.class, Long.class, long.class); + authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, Long.class, long.class); authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class); authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index ca577af36..d23d7d14b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; @DisplayName("船期价格表公共契约") class PriceBookContractTest { @@ -24,11 +25,23 @@ class PriceBookContractTest { void keepsStableChannelCatalog() { assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15); assertThat(PriceBookChannelCatalog.CHANNELS) - .extracting(value -> value.channelCode() + ":" + value.inboundCodePrefix()) - .contains("MATSON_CLX_TRUCK:ZBYHK", "OA_LA_FULL_TRUCK:PCKK", "OA_CHICAGO_RAIL:CPHT"); - assertThat(PriceBookChannelCatalog.CHANNELS) - .filteredOn(value -> "OA_NEW_YORK_TRUCK".equals(value.channelCode())) - .singleElement().extracting(value -> value.overseasInboundCodePrefix()).isEqualTo("NPHWK"); + .extracting("channelCode", "inboundCodePrefix", "overseasInboundCodePrefix") + .containsExactly( + tuple("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK"), + tuple("MATSON_MAX_TRUCK", "BYHK", "BYHWK"), + tuple("EXX_TRUCK", "EXXHK", "EXXHWK"), + tuple("HEDE_TRUCK", "HDHK", "HDHWK"), + tuple("EXX_RAIL_NY", "NYEXX", null), + tuple("OA_LA_TRUCK", "PCHK", "PCHWK"), + tuple("OA_LA_FULL_TRUCK", "PCKK", null), + tuple("OA_CHICAGO_RAIL", "CPHT", null), + tuple("OA_HOUSTON_TRUCK", "HPHK", null), + tuple("OA_NEW_YORK_TRUCK", "NPHK", "NPHWK"), + tuple("OA_SAVANNAH_TRUCK", "SPHK", null), + tuple("MATSON_CLX_PARCEL", "ZBYHP", null), + tuple("MATSON_MAX_PARCEL", "BYHP", null), + tuple("HEDE_PARCEL", "HDHP", null), + tuple("OA_PARCEL", "PCHP", null)); assertThat(PriceBookChannelCatalog.CHANNELS.stream() .map(PriceBookChannelCatalog.ChannelDefinition::channelCode)) .containsExactly( @@ -102,5 +115,12 @@ class PriceBookContractTest { .contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source") .contains("确认并立即生效") .contains("SUPERSEDED"); + + String firstlegOpenapi = new ClassPathResource("openapi/firstleg-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(firstlegOpenapi) + .contains("/internal/firstleg/v1/pricing/channels/validate:") + .contains("required: [tenantId, originWarehouseId, channelCode, destinationType]") + .contains("同一渠道的包税与不包税订单共用编码前缀"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d9cd3c9b0..121390e9d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -5,6 +5,8 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest; +import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; @@ -30,6 +32,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; + @Mock private LoadingPickService loadingPickService; @InjectMocks private OceanBookingService service; @BeforeEach @@ -77,6 +80,24 @@ class OceanBookingServiceTest { verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); } + @Test + void opensAllocatedContainerPdaTasksAfterRelease() { + OceanBookingEntity confirmed = booking("CONFIRMED", 3); + OceanBookingEntity released = booking("RELEASED", 4); + var allocation = new OceanBookingContainerAllocationEntity(); + allocation.setContainerBookingId(12L); + when(repository.find(1L)).thenReturn(confirmed, released); + when(repository.release(eq(1L), any(ReleaseRequest.class), eq(9L), eq("admin"))).thenReturn(1); + when(repository.allocations(1L)).thenReturn(List.of(allocation)); + var request = new ReleaseRequest(3, "BL-001", "VESSEL", "V001", + LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), "放舱"); + + Detail result = service.release(1L, 9L, "admin", "release-1", request); + + assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED"); + verify(loadingPickService).beginIfReady(12L, 9L, "admin"); + } + @Test void keepsCurrentVoyagePendingUntilChangeConfirmation() { OceanBookingEntity current = booking("CONFIRMED", 5); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 19066d250..46693ba9b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -143,9 +143,7 @@ class ContainerServiceTest { service.updateTransport(11L, 9L, "admin", new TransportUpdateRequest("MSCU1234567", "V002", 2)); - verify(loadingPickService).begin(org.mockito.ArgumentMatchers.eq(31L), - org.mockito.ArgumentMatchers.eq("MSCU1234567"), org.mockito.ArgumentMatchers.eq(9L), - org.mockito.ArgumentMatchers.eq("admin"), any()); + verify(loadingPickService).beginIfReady(11L, 9L, "admin"); } @Test @@ -165,8 +163,7 @@ class ContainerServiceTest { service.updateTransport(11L, 9L, "admin", new TransportUpdateRequest("MSCU7654321", "V002", 2)); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), any(), any()); - verify(smartLoadingRepository, never()).items(31L); + verify(loadingPickService).beginIfReady(11L, 9L, "admin"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 52634b9df..009fab68a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -85,6 +85,28 @@ class InboundMeasurementServiceTest { assertThat(revision.getValue().getAfterWeightValue()).isEqualByComparingTo("13.200"); } + @Test + void correctingConfirmedWeightImmediatelyRemovesItFromInboundTotal() { + InboundOrderEntity inbound = inbound("MEASURING"); + inbound.setActualGrossWeightKg(new BigDecimal("12.500")); + InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); + weight.setFinalWeightKg(new BigDecimal("12.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + service.correctWeightResult(1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.CorrectWeightResultRequest( + 4, new BigDecimal("13.200"), "KG", "秤面显示修正")); + + assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM"); + assertThat(weight.getFinalWeightKg()).isNull(); + assertThat(inbound.getActualGrossWeightKg()).isNull(); + verify(repository).updateInboundWeightSummary(inbound); + } + @Test void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 73251d400..35e990082 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -19,6 +19,9 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; +import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.mockito.InOrder; @@ -46,6 +49,8 @@ class LoadingPickServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean + private OceanBookingRepository oceanBookingRepository; + @MockBean private LoadingListService loadingListService; @MockBean private PdaPickSettlementService settlementService; @@ -54,6 +59,51 @@ class LoadingPickServiceTest { @Resource private LoadingPickService service; + @Test + void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(smartLoadingRepository.plan(10L)).thenReturn(plan); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + when(repository.taskByPlan(10L)).thenReturn(null); + when(smartLoadingRepository.items(10L)).thenReturn(List.of()); + doAnswer(invocation -> { + invocation.getArgument(0, LoadingPickTaskEntity.class).setId(20L); + return null; + }).when(repository).insert(any(LoadingPickTaskEntity.class)); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository).insert(any(LoadingPickTaskEntity.class)); + } + + @Test + void doesNotOpenTaskBeforeOceanRelease() { + var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + booking.setId(40L); booking.setContainerNo("MSCU-001"); + var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); + plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); + plan.setPreloadPushedAt(java.time.LocalDateTime.now()); + var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED"); + when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); + + service.beginIfReady(40L, 7L, "admin"); + + verify(repository, never()).insert(any(LoadingPickTaskEntity.class)); + } + @Test void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() { LoadingPickTaskEntity task = task(); @@ -66,8 +116,8 @@ class LoadingPickServiceTest { when(repository.items(20L, 101L)).thenReturn(List.of()); when(repository.cartonLabels(20L)).thenReturn(List.of()); - var byCabinet = service.task(null, "CAB-001", null, 101L); - var byPlan = service.task(null, null, 10L, 101L); + var byCabinet = service.task("CAB-001", null, 101L); + var byPlan = service.task(null, 10L, 101L); assertThat(byCabinet.id()).isEqualTo(20L); assertThat(byPlan.id()).isEqualTo(20L); @@ -76,27 +126,12 @@ class LoadingPickServiceTest { @Test void rejectsMissingOrConflictingCompatibilitySelectors() { - assertThatThrownBy(() -> service.task(null, null, null, 101L)) + assertThatThrownBy(() -> service.task(null, null, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); - assertThatThrownBy(() -> service.task("MSCU", "CAB", null, 101L)) + assertThatThrownBy(() -> service.task("CAB", 10L, 101L)) .isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个"); } - @Test - void legacyContainerLookupReturnsCurrentTransportDisplayWithoutChangingIdentity() { - LoadingPickTaskEntity task = task(); task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); - var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - booking.setId(40L); booking.setContainerNo("MSCU-CURRENT"); - when(repository.taskByContainer("MSCU-CURRENT", 101L)).thenReturn(task); - when(smartLoadingRepository.booking(40L)).thenReturn(booking); - when(repository.items(20L, 101L)).thenReturn(List.of()); - when(repository.cartonLabels(20L)).thenReturn(List.of()); - var result = service.task("MSCU-CURRENT", null, null, 101L); - assertThat(result.id()).isEqualTo(20L); - assertThat(result.loadingPlanId()).isEqualTo(10L); - assertThat(result.currentContainerNo()).isEqualTo("MSCU-CURRENT"); - } - @Test void keepsPlanLoadingWhenItemsRemain() { LoadingPickTaskEntity task = task(); @@ -271,7 +306,7 @@ class LoadingPickServiceTest { ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "SKIP", null, null, null, null, 0, "damaged carton"))); @@ -301,7 +336,7 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).thenReturn(items); when(repository.items(20L, 101L)).thenReturn(items); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null), new BatchItemRequest(31L, "PARTIAL", null, 4, "AUTO", null, 0, null))); @@ -325,7 +360,7 @@ class LoadingPickServiceTest { when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 0, "0".repeat(64), List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( new BatchItemRequest(30L, "PICK", 2, null, "MANUAL", List.of(), 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator")) @@ -343,7 +378,7 @@ class LoadingPickServiceTest { LoadingPickTaskEntity task = task(); task.setVersion(3); when(repository.taskForUpdate(20L)).thenReturn(task); - BatchRequest request = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 2, "stale-token", List.of( + BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator")) @@ -373,14 +408,14 @@ class LoadingPickServiceTest { 30L, "PICK", 2, null, "MANUAL", List.of(), 0, null); assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest( - 101L, 10L, 99L, "CSLU1234567", 1, snapshotToken, List.of(line)), 9L, "operator")) + 101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator")) .isInstanceOf(BizException.class) .hasMessageContaining("任务版本已变化"); assertThat(item.getVersion()).isZero(); assertThat(item.getPickedPackageCount()).isZero(); service.batch(20L, "batch-valid", new BatchRequest( - 101L, 10L, 99L, "CSLU1234567", 0, snapshotToken, List.of(line)), 9L, "operator"); + 101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator"); verify(repository, times(1)).pick(30L, 2, "MANUAL", "[]", 0, 101L); verify(repository, times(1)).touchTask(20L, 0); @@ -401,7 +436,7 @@ class LoadingPickServiceTest { service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem)); - BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, "CSLU1234567", 1, + BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1, "stale-token", List.of( new BatchItemRequest(30L, "PICK", 2, null, "AUTO", null, 0, null))); assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index ea790c269..a84dd0918 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -389,7 +389,7 @@ class SmartLoadingServiceTest { assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); assertThat(error.getMessage()).contains("重量").contains("110%"); verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); } @Test @@ -407,7 +407,7 @@ class SmartLoadingServiceTest { assertThat(result.status()).isEqualTo("EDITING"); verify(repository).push(1, "no-container-key", 1, 7, "u"); - verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + verify(loadingPickService).beginIfReady(99L, 7L, "u"); } private void stubAssignment() { diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index 5dacabec2..c2778a083 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPatternParser; import static org.assertj.core.api.Assertions.assertThat; @@ -101,8 +103,11 @@ class GatewayFileRouteConfigurationTest { assertThat(route.getUri().toString()).isEqualTo("lb://audit-service"); assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { assertThat(predicate.getName()).isEqualTo("Path"); - assertThat(String.join(",", predicate.getArgs().values())) - .contains("/api/platform/v1/audit/**"); + String pattern = String.join(",", predicate.getArgs().values()); + assertThat(pattern).contains("/api/platform/v1/audit/**"); + var matcher = PathPatternParser.defaultInstance.parse("/api/platform/v1/audit/**"); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs"))).isTrue(); + assertThat(matcher.matches(PathContainer.parsePath("/api/platform/v1/audit/logs/8"))).isTrue(); }); } } diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index bcc7206ba..c59fc9cd9 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -127,7 +127,7 @@ public class InternalTenantController { @RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) { + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) { verify(token); return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status)); } diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 4140dd770..0cfcfd30b 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -62,7 +62,7 @@ public class PlatformTenantController { @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size, @RequestParam(required = false) String keyword, @RequestParam(required = false) - @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status, + @Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status, @AuthenticationPrincipal AuthenticatedUser user) { TenantGuard.tenant(user, "PLATFORM"); return ApiResponse.success(service.pageMerchants(page, size, keyword, status)); diff --git a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java index 7e33c6fba..300cb1377 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java +++ b/iam/src/main/java/com/baiyue/iam/notification/controller/PlatformNotificationController.java @@ -31,6 +31,8 @@ public class PlatformNotificationController { public ApiResponse status(@PathVariable Long id,@Valid@RequestBody ChangeTemplateStatusRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.changeTemplateStatus(id,request,user.membershipId()));} @GetMapping("/notification-tasks") @PreAuthorize("hasAuthority('platform:notification:task:list')") public ApiResponse> tasks(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String taskNo,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="CREATED|SCHEDULED|DISPATCHING|RETRYING|SENT|DEAD|CANCELLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.tasks(page,size,taskNo,category,title,status));} + @GetMapping("/notification-tasks/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:task:list')") + public ApiResponse taskDetail(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.taskDetail(id));} @PutMapping("/notification-tasks/{id:\\d+}/cancel") @PreAuthorize("hasAuthority('platform:notification:task:cancel')") public ApiResponse cancel(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.cancel(id));} @PutMapping("/notification-tasks/{id:\\d+}/retry") @PreAuthorize("hasAuthority('platform:notification:task:retry')") public ApiResponse retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));} private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");} diff --git a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java index c25ad297c..5e138668e 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/notification/dto/NotificationDtos.java @@ -28,10 +28,10 @@ public final class NotificationDtos { @Size(max = 128) String sourceEventId, @JsonIgnore @Size(max = 128) String sourceEventType) { } - public record NotificationSummary(Long id, String category, String title, String status, + public record NotificationSummary(Long id, String category, String title, String summary, String status, LocalDateTime sentAt, LocalDateTime readAt) { } - public record NotificationDetail(Long id, String category, String title, String body, + public record NotificationDetail(Long id, String category, String title, String summary, String body, String status, String sourceType, String sourceId, LocalDateTime sentAt, LocalDateTime readAt) { } @@ -69,4 +69,12 @@ public final class NotificationDtos { public record TaskListItem(Long id, String taskNo, String category, String title, String status, int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt, boolean duplicated) { } + + public record TaskRecipientItem(Long id, String displayName, String appCode, String status, + String failureReason, LocalDateTime deliveredAt) { } + + public record TaskDetail(Long id, String taskNo, String category, String title, String status, + int recipientCount, int attemptCount, LocalDateTime scheduledAt, + LocalDateTime sentAt, String lastError, + List recipients) { } } diff --git a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java index 0d58a2fef..427b67e18 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java +++ b/iam/src/main/java/com/baiyue/iam/notification/mapper/NotificationTaskMapper.java @@ -6,6 +6,35 @@ import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Param; import java.time.LocalDateTime; @Mapper public interface NotificationTaskMapper extends BaseMapper { + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'SCHEDULED' + AND scheduled_at <= #{now} + AND attempt_count < #{maxAttempts} + """) + int claimScheduled(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now); + + @Update(""" + UPDATE iam_notification_task + SET status = 'DISPATCHING', + attempt_count = attempt_count + 1, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'RETRYING' + AND updated_at <= #{dueBefore} + AND attempt_count < #{maxAttempts} + """) + int claimRetrying(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("dueBefore") LocalDateTime dueBefore, + @Param("now") LocalDateTime now); + @Update(""" UPDATE iam_notification_task task SET task.status = 'DEAD', @@ -27,15 +56,36 @@ import java.time.LocalDateTime; int deadTimedOutDispatchingWithDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, @Param("now") LocalDateTime now); + @Update(""" + UPDATE iam_notification_task task + SET task.status = 'DEAD', + task.last_error = 'DISPATCH_TIMEOUT_ATTEMPT_LIMIT', + task.updated_at = #{now} + WHERE task.status = 'DISPATCHING' + AND task.updated_at <= #{staleBefore} + AND task.sent_at IS NULL + AND task.attempt_count >= #{maxAttempts} + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_recipient recipient + WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' + ) + AND NOT EXISTS ( + SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id + ) + """) + int deadTimedOutDispatchingAtAttemptLimit(@Param("staleBefore") LocalDateTime staleBefore, + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + @Update(""" UPDATE iam_notification_task task SET task.status = 'RETRYING', - task.attempt_count = task.attempt_count + 1, task.last_error = 'DISPATCH_TIMEOUT_RECOVERED', task.updated_at = #{now} WHERE task.status = 'DISPATCHING' AND task.updated_at <= #{staleBefore} AND task.sent_at IS NULL + AND task.attempt_count < #{maxAttempts} AND NOT EXISTS ( SELECT 1 FROM iam_notification_recipient recipient WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED' @@ -45,5 +95,19 @@ import java.time.LocalDateTime; ) """) int retryTimedOutDispatchingWithoutDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore, - @Param("now") LocalDateTime now); + @Param("now") LocalDateTime now, + @Param("maxAttempts") int maxAttempts); + + @Update(""" + UPDATE iam_notification_task + SET status = CASE WHEN attempt_count >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRYING' END, + last_error = #{error}, + updated_at = #{now} + WHERE id = #{taskId} + AND status = 'DISPATCHING' + """) + int failDispatch(@Param("taskId") Long taskId, + @Param("maxAttempts") int maxAttempts, + @Param("error") String error, + @Param("now") LocalDateTime now); } diff --git a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java index 5f5e76809..24431b598 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java +++ b/iam/src/main/java/com/baiyue/iam/notification/repository/NotificationRepository.java @@ -49,21 +49,43 @@ public class NotificationRepository { public void insertRecipient(NotificationRecipient value) { recipients.insert(value); } public List recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper().eq(NotificationRecipient::getTaskId,taskId)); } public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); } - public List dueTasks() { return tasks.selectList(new LambdaQueryWrapper() - .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,java.time.LocalDateTime.now()).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } - public boolean claimScheduled(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper() - .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"SCHEDULED") - .set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; } - public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now) { - int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); - return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now); + public List dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,now).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); } + public boolean claimScheduled(Long taskId,int maxAttempts,java.time.LocalDateTime now) { + return tasks.claimScheduled(taskId,maxAttempts,now)==1; } - public List retryingTasks() { return tasks.selectList(new LambdaQueryWrapper() - .eq(NotificationTask::getStatus,"RETRYING").orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } - public boolean claimRetrying(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper() - .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"RETRYING") - .set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; } + public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now,int maxAttempts) { + int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now); + dead += tasks.deadTimedOutDispatchingAtAttemptLimit(staleBefore,now,maxAttempts); + return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now,maxAttempts); + } + public List retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper() + .eq(NotificationTask::getStatus,"RETRYING").lt(NotificationTask::getAttemptCount,maxAttempts) + .le(NotificationTask::getUpdatedAt,dueBefore).orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); } + public boolean claimRetrying(Long taskId,int maxAttempts,java.time.LocalDateTime dueBefore,java.time.LocalDateTime now) { + return tasks.claimRetrying(taskId,maxAttempts,dueBefore,now)==1; + } + public boolean completeDispatch(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"DISPATCHING") + .set(NotificationTask::getStatus,"SENT").set(NotificationTask::getSentAt,now) + .set(NotificationTask::getLastError,null).set(NotificationTask::getUpdatedAt,now))==1; } + public boolean failDispatch(Long taskId,int maxAttempts,String error,java.time.LocalDateTime now) { + return tasks.failDispatch(taskId,maxAttempts,error,now)==1; + } + public boolean requeueForManualRetry(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper() + .eq(NotificationTask::getId,taskId).in(NotificationTask::getStatus,"RETRYING","DEAD") + .set(NotificationTask::getStatus,"RETRYING").set(NotificationTask::getAttemptCount,0) + .set(NotificationTask::getLastError,null).set(NotificationTask::getSentAt,null) + .set(NotificationTask::getUpdatedAt,now))==1; } public void insertInbox(NotificationInbox value) { inbox.insert(value); } + public boolean markRecipientDelivered(Long recipientId,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getId,recipientId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"DELIVERED").set(NotificationRecipient::getFailureReason,null) + .set(NotificationRecipient::getDeliveredAt,now).set(NotificationRecipient::getUpdatedAt,now))==1; } + public int markUndeliveredRecipientsFailed(Long taskId,String error,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper() + .eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED") + .set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error) + .set(NotificationRecipient::getUpdatedAt,now)); } public PageResult pageInbox(long tenantId,long membershipId,String app,long page,long size,String status,String category) { return PageResult.from(inbox.selectPage(new Page<>(page,size), new LambdaQueryWrapper() .eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId) diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java new file mode 100644 index 000000000..3d8bc076d --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationDispatchTransactionService.java @@ -0,0 +1,74 @@ +package com.baiyue.iam.notification.service; + +import com.baiyue.iam.notification.entity.NotificationInbox; +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +public class NotificationDispatchTransactionService { + static final int MAX_DISPATCH_ATTEMPTS = 3; + static final long RETRY_DELAY_SECONDS = 30; + + @Resource private NotificationRepository repository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimScheduled(Long taskId) { + return repository.claimScheduled(taskId, MAX_DISPATCH_ATTEMPTS, LocalDateTime.now()); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimRetrying(Long taskId) { + LocalDateTime now = LocalDateTime.now(); + return repository.claimRetrying(taskId, MAX_DISPATCH_ATTEMPTS, + now.minusSeconds(RETRY_DELAY_SECONDS), now); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deliverClaimed(Long taskId) { + NotificationTask task = repository.task(taskId); + if (task == null || !"DISPATCHING".equals(task.getStatus())) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + LocalDateTime now = LocalDateTime.now(); + for (NotificationRecipient recipient : repository.recipients(taskId)) { + if ("DELIVERED".equals(recipient.getStatus())) continue; + NotificationInbox item = new NotificationInbox(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setRecipientId(recipient.getId()); + item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId()); + item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle()); + item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId()); + item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now); + repository.insertInbox(item); + if (!repository.markRecipientDelivered(recipient.getId(), now)) { + throw new IllegalStateException("NOTIFICATION_RECIPIENT_DELIVERY_STATE_LOST"); + } + } + if (!repository.completeDispatch(taskId, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordFailure(Long taskId, Throwable failure) { + LocalDateTime now = LocalDateTime.now(); + String error = boundedError(failure); + repository.markUndeliveredRecipientsFailed(taskId, error, now); + if (!repository.failDispatch(taskId, MAX_DISPATCH_ATTEMPTS, error, now)) { + throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST"); + } + } + + private String boundedError(Throwable failure) { + String message = failure.getMessage(); + String error = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + return error.length() <= 1000 ? error : error.substring(0, 1000); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java index 9b1e8bc36..4a7285aac 100644 --- a/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java +++ b/iam/src/main/java/com/baiyue/iam/notification/service/NotificationService.java @@ -25,6 +25,7 @@ import java.util.Set; @Service public class NotificationService { private static final long EVENT_PROCESSING_TIMEOUT_MINUTES = 5; + private static final int INBOX_SUMMARY_MAX_CODE_POINTS = 120; public enum EventClaimStatus { ACQUIRED, PROCESSED, PROCESSING } public record EventClaim(NotificationEventInbox event, EventClaimStatus status) { } @@ -33,6 +34,7 @@ public class NotificationService { @Resource private NotificationTemplateRenderer renderer; @Resource private NotificationRecipientResolver resolver; @Resource private ObjectMapper objectMapper; + @Resource private NotificationDispatchTransactionService dispatchTransactions; @Transactional(readOnly = true) public PageResult inbox(long tenantId, long membershipId, String appCode, @@ -183,35 +185,44 @@ public class NotificationService { public int recoverTimedOutDispatching(long timeoutSeconds) { if (timeoutSeconds < 1) throw new IllegalArgumentException("Dispatch timeout must be positive"); LocalDateTime now = eventNow(); - return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now); + return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now, + NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS); } @Transactional(readOnly = true) public List dueScheduledTaskIds() { - return repository.dueTasks().stream().map(NotificationTask::getId).toList(); + return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList(); } @Transactional(readOnly = true) public List retryingTaskIds() { - return repository.retryingTasks().stream().map(NotificationTask::getId).toList(); + LocalDateTime dueBefore = LocalDateTime.now() + .minusSeconds(NotificationDispatchTransactionService.RETRY_DELAY_SECONDS); + return repository.retryingTasks(dueBefore, NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS) + .stream().map(NotificationTask::getId).toList(); } - @Transactional public boolean dispatchScheduled(Long taskId) { - if (!repository.claimScheduled(taskId)) return false; - NotificationTask task = requiredTask(taskId); - task.setStatus("DISPATCHING"); - deliver(task, LocalDateTime.now()); - return true; + return dispatch(taskId, dispatchTransactions.claimScheduled(taskId)); } - @Transactional public boolean dispatchRetrying(Long taskId) { - if (!repository.claimRetrying(taskId)) return false; - NotificationTask task = requiredTask(taskId); - task.setStatus("DISPATCHING"); - deliver(task, LocalDateTime.now()); - return true; + return dispatch(taskId, dispatchTransactions.claimRetrying(taskId)); + } + + private boolean dispatch(Long taskId, boolean claimed) { + if (!claimed) return false; + try { + dispatchTransactions.deliverClaimed(taskId); + return true; + } catch (RuntimeException failure) { + try { + dispatchTransactions.recordFailure(taskId, failure); + } catch (RuntimeException persistenceFailure) { + failure.addSuppressed(persistenceFailure); + } + throw failure; + } } @Transactional(readOnly = true) @@ -280,6 +291,19 @@ public class NotificationService { source.page(), source.size(), source.total(), source.pages()); } + @Transactional(readOnly = true) + public TaskDetail taskDetail(Long id) { + NotificationTask task = requiredTask(id); + List recipients = repository.recipients(id).stream() + .map(value -> new TaskRecipientItem(value.getId(), value.getDisplayName(), value.getAppCode(), + value.getStatus(), value.getFailureReason(), value.getDeliveredAt())) + .toList(); + return new TaskDetail(task.getId(), task.getTaskNo(), task.getCategory(), task.getTitle(), task.getStatus(), + task.getRecipientCount(), task.getAttemptCount() == null ? 0 : task.getAttemptCount(), + task.getScheduledAt(), task.getSentAt(), + task.getLastError(), recipients); + } + @Transactional @BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK", resourceId = "#id", description = "取消消息任务", captureAfter = false) @@ -298,8 +322,9 @@ public class NotificationService { NotificationTask task = requiredTask(id); if (!Set.of("RETRYING", "DEAD").contains(task.getStatus())) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); - task.setStatus("RETRYING"); task.setAttemptCount(task.getAttemptCount() + 1); repository.saveTask(task); - deliver(task, LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + if (!repository.requeueForManualRetry(id, now)) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE"); + task.setStatus("RETRYING"); task.setAttemptCount(0); task.setLastError(null); task.setUpdatedAt(now); return taskItem(task, false); } @@ -319,12 +344,21 @@ public class NotificationService { return value; } private NotificationSummary summary(NotificationInbox value) { - return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), value.getStatus(), - value.getSentAt(), value.getReadAt()); + return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getStatus(), value.getSentAt(), value.getReadAt()); + } + + private String boundedSummary(String body) { + if (body == null || body.isBlank()) return ""; + String compact = body.strip().replaceAll("\\s+", " "); + int count = compact.codePointCount(0, compact.length()); + if (count <= INBOX_SUMMARY_MAX_CODE_POINTS) return compact; + return compact.substring(0, compact.offsetByCodePoints(0, INBOX_SUMMARY_MAX_CODE_POINTS)); } private NotificationDetail detail(NotificationInbox value) { - return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), value.getBody(), - value.getStatus(), value.getSourceType(), value.getSourceId(), value.getSentAt(), value.getReadAt()); + return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), + boundedSummary(value.getBody()), value.getBody(), value.getStatus(), value.getSourceType(), + value.getSourceId(), value.getSentAt(), value.getReadAt()); } private TemplateResponse template(NotificationTemplate value) { return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(), diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java index dce0133f6..403039863 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerListExportProviderService.java @@ -31,7 +31,7 @@ public class CustomerListExportProviderService { private static final int MAX_BATCH_SIZE = 500; private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512; private static final Set STATUSES = Set.of( - "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED"); + "PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED"); private static final Set PARAMETER_KEYS = Set.of("keyword", "status"); private static final List COLUMNS = List.of( column("id", "客户ID", ExportColumnType.STRING, 20), @@ -205,6 +205,7 @@ public class CustomerListExportProviderService { case "ACTIVE" -> "启用"; case "REJECTED" -> "已驳回"; case "DISABLED" -> "已停用"; + case "ARCHIVED" -> "已归档"; default -> status; }; } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index aa3bce84a..0a10d16e3 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -93,7 +93,7 @@ public class TenantService { captureBefore = false, captureAfter = false) public TenantResponse archiveMerchant(long tenantId) { IamTenant tenant = requireMerchantForUpdate(tenantId); - if ("DISABLED".equals(tenant.getStatus())) return response(tenant); + if ("ARCHIVED".equals(tenant.getStatus())) return response(tenant); if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) { throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档"); } @@ -106,7 +106,7 @@ public class TenantService { } catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); } - tenant.setStatus("DISABLED"); + tenant.setStatus("ARCHIVED"); repository.saveTenant(tenant); return response(tenant); } @@ -350,6 +350,7 @@ public class TenantService { description = "平台编辑企业客户档案") public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) { IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); String contactPhone = normalizeContactPhone(request.contactPhone()); String existingContactPhone = normalizeContactPhone(tenant.getContactPhone()); if (!contactPhone.equals(existingContactPhone)) { @@ -429,6 +430,7 @@ public class TenantService { resourceId = "#tenantId", description = "编辑企业资料") public TenantResponse update(long tenantId, UpdateProfileRequest request) { IamTenant tenant = requireMerchant(tenantId); + ensureNotArchived(tenant); tenant.setTenantName(request.tenantName()); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); @@ -443,6 +445,12 @@ public class TenantService { return tenant.getId() != null && tenant.getId().longValue() == id; } + private void ensureNotArchived(IamTenant tenant) { + if ("ARCHIVED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Archived customer is read-only / 已归档客户仅可查看"); + } + } + private String normalizeContactPhone(String contactPhone) { return contactPhone == null ? "" : contactPhone.trim(); } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 2f0d3b823..65b0d2654 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', - status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用', + status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 00fccfde0..0ae6774de 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -45,7 +45,7 @@ paths: - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} - {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索} - - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}} + - {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}} responses: '200': description: 企业客户分页查询成功 @@ -985,7 +985,7 @@ components: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} - status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]} + status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index ebb2711c3..99f5f992f 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -50,7 +50,7 @@ paths: tags: [Platform notification administration] summary: Create an IN_APP notification task requestBody: {$ref: '#/components/requestBodies/CreateNotification'} - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} /platform/v1/notification-templates: get: tags: [Platform notification administration] @@ -95,18 +95,26 @@ paths: - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} + /platform/v1/notification-tasks/{id}: + get: + tags: [Platform notification administration] + summary: Get notification task delivery details + description: Uses the task list permission and exposes bounded operational evidence without message body content. + parameters: [{$ref: '#/components/parameters/IdPath'}] + responses: {'200': {$ref: '#/components/responses/TaskDetail'}} /platform/v1/notification-tasks/{id}/cancel: put: tags: [Platform notification administration] summary: Cancel a CREATED or SCHEDULED task parameters: [{$ref: '#/components/parameters/IdPath'}] - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} /platform/v1/notification-tasks/{id}/retry: put: tags: [Platform notification administration] - summary: Retry a RETRYING or DEAD task + summary: Requeue a RETRYING or DEAD task + description: Resets the current delivery-attempt counter and returns the task in RETRYING; scheduler delivery starts after the retry delay. parameters: [{$ref: '#/components/parameters/IdPath'}] - responses: {'200': {$ref: '#/components/responses/TaskDetail'}} + responses: {'200': {$ref: '#/components/responses/TaskItem'}} components: parameters: AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}} @@ -126,6 +134,7 @@ components: TemplatePage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}} TemplateDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateDetailResponse'}}}} TaskPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}} + TaskItem: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskItemResponse'}}}} TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}} schemas: CreateNotificationRequest: @@ -159,10 +168,12 @@ components: type: object required: [targetStatus, version] properties: {targetStatus: {type: string, enum: [ACTIVE, DISABLED], description: ACTIVE is accepted only from DRAFT; DISABLED only from ACTIVE.}, version: {type: integer, minimum: 1}} - InboxItem: {type: object, required: [id, category, title, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} + InboxItem: {type: object, required: [id, category, title, summary, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, summary: {type: string, maxLength: 120, description: Whitespace-compacted body preview limited to 120 Unicode code points; never contains the full body unless the body itself fits.}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}} InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, required: [body], properties: {body: {type: string}, sourceType: {type: string, nullable: true}, sourceId: {type: string, nullable: true}}}]} TemplateItem: {type: object, required: [id, templateCode, templateName, category, channel, titleTemplate, bodyTemplate, variableWhitelist, status, version], properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}} TaskItem: {type: object, required: [id, taskNo, category, title, status, recipientCount, duplicated], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}} + TaskRecipientItem: {type: object, required: [id, displayName, appCode, status], properties: {id: {type: integer, format: int64}, displayName: {type: string}, appCode: {type: string}, status: {type: string}, failureReason: {type: string, nullable: true}, deliveredAt: {type: string, format: date-time, nullable: true}}} + TaskDetail: {type: object, required: [id, taskNo, category, title, status, recipientCount, attemptCount, recipients], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, attemptCount: {type: integer, minimum: 0, maximum: 3, description: Number of started delivery attempts in the current automatic retry cycle.}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, lastError: {type: string, nullable: true}, recipients: {type: array, items: {$ref: '#/components/schemas/TaskRecipientItem'}}}} InboxPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} TemplatePageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} TaskPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}} @@ -173,4 +184,5 @@ components: TemplatePageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplatePageData'}}} TemplateDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplateItem'}}} TaskPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskPageData'}}} - TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskItemResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}} + TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskDetail'}}} diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java index 461ddbbc1..9066fd423 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java @@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest { assertNotNull(annotation(method, 0, Min.class)); assertNotNull(annotation(method, 1, Min.class)); assertEquals(200, annotation(method, 1, Max.class).value()); - assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED", + assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED", annotation(method, 3, Pattern.class).regexp()); Set statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value()) diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java new file mode 100644 index 000000000..788fa540b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationDispatchTransactionServiceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.iam.notification; + +import com.baiyue.iam.notification.entity.NotificationRecipient; +import com.baiyue.iam.notification.entity.NotificationTask; +import com.baiyue.iam.notification.repository.NotificationRepository; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class NotificationDispatchTransactionServiceTest { + private final NotificationRepository repository = mock(NotificationRepository.class); + private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception { + for (var method : List.of( + NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class), + NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class), + NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class), + NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) { + assertThat(method.getAnnotation(Transactional.class)).isNotNull() + .extracting(Transactional::propagation) + .isEqualTo(Propagation.REQUIRES_NEW); + } + } + + @Test void claimStartsOneOfThreeAllowedAttempts() { + when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true); + + assertThat(service.claimScheduled(41L)).isTrue(); + + verify(repository).claimScheduled(eq(41L), eq(3), any()); + } + + @Test void retryClaimEnforcesDelayAndSameAttemptLimit() { + when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true); + + assertThat(service.claimRetrying(41L)).isTrue(); + + ArgumentCaptor dueBefore = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture()); + assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30)); + } + + @Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("FAILED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L) + && item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus()))); + var ordered = inOrder(repository); + ordered.verify(repository).markRecipientDelivered(eq(51L), any()); + ordered.verify(repository).completeDispatch(eq(41L), any()); + } + + @Test void alreadyDeliveredRecipientIsNotInsertedAgain() { + NotificationTask task = task("DISPATCHING"); + NotificationRecipient recipient = recipient("DELIVERED"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + when(repository.completeDispatch(eq(41L), any())).thenReturn(true); + + service.deliverClaimed(41L); + + verify(repository, never()).insertInbox(any()); + verify(repository, never()).markRecipientDelivered(anyLong(), any()); + } + + @Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() { + when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true); + + service.recordFailure(41L, new IllegalStateException("x".repeat(1200))); + + ArgumentCaptor error = ArgumentCaptor.forClass(String.class); + verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any()); + assertThat(error.getValue()).hasSize(1000); + verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any()); + } + + @Test void rejectsDeliveryWithoutCurrentClaim() { + when(repository.task(41L)).thenReturn(task("RETRYING")); + + assertThatThrownBy(() -> service.deliverClaimed(41L)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST"); + + verify(repository, never()).insertInbox(any()); + } + + private NotificationTask task(String status) { + NotificationTask task = new NotificationTask(); + task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化"); + task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed"); + return task; + } + + private NotificationRecipient recipient(String status) { + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L); + recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status); + recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now()); + return recipient; + } +} diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java index 776d2bdcc..ea97693f2 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationInboxPermissionContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.iam.notification; import com.baiyue.iam.notification.controller.NotificationInboxController; +import com.baiyue.iam.notification.controller.PlatformNotificationController; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest { assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')"); } + @Test + void taskDetailUsesTheTaskListPermission() { + var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("taskDetail")) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:notification:task:list')"); + } + private static void assertPermission(String methodName, String expected) { var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index afab24b08..af6a9a172 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -28,6 +28,7 @@ class NotificationOpenApiContractTest { "/{app}/v1/notifications/read-all", "/platform/v1/notifications", "/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}", "/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks", + "/platform/v1/notification-tasks/{id}", "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry"); assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create"); @@ -37,6 +38,7 @@ class NotificationOpenApiContractTest { assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put"); for (String path : List.of("/platform/v1/notification-templates/{id}", "/platform/v1/notification-templates/{id}/status", + "/platform/v1/notification-tasks/{id}", "/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry")) { Map operation = map(map(paths.get(path)).values().iterator().next()); @@ -50,6 +52,7 @@ class NotificationOpenApiContractTest { "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse", "InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse", "TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse"); + assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail"); for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest", "UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem", "TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) { @@ -59,6 +62,13 @@ class NotificationOpenApiContractTest { .contains("DISABLED cannot return to ACTIVE") .contains("does not support deleting or clearing read messages") .contains("name: taskNo").contains("name: templateCode").contains("name: title"); + Map inboxItem = map(schemas.get("InboxItem")); + assertThat(list(inboxItem.get("required"))).contains("summary"); + assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120); + Map taskDetail = map(schemas.get("TaskDetail")); + assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) + .containsEntry("minimum", 0).containsEntry("maximum", 3); + assertThat(document).contains("scheduler delivery starts after the retry delay"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java index d199ba723..c629d44be 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java @@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class NotificationSchedulerRecoveryTest { + @Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception { + Update scheduled = NotificationTaskMapper.class + .getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + + Update retrying = NotificationTaskMapper.class + .getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class, + java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1", + "STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}"); + } + @Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception { Update retry = NotificationTaskMapper.class - .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) + .getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) .getAnnotation(Update.class); String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase(); assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", "TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX") - .contains("SET TASK.STATUS = 'RETRYING'"); + .contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}") + .doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1"); Update dead = NotificationTaskMapper.class .getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class) @@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest { assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX", "DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE"); + + Update attemptLimit = NotificationTaskMapper.class + .getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class, + java.time.LocalDateTime.class, int.class) + .getAnnotation(Update.class); + String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'", + "TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT"); + + Update failed = NotificationTaskMapper.class + .getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class); + String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase(); + assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END", + "LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'"); } @Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() { diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java index ac6b51b98..b000b0ae0 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java @@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.notification.dto.NotificationDtos.*; import com.baiyue.iam.notification.entity.NotificationRecipient; import com.baiyue.iam.notification.entity.NotificationEventInbox; +import com.baiyue.iam.notification.entity.NotificationInbox; import com.baiyue.iam.notification.entity.NotificationTask; import com.baiyue.iam.notification.entity.NotificationTemplate; import com.baiyue.iam.notification.repository.NotificationRepository; import com.baiyue.iam.notification.service.NotificationRecipientResolver; +import com.baiyue.iam.notification.service.NotificationDispatchTransactionService; import com.baiyue.iam.notification.service.NotificationService; import com.baiyue.iam.notification.service.NotificationTemplateRenderer; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,6 +29,8 @@ import static org.mockito.Mockito.*; class NotificationServiceTest { private final NotificationRepository repository = mock(NotificationRepository.class); private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class); + private final NotificationDispatchTransactionService dispatchTransactions = + mock(NotificationDispatchTransactionService.class); private final NotificationService service = new NotificationService(); @BeforeEach void setup() { @@ -34,6 +38,7 @@ class NotificationServiceTest { ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer()); ReflectionTestUtils.setField(service, "resolver", resolver); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions); } @Test void createsTaskAndImmutableRecipientSnapshot() { @@ -63,6 +68,39 @@ class NotificationServiceTest { assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED"); } + @Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() { + NotificationInbox item = new NotificationInbox(); + item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD"); + item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " "); + when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null)) + .thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1)); + + NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0); + + assertThat(summary.summary()).startsWith("第一行 "); + assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120); + assertThat(summary.summary()).isNotEqualTo(item.getBody()); + } + + @Test void returnsTaskAndRecipientFailureEvidence() { + NotificationTask task = task(41L, "DEAD"); + task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3); + NotificationRecipient recipient = new NotificationRecipient(); + recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT"); + recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout"); + when(repository.task(41L)).thenReturn(task); + when(repository.recipients(41L)).thenReturn(List.of(recipient)); + + TaskDetail detail = service.taskDetail(41L); + + assertThat(detail.attemptCount()).isEqualTo(3); + assertThat(detail.lastError()).isEqualTo("gateway timeout"); + assertThat(detail.recipients()).singleElement().satisfies(value -> { + assertThat(value.status()).isEqualTo("FAILED"); + assertThat(value.failureReason()).isEqualTo("gateway timeout"); + }); + } + @Test void returnsOriginalTaskForRepeatedIdempotencyKey() { NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5"); existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1); @@ -151,31 +189,28 @@ class NotificationServiceTest { } @Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() { - when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2); + when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2); assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2); ArgumentCaptor staleBefore = ArgumentCaptor.forClass(LocalDateTime.class); ArgumentCaptor now = ArgumentCaptor.forClass(LocalDateTime.class); - verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture()); + verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3)); assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300)); } @Test void onlyOneInstanceCanClaimRecoveredTask() { - when(repository.claimRetrying(41L)).thenReturn(true, false); - NotificationTask task = task(41L, "RETRYING"); - when(repository.task(41L)).thenReturn(task); - when(repository.recipients(41L)).thenReturn(List.of()); + when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false); assertThat(service.dispatchRetrying(41L)).isTrue(); assertThat(service.dispatchRetrying(41L)).isFalse(); - verify(repository, times(2)).claimRetrying(41L); - verify(repository, times(1)).saveTask(task); + verify(dispatchTransactions, times(2)).claimRetrying(41L); + verify(dispatchTransactions, times(1)).deliverClaimed(41L); } @Test void sentTaskCannotBeClaimedForRecoveryDelivery() { - when(repository.claimRetrying(42L)).thenReturn(false); + when(dispatchTransactions.claimRetrying(42L)).thenReturn(false); assertThat(service.dispatchRetrying(42L)).isFalse(); @@ -183,6 +218,30 @@ class NotificationServiceTest { verify(repository, never()).recipients(42L); } + @Test void persistsScheduledDeliveryFailureAfterCommittedClaim() { + IllegalStateException failure = new IllegalStateException("inbox unavailable"); + when(dispatchTransactions.claimScheduled(43L)).thenReturn(true); + doThrow(failure).when(dispatchTransactions).deliverClaimed(43L); + + assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure); + + verify(dispatchTransactions).recordFailure(43L, failure); + } + + @Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() { + NotificationTask task = task(44L, "DEAD"); + task.setAttemptCount(3); task.setLastError("timeout"); + when(repository.task(44L)).thenReturn(task); + when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true); + + TaskListItem result = service.retry(44L); + + assertThat(result.status()).isEqualTo("RETRYING"); + assertThat(task.getAttemptCount()).isZero(); + assertThat(task.getLastError()).isNull(); + verify(dispatchTransactions, never()).deliverClaimed(anyLong()); + } + private NotificationTask task(long id, String status) { NotificationTask value = new NotificationTask(); value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 37eacb10d..18d4a36c0 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -179,11 +179,11 @@ class TenantServiceTest { } @Test - void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() { - IamTenant tenant = merchant(10001L, "DISABLED"); + void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() { + IamTenant tenant = merchant(10001L, "ARCHIVED"); when(repository.findTenantForUpdate(10001L)).thenReturn(tenant); - assertEquals("DISABLED", service.archiveMerchant(10001L).status()); + assertEquals("ARCHIVED", service.archiveMerchant(10001L).status()); verify(authClient, never()).archiveTenant(any(), any()); verify(repository, never()).saveTenant(any()); } @@ -222,6 +222,34 @@ class TenantServiceTest { assertEquals(ResultCode.CONFLICT, exception.getResultCode()); } + @Test + void archivedCustomerCannotBeReactivated() { + when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED")); + + BizException exception = assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE"))); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + + @Test + void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() { + IamTenant archived = merchant(10001L, "ARCHIVED"); + when(repository.findTenant(10001L)).thenReturn(archived); + + BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, + new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, + "基础版", null, null, 30, null, null, null, "CNY", null, null))); + BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, + new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", + "13800138000", "alice@example.com"))); + + assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode()); + assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode()); + verify(repository, never()).saveTenant(any()); + } + @Test void merchantProfileCannotReadOrUpdatePlatformTenant() { IamTenant platform = new IamTenant(); @@ -300,7 +328,7 @@ class TenantServiceTest { var archived = service.archiveMerchant(10001L); - assertEquals("DISABLED", archived.status()); + assertEquals("ARCHIVED", archived.status()); verify(authClient).archiveTenant("internal-token", 10001L); verify(repository).saveTenant(tenant); } diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index f0649f719..ae601f063 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -201,7 +201,7 @@ export async function run(context) { const pushKey = `${context.marker}-container-push`.slice(0, 128) const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) - const pushed = context.apiSuccess(MODULE, '推送预装并创建 PDA 任务', pushResponse, '', OPTIONS) + const pushed = context.apiSuccess(MODULE, '推送预装计划', pushResponse, '', OPTIONS) context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse, pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1, `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) @@ -232,11 +232,34 @@ export async function run(context) { containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64), version: Number(containerDetail?.version ?? 0), reason: context.marker, }) - const transported = context.apiSuccess(MODULE, '绑定本轮真实箱号并补建 PDA 任务', transportResponse, '', OPTIONS) + const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS) context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse, transported?.containerNo === `TST-${suffix}`.slice(0, 64), `containerNo=${transported?.containerNo || 'missing'}`, OPTIONS) + const confirmedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', confirmedTaskResponse, + failure(confirmedTaskResponse) || !id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id), + `HTTP=${confirmedTaskResponse.http}, code=${codeOf(confirmedTaskResponse)}, task=${id(confirmedTaskResponse?.body?.id || confirmedTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) + + const sailingAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + const arrivalAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString() + const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/release`, { + expectedVersion: Number(confirmedBooking?.oceanBooking?.version), + billOfLadingNo: `BL-${suffix}`.slice(0, 128), vesselName: `VESSEL-${suffix}`.slice(0, 128), + voyageNo: `V-${suffix}`.slice(0, 64), estimatedSailingAt: sailingAt, + estimatedArrivalAt: arrivalAt, reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-loading-booking-release`.slice(0, 128) }) + const releasedBooking = context.apiSuccess(MODULE, '订舱放舱并开放现场 PDA 任务', releaseResponse, '', OPTIONS) + context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse, + releasedBooking?.oceanBooking?.status === 'RELEASED' + && releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId), + `status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS) + + const releasedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) + const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS) + const taskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS) @@ -263,7 +286,7 @@ export async function run(context) { const replacementContainerNo = `TST-R-${suffix}`.slice(0, 64) const replacementResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo: replacementContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), - version: Number(transported?.version), reason: `${context.marker}-replace-container-no`, + version: Number(releasedContainer?.version), reason: `${context.marker}-replace-container-no`, }) const replaced = context.apiSuccess(MODULE, 'PDA 任务创建后替换真实柜号', replacementResponse, '', OPTIONS) context.assert(MODULE, '任务创建后真实柜号替换成功', replacementResponse, diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 802b1fec9..096eb9640 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -73,6 +73,14 @@ test('existing container number replacement keeps container plan and PDA identit assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/) }) +test('PDA task opens only after preload push, real container number and ocean release converge', () => { + assert.match(containerLoadingAcceptance, /无真实柜号时计划可排柜但 PDA 任务不开放/) + assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/) + assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/) + assert.match(containerLoadingAcceptance, /订舱放舱并开放现场 PDA 任务/) + assert.match(containerLoadingAcceptance, /releasedBooking\?\.oceanBooking\?\.status === 'RELEASED'/) +}) + test('customer public display uses tenantName without a legalName fallback', () => { for (const source of [truckList, containerList]) { assert.match(source, /label: customer\.tenantName/) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 89870ba7d..356cd1c9b 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -150,7 +150,8 @@ test('container loading creates and confirms booking before scoped candidates', assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport")) - assert.match(value, /绑定本轮真实箱号并补建 PDA 任务/) + assert.match(value, /在放舱前绑定本轮真实箱号/) + assert.match(value, /订舱放舱并开放现场 PDA 任务/) assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/) }) @@ -188,7 +189,9 @@ test('container loading enforces channel isolation and stable PDA identity acros '创建异渠道隔离订舱', '异渠道入库单拒绝装入当前柜次', '无真实柜号时计划可排柜但 PDA 任务不开放', - '绑定本轮真实箱号并补建 PDA 任务', + '在放舱前绑定本轮真实箱号', + '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', + '订舱放舱并开放现场 PDA 任务', '换柜号前读取 PDA 稳定库存快照', 'PDA 任务创建后替换真实柜号', 'PDA 任务创建后清空真实柜号失败', diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index 0368cd4b7..cc496ce32 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -64,7 +64,7 @@ export async function run(context) { 'all warehouse role permissions must use platform scope', OPTIONS) const unauthorized = await context.request('GET', - '/api/platform/v1/first-leg/pda-loading/tasks?containerNo=UNAUTHORIZED&warehouseId=1', undefined, '') + '/api/platform/v1/first-leg/pda-loading/tasks?cabinetNo=UNAUTHORIZED&warehouseId=1', undefined, '') context.addStep(MODULE, '未登录访问仓库PDA任务被拒绝', unauthorized, [401, 403].includes(unauthorized.http) || (unauthorized.http === 200 && ['A0401', 'A0403', '401', '403'].includes(String(unauthorized.body?.code))), From feced8eb926a2e3d9353bd761f6603ebf8bc8b49 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 17:37:59 +0800 Subject: [PATCH 010/112] =?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 | 211 +++++++++++++----- .../client/WarehousePalletLocationClient.java | 29 ++- .../PlatformInboundMeasurementController.java | 21 +- .../dto/InboundMeasurementRequests.java | 1 + .../dto/InboundMeasurementResponses.java | 17 +- .../dto/MerchantInboundBillingResponse.java | 8 +- .../entity/InboundWeightResultEntity.java | 10 +- .../entity/InboundWeightRevisionEntity.java | 3 +- .../service/CartonMeasurementService.java | 28 ++- .../service/InboundMeasurementService.java | 89 +++++++- .../MerchantInboundBillingService.java | 8 +- ...phase2-inbound-measurement-maintenance.sql | 3 +- .../resources/db/phase2-inbound-schema.sql | 17 +- .../main/resources/openapi/firstleg-v1.yaml | 146 +++++++++++- ...housePalletLocationClientContractTest.java | 37 +++ ...oundMeasurementControllerContractTest.java | 4 + ...eateWeightResultRequestValidationTest.java | 34 +++ .../InboundWorkflowSqlContractTest.java | 22 ++ .../service/CartonMeasurementServiceTest.java | 39 +++- .../InboundMeasurementServiceTest.java | 134 +++++++++-- warehouse/AGENTS.md | 7 +- .../controller/InternalPalletController.java | 67 ++++++ .../InternalPalletLocationController.java | 6 +- .../controller/PlatformPalletController.java | 2 +- .../baiyue/warehouse/dto/PalletRequests.java | 10 + .../baiyue/warehouse/entity/PalletEntity.java | 9 +- .../baiyue/warehouse/mapper/PalletMapper.java | 9 + .../repository/PalletRepository.java | 35 ++- .../service/PalletApplicationService.java | 19 +- .../PalletLocationAllocationService.java | 55 ++--- .../WarehouseSchemaReadinessService.java | 3 +- .../db/phase2-pallet-location-schema.sql | 17 +- .../main/resources/db/phase2-pallet-seed.sql | 8 +- .../src/main/resources/openapi/pallet-v1.yaml | 72 ++++++ .../InternalPalletControllerTest.java | 59 +++++ .../controller/PalletOpenApiContractTest.java | 25 +++ ...lWarehouseResourceSeedSqlContractTest.java | 3 +- .../WarehouseSchemaContractTest.java | 44 +++- .../service/PalletApplicationServiceTest.java | 25 ++- .../PalletLocationAllocationServiceTest.java | 50 +++-- 40 files changed, 1142 insertions(+), 244 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java create mode 100644 warehouse/src/main/resources/openapi/pallet-v1.yaml create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 24dbaba03..4cd343579 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2000,9 +2000,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘皮重kg', color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -2012,11 +2011,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), + KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code), + KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; +) ENGINE=InnoDB COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态'; -- 存量库补齐托盘重量、颜色字段(可重复执行)。 SET @pallet_weight_sql = IF((SELECT COUNT(*) FROM information_schema.columns @@ -2028,6 +2027,28 @@ SET @pallet_color_sql = IF((SELECT COUNT(*) FROM information_schema.columns 'ALTER TABLE wh_pallet ADD COLUMN color VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘颜色'' AFTER weight_kg', 'SELECT 1'); PREPARE pallet_color_stmt FROM @pallet_color_sql; EXECUTE pallet_color_stmt; DEALLOCATE PREPARE pallet_color_stmt; +-- 托盘是可复用主数据,不再用状态或活动唯一约束模拟库存资源。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_available') > 0, + 'ALTER TABLE wh_pallet DROP INDEX idx_pallet_available', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_type') > 0, + 'ALTER TABLE wh_pallet DROP INDEX idx_pallet_type', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND column_name = 'status') > 0, + 'ALTER TABLE wh_pallet DROP COLUMN status', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_warehouse') = 0, + 'ALTER TABLE wh_pallet ADD KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'wh_pallet' AND index_name = 'idx_pallet_type') = 0, + 'ALTER TABLE wh_pallet ADD KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共库位固定使用 tenant_id=0', @@ -2079,19 +2100,17 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), KEY idx_location_reservation_expire (reservation_status, expires_at), KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; +) ENGINE=InnoDB COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'; CREATE TABLE IF NOT EXISTS wh_location_occupancy ( id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', @@ -2118,12 +2137,10 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), @@ -2131,7 +2148,7 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; +) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; -- 头程入库单属于其他数据库,不建立跨库外键。 @@ -2211,10 +2228,6 @@ SET @reservation_needs_upgrade = IFNULL(( SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') > 0, 'ALTER TABLE wh_location_reservation DROP INDEX uk_location_active_reservation', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF(@reservation_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, - 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - SET @sql = IF(@reservation_needs_upgrade = 1, 'ALTER TABLE wh_location_reservation MODIFY COLUMN active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动预占唯一标记,A0 返回 NULL''', 'SELECT 1'); @@ -2228,18 +2241,11 @@ SET @occupancy_needs_upgrade = IF( EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND column_name = 'active_location_occupancy' - AND LOCATE('shared_overflow', generation_expression) > 0) - AND EXISTS(SELECT 1 FROM information_schema.columns - WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' - AND column_name = 'active_pallet_occupancy'), + AND LOCATE('shared_overflow', generation_expression) > 0), 0, 1); SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') > 0, 'ALTER TABLE wh_location_occupancy DROP INDEX uk_location_active_occupancy', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF(@occupancy_needs_upgrade = 1 AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, - 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; DELIMITER $$ CREATE PROCEDURE add_location_overflow_column_if_missing( @@ -2265,29 +2271,28 @@ BEGIN END$$ DELIMITER ; -CALL add_location_overflow_column_if_missing( - 'wh_location_reservation', 'active_pallet_reservation', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = ''RESERVED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动预占唯一标记(包含 A0)''' -); CALL add_location_overflow_column_if_missing( 'wh_location_occupancy', 'active_location_occupancy', 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT ''普通库位活动占用唯一标记''' ); -CALL add_location_overflow_column_if_missing( - 'wh_location_occupancy', 'active_pallet_occupancy', - 'TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = ''OCCUPIED'' THEN 1 ELSE NULL END) STORED COMMENT ''托盘活动占用唯一标记(包含 A0)''' -); DROP PROCEDURE IF EXISTS add_location_overflow_column_if_missing; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') = 0, - 'ALTER TABLE wh_location_reservation ADD UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)', 'SELECT 1'); -PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; - SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy') = 0, 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy)', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') = 0, - 'ALTER TABLE wh_location_occupancy ADD UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy)', 'SELECT 1'); + +-- 清理旧环境中的托盘活动资源约束;库位活动约束继续保留。 +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND index_name = 'uk_pallet_active_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP INDEX uk_pallet_active_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND index_name = 'uk_pallet_active_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP INDEX uk_pallet_active_occupancy', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_reservation' AND column_name = 'active_pallet_reservation') > 0, + 'ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'wh_location_occupancy' AND column_name = 'active_pallet_occupancy') > 0, + 'ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy', 'SELECT 1'); PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; -- 托盘、库位、预占和占用均为平台公共仓储资源,存量测试数据统一迁移到平台作用域 0。 @@ -2758,6 +2763,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照', ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', @@ -2765,7 +2775,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( confidence DECIMAL(6,5) NULL COMMENT '识别置信度', adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', @@ -2781,8 +2792,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( PRIMARY KEY (id), UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id), KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id), CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; +) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', @@ -2795,7 +2807,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -3014,11 +3027,16 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用'; +SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_pallet_allocation' + AND index_name = 'uk_pallet_allocation_pallet') > 0, + 'ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet', 'SELECT 1'); +PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement; + CREATE TABLE IF NOT EXISTS fl_inbound_file ( id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3197,6 +3215,25 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', CALL phase2_measure_add_column('fl_inbound_measurement_item', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id', + 'BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Warehouse托盘ID'' AFTER file_id'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_code', + 'VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''托盘编码快照'' AFTER pallet_id'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_type', + 'VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''托盘类型快照'' AFTER pallet_code'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_capacity_cbm', + 'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''托盘容量CBM快照'' AFTER pallet_type'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_tare_weight_kg', + 'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''托盘皮重KG快照'' AFTER pallet_capacity_cbm'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认时统一换算后的含托盘毛重kg'' AFTER adjusted_weight_unit'); +CALL phase2_measure_add_column('fl_inbound_weight_result', 'net_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认毛重扣除托盘皮重后的货物净重kg'' AFTER gross_weight_kg'); +CALL phase2_measure_add_column('fl_inbound_weight_revision', 'gross_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认时统一换算后的含托盘毛重kg'' AFTER after_weight_unit'); +CALL phase2_measure_add_column('fl_inbound_weight_revision', 'net_weight_kg', + 'DECIMAL(18,3) NULL COMMENT ''确认毛重扣除托盘皮重后的货物净重kg'' AFTER gross_weight_kg'); + SET @has_measure_box_code_index = ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() @@ -3217,6 +3254,30 @@ ALTER TABLE fl_inbound_measurement_item DROP PROCEDURE IF EXISTS phase2_measure_add_column; +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' + AND index_name = 'idx_weight_inbound_pallet') = 0, + 'ALTER TABLE fl_inbound_weight_result ADD KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id)', + 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; + +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_result' + AND column_name = 'final_weight_kg') > 0, + 'ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg', 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; +SET @phase2_measure_sql = IF((SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_weight_revision' + AND column_name = 'final_weight_kg') > 0, + 'ALTER TABLE fl_inbound_weight_revision DROP COLUMN final_weight_kg', 'SELECT 1'); +PREPARE phase2_measure_statement FROM @phase2_measure_sql; +EXECUTE phase2_measure_statement; +DEALLOCATE PREPARE phase2_measure_statement; + CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -3228,7 +3289,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -11190,7 +11252,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_split_history', '不可 CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_split_item', '主子入仓单拆分数量台账'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_status_history', '入库单流程状态不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_transport_snapshot', '入库单关联运输信息只读快照'); -CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_result', '称重图片OCR原值、人工修正值和确认重量'); +CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_result', '称重图片、托盘皮重快照、OCR毛重和确认净重'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'OCR重量人工修正和确认的不可变版本历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_loading_evidence', '订单装柜空仓/半仓/满仓/锁仓/带锁码照证据'); CALL queue_table_comment('baiyue_firstleg', 'fl_loading_operation', '装柜与派送写操作幂等记录'); @@ -11256,11 +11318,11 @@ CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件 CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱'); CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0'); CALL queue_table_comment('baiyue_warehouse', 'wh_location', '二期库位基础资料,默认一托盘对应一个库位且不作容量校验'); -CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的一托一库位占用事实记录'); -CALL queue_table_comment('baiyue_warehouse', 'wh_location_reservation', '跨服务入库Saga的托盘和库位临时预占'); +CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的库位占用事实记录,托盘字段仅为主数据快照'); +CALL queue_table_comment('baiyue_warehouse', 'wh_location_reservation', '跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'); CALL queue_table_comment('baiyue_warehouse', 'wh_overseas_storage_fee_rule', '客户海外仓费用规则'); CALL queue_table_comment('baiyue_warehouse', 'wh_overseas_storage_fee_tier', '客户海外仓费用阶梯'); -CALL queue_table_comment('baiyue_warehouse', 'wh_pallet', '二期托盘基础资料和物理托盘实例'); +CALL queue_table_comment('baiyue_warehouse', 'wh_pallet', '可复用托盘标识和称重主数据,不承载动态资源状态'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'PDA已拣满托盘库位精确释放的持久化幂等操作记录'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_settlement_operation', 'PDA拣货资源原子结算的持久化幂等操作记录'); CALL queue_table_comment('baiyue_warehouse', 'wh_pallet_location_transfer_operation', '托盘库位归属转移的持久化幂等操作记录'); @@ -12536,13 +12598,19 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'create CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'deleted', '逻辑删除标记'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'file_id', '称重图片文件ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'final_weight_kg', '确认并统一换算后的重量kg'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'gross_weight_kg', '确认时统一换算后的含托盘毛重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'id', 'OCR重量结果ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'inbound_id', '入库单ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'net_weight_kg', '确认毛重扣除托盘皮重后的货物净重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_provider', 'OCR服务商编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_raw_text', 'OCR原始文本'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_weight_unit', 'OCR识别原始单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'ocr_weight_value', 'OCR识别原始重量值'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_capacity_cbm', '托盘容量CBM快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_code', '托盘编码快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_id', 'Warehouse托盘ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_tare_weight_kg', '托盘皮重KG快照'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'pallet_type', '托盘类型快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'recognition_status', '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'tenant_id', '客户ID和数据隔离键'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_result', 'updated_at', '更新时间'); @@ -12553,9 +12621,10 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'afte CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'after_weight_value', '变更后原单位重量'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'before_weight_unit', '变更前单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'before_weight_value', '变更前原单位重量'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'final_weight_kg', '确认时统一换算后的重量kg'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'gross_weight_kg', '确认时统一换算后的含托盘毛重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'id', 'OCR重量修订记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'inbound_id', '入库单ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'net_weight_kg', '确认毛重扣除托盘皮重后的货物净重kg'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operated_at', '操作时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operator_id', '操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_weight_revision', 'operator_name', '操作人名称快照'); @@ -13527,7 +13596,6 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'warehouse_id', ' CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'zone_code', '库区编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_location', 'zone_name', '库区名称'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'active_location_occupancy', '普通库位活动占用唯一标记'); -CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'active_pallet_occupancy', '托盘活动占用唯一标记(包含A0)'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'allocated_package_count', '本托盘分配箱数'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'allocated_volume_cbm', '本托盘分配体积CBM'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'business_id', '来源服务入库单ID'); @@ -13551,7 +13619,6 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'tenant_i CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_occupancy', 'warehouse_id', '仓库ID'); -CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_pallet_reservation', '托盘活动预占唯一标记(包含A0)'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'active_reservation', '普通库位活动预占唯一标记,A0 返回 NULL'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'business_id', '来源服务入库单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_location_reservation', 'confirmed_at', '确认占用时间'); @@ -13613,13 +13680,12 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_code', '平 CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_name', '托盘名称'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'pallet_type', '托盘类型'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'remark', '备注'); -CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'status', '内部状态:AVAILABLE、RESERVED或OCCUPIED'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'tenant_id', '平台公共托盘固定使用 tenant_id=0'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'warehouse_id', '所属仓库ID'); -CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'weight_kg', '托盘重量kg'); +CALL queue_column_comment('baiyue_warehouse', 'wh_pallet', 'weight_kg', '托盘皮重kg'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'business_id', '入库单ID'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'completed_at', '完成时间'); CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_release_operation', 'created_at', '创建时间'); @@ -13815,6 +13881,47 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:Finance 来源事件或人工写操作幂等约束不完整'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_pallet' AND column_name = 'status') + OR (table_name = 'wh_location_reservation' AND column_name = 'active_pallet_reservation') + OR (table_name = 'wh_location_occupancy' AND column_name = 'active_pallet_occupancy'))) <> 0 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND index_name IN ('uk_pallet_active_reservation', 'uk_pallet_active_occupancy')) <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:托盘动态资源状态或活动唯一约束仍存在'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name)) + FROM information_schema.statistics + WHERE table_schema = 'baiyue_warehouse' + AND ((table_name = 'wh_location_reservation' AND index_name = 'uk_location_active_reservation') + OR (table_name = 'wh_location_occupancy' AND index_name = 'uk_location_active_occupancy')) + AND non_unique = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:库位活动预占或占用唯一约束不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND ((table_name = 'fl_inbound_weight_result' AND column_name IN ( + 'pallet_id', 'pallet_code', 'pallet_type', 'pallet_capacity_cbm', + 'pallet_tare_weight_kg', 'gross_weight_kg', 'net_weight_kg')) + OR (table_name = 'fl_inbound_weight_revision' + AND column_name IN ('gross_weight_kg', 'net_weight_kg')))) <> 9 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' + AND table_name IN ('fl_inbound_weight_result', 'fl_inbound_weight_revision') + AND column_name = 'final_weight_kg') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:称重托盘快照或毛重净重字段未正确收敛'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' + AND table_name = 'fl_inbound_pallet_allocation' + AND index_name = 'uk_pallet_allocation_pallet') <> 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:同票托盘唯一约束仍阻止托盘主数据复用'; + END IF; IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_carton_label') <> 1 THEN diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index 86d0e8481..d5ccc7dc4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -13,39 +13,44 @@ import java.time.LocalDateTime; import java.util.List; @FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient", - url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations") + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1") public interface WarehousePalletLocationClient { - @PostMapping("/reservations") + @GetMapping("/pallets/snapshots") + ApiResponse> palletSnapshots( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("warehouseId") long warehouseId); + + @PostMapping("/pallet-locations/reservations") ApiResponse reserve(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReserveRequest request); - @PostMapping("/confirm") + @PostMapping("/pallet-locations/confirm") ApiResponse confirm(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ConfirmRequest request); - @PostMapping("/release") + @PostMapping("/pallet-locations/release") ApiResponse> release(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody ReleaseRequest request); - @PostMapping("/release-selected") + @PostMapping("/pallet-locations/release-selected") ApiResponse> releaseSelected( @RequestHeader("X-Internal-Service-Token") String token, @RequestBody SelectiveReleaseRequest request); - @PostMapping("/transfer") + @PostMapping("/pallet-locations/transfer") ApiResponse> transfer(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody TransferRequest request); - @PostMapping("/settlements") + @PostMapping("/pallet-locations/settlements") ApiResponse settle(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody SettlementRequest request); - @GetMapping("/settlements/target") + @GetMapping("/pallet-locations/settlements/target") ApiResponse settlementTarget(@RequestHeader("X-Internal-Service-Token") String token, @RequestParam("sourceBusinessId") long sourceBusinessId, @RequestParam("idempotencyKey") String idempotencyKey); - @GetMapping("/occupied-resources") + @GetMapping("/pallet-locations/occupied-resources") ApiResponse> occupiedResources( @RequestHeader("X-Internal-Service-Token") String token, @RequestParam("tenantId") long tenantId, @@ -90,6 +95,8 @@ public interface WarehousePalletLocationClient { List remainingItems) {} record SettlementResult(List released, List transferred) {} record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId, - long palletId, long locationId, String locationCode, boolean sharedOverflow, - int allocatedPackageCount) {} + long palletId, long locationId, String locationCode, boolean sharedOverflow, + int allocatedPackageCount) {} + record PalletSnapshot(long palletId, String palletCode, String palletType, + BigDecimal capacityCbm, BigDecimal tareWeightKg) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 058850f70..f95135f66 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -33,6 +33,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + /** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */ @Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。") @RestController @@ -53,6 +55,19 @@ public class PlatformInboundMeasurementController { return ApiResponse.success(service.detail(tenantId, inboundId)); } + @Operation(summary = "查询称重可选托盘快照", + description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。") + @GetMapping("/weight-pallet-options") + @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view'," + + " 'platform:firstleg:inbound:weight:edit')") + public ApiResponse> weightPalletOptions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId)); + } + @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") @@ -142,7 +157,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND", resourceId = "#inboundId", description = "登记称重图片 OCR 原值") - @Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。") + @Operation(summary = "登记 OCR 重量结果", + description = "按图片文件 ID 和托盘 ID 保存 OCR 毛重原值及同仓托盘快照。") @PostMapping("/weight-results") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')") public ApiResponse createWeightResult( @@ -173,7 +189,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT", resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重") @Operation(summary = "确认 OCR 重量", - description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。") + description = "可在同一事务中携带人工毛重修改值并确认;统一换算 KG、扣除托盘皮重后," + + "按所有已确认净重重算入库单 actualGrossWeightKg。") @PostMapping("/weight-results/{resultId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')" + " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 38bebfe18..b6ab938c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -79,6 +79,7 @@ public final class InboundMeasurementRequests { public record CreateWeightResultRequest( @NotNull @Positive Long fileId, + @NotNull @Positive Long palletId, @Size(max = 64) String ocrProvider, @Size(max = 1000) String ocrRawText, @DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 67744b939..0f74b7382 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -81,14 +81,20 @@ public final class InboundMeasurementResponses { public record WeightResult( Long id, Long fileId, + Long palletId, + String palletCode, + String palletType, + BigDecimal palletCapacityCbm, + BigDecimal palletTareWeightKg, String ocrProvider, String ocrRawText, BigDecimal ocrWeightValue, String ocrWeightUnit, BigDecimal confidence, + BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit, - BigDecimal finalWeightKg, + BigDecimal netWeightKg, String recognitionStatus, String adjustmentReason, Long adjustedBy, @@ -99,6 +105,15 @@ public final class InboundMeasurementResponses { ) { } + public record PalletSnapshot( + Long palletId, + String palletCode, + String palletType, + BigDecimal capacityCbm, + BigDecimal tareWeightKg + ) { + } + public record DraftSaved(Long measurementId, Integer version, String status) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index 39544286f..22dd6a734 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -37,9 +37,11 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, } public record WeightResult( - Long id, Long fileId, BigDecimal recognizedWeightValue, String recognizedWeightUnit, - BigDecimal adjustedWeightValue, String adjustedWeightUnit, - BigDecimal finalWeightKg, String recognitionStatus) { + Long id, Long fileId, Long palletId, String palletCode, String palletType, + BigDecimal palletCapacityCbm, BigDecimal palletTareWeightKg, + BigDecimal recognizedWeightValue, String recognizedWeightUnit, + BigDecimal grossWeightKg, BigDecimal adjustedWeightValue, String adjustedWeightUnit, + BigDecimal netWeightKg, String recognitionStatus) { } public record BillingBasis( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java index 6e00d2919..e981ba446 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightResultEntity.java @@ -10,7 +10,7 @@ import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; -/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */ +/** 称重图片、托盘皮重快照、OCR 原值和最终确认净重。 */ @Getter @Setter @TableName("fl_inbound_weight_result") @@ -20,6 +20,11 @@ public class InboundWeightResultEntity extends BaseEntity { private Long tenantId; private Long inboundId; private Long fileId; + private Long palletId; + private String palletCode; + private String palletType; + private BigDecimal palletCapacityCbm; + private BigDecimal palletTareWeightKg; private String ocrProvider; private String ocrRawText; private BigDecimal ocrWeightValue; @@ -27,7 +32,8 @@ public class InboundWeightResultEntity extends BaseEntity { private BigDecimal confidence; private BigDecimal adjustedWeightValue; private String adjustedWeightUnit; - private BigDecimal finalWeightKg; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private String recognitionStatus; private String adjustmentReason; private Long adjustedBy; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java index 7882ebdef..ef6eb203b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundWeightRevisionEntity.java @@ -25,7 +25,8 @@ public class InboundWeightRevisionEntity { private String beforeWeightUnit; private BigDecimal afterWeightValue; private String afterWeightUnit; - private BigDecimal finalWeightKg; + private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private String reason; private Long operatorId; private String operatorName; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 1e67be5d1..b82ca598c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CartonMeasurementResult; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import feign.FeignException; import org.slf4j.Logger; @@ -143,13 +144,26 @@ public class CartonMeasurementService { } // 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。 if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) { - BigDecimal totalWeight = items.stream() - .map(InboundMeasurementItemEntity::getTotalWeightKg) - .filter(java.util.Objects::nonNull) - .reduce(BigDecimal.ZERO, BigDecimal::add) - .setScale(3, RoundingMode.CEILING); - if (positive(totalWeight)) { - inbound.setActualGrossWeightKg(totalWeight); + List confirmedWeights = repository.listWeights(tenantId, inboundId) + .stream() + .filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus())) + .toList(); + if (!confirmedWeights.isEmpty()) { + BigDecimal confirmedNetWeight = confirmedWeights.stream() + .map(InboundWeightResultEntity::getNetWeightKg) + .filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.CEILING); + inbound.setActualGrossWeightKg(confirmedNetWeight); + } else { + BigDecimal totalWeight = items.stream() + .map(InboundMeasurementItemEntity::getTotalWeightKg) + .filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(3, RoundingMode.CEILING); + if (positive(totalWeight)) { + inbound.setActualGrossWeightKg(totalWeight); + } } inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 199f96517..f61879683 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.dto.MeasurementBatchCommand; @@ -57,6 +58,8 @@ public class InboundMeasurementService { private String internalToken; @Resource private MeasurementImageClient imageClient; + @Resource + private WarehousePalletLocationClient warehousePallets; public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); @@ -68,6 +71,16 @@ public class InboundMeasurementService { return toDetail(inbound, measurement, items, weights, review); } + public List weightPalletOptions( + long tenantId, long inboundId) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + return loadPalletSnapshots(inbound).stream() + .map(snapshot -> new InboundMeasurementResponses.PalletSnapshot( + snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(), + snapshot.capacityCbm(), snapshot.tareWeightKg())) + .toList(); + } + @Transactional public InboundMeasurementResponses.DraftSaved saveDraft( long tenantId, long inboundId, long operatorId, @@ -147,11 +160,22 @@ public class InboundMeasurementService { if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) { throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重"); } + WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshots(inbound).stream() + .filter(snapshot -> snapshot.palletId() == request.palletId()) + .findFirst() + .orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED, + "所选托盘不属于当前入库仓库或已被删除")); + validatePalletSnapshot(pallet); String unit = rawOcrUnit(request.ocrWeightUnit()); InboundWeightResultEntity weight = new InboundWeightResultEntity(); weight.setTenantId(tenantId); weight.setInboundId(inboundId); weight.setFileId(request.fileId()); + weight.setPalletId(pallet.palletId()); + weight.setPalletCode(pallet.palletCode().trim()); + weight.setPalletType(pallet.palletType().trim()); + weight.setPalletCapacityCbm(pallet.capacityCbm().setScale(4, RoundingMode.HALF_UP)); + weight.setPalletTareWeightKg(pallet.tareWeightKg().setScale(3, RoundingMode.HALF_UP)); weight.setOcrProvider(request.ocrProvider()); weight.setOcrRawText(request.ocrRawText()); weight.setOcrWeightValue(request.ocrWeightValue()); @@ -196,7 +220,8 @@ public class InboundMeasurementService { weight.setAdjustmentReason(adjustmentReason.trim()); weight.setAdjustedBy(operatorId); weight.setAdjustedAt(LocalDateTime.now()); - weight.setFinalWeightKg(null); + weight.setGrossWeightKg(null); + weight.setNetWeightKg(null); weight.setConfirmedBy(null); weight.setConfirmedAt(null); weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM); @@ -207,7 +232,7 @@ public class InboundMeasurementService { int correctedVersion = expectedVersion + 1; weight.setVersion(correctedVersion); insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit, - adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(), + adjustedWeightValue, adjustedUnit, null, null, adjustmentReason.trim(), operatorId, operatorName); return correctedVersion; } @@ -236,8 +261,15 @@ public class InboundMeasurementService { throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位"); } String unit = normalizeUnit(rawUnit); - BigDecimal finalKg = requireConfirmableKg(value, unit); - weight.setFinalWeightKg(finalKg); + BigDecimal grossWeightKg = requireConfirmableKg(value, unit); + BigDecimal netWeightKg = grossWeightKg.subtract(weight.getPalletTareWeightKg()) + .setScale(3, RoundingMode.HALF_UP); + if (!isPositive(netWeightKg)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "称重毛重必须大于托盘皮重"); + } + weight.setGrossWeightKg(grossWeightKg); + weight.setNetWeightKg(netWeightKg); weight.setRecognitionStatus(WEIGHT_CONFIRMED); weight.setConfirmedBy(operatorId); weight.setConfirmedAt(LocalDateTime.now()); @@ -248,7 +280,7 @@ public class InboundMeasurementService { int confirmedVersion = currentVersion + 1; weight.setVersion(confirmedVersion); insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit, - finalKg, null, operatorId, operatorName); + grossWeightKg, netWeightKg, null, operatorId, operatorName); recalculateConfirmedWeight(inbound, operatorId); return toWeight(weight); } @@ -288,7 +320,7 @@ public class InboundMeasurementService { .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) .toList(); BigDecimal total = confirmedWeights.stream() - .map(InboundWeightResultEntity::getFinalWeightKg) + .map(InboundWeightResultEntity::getNetWeightKg) .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(3, RoundingMode.HALF_UP); inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total); @@ -304,7 +336,7 @@ public class InboundMeasurementService { private BigDecimal confirmedWeightForSubmission(List weights) { BigDecimal total = weights.stream() .filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) - .map(InboundWeightResultEntity::getFinalWeightKg) + .map(InboundWeightResultEntity::getNetWeightKg) .filter(java.util.Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP); return isPositive(total) ? total : null; @@ -319,7 +351,8 @@ public class InboundMeasurementService { private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action, BigDecimal beforeValue, String beforeUnit, - BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg, + BigDecimal afterValue, String afterUnit, + BigDecimal grossWeightKg, BigDecimal netWeightKg, String reason, long operatorId, String operatorName) { InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity(); revision.setTenantId(weight.getTenantId()); @@ -331,7 +364,8 @@ public class InboundMeasurementService { revision.setBeforeWeightUnit(beforeUnit); revision.setAfterWeightValue(afterValue); revision.setAfterWeightUnit(afterUnit); - revision.setFinalWeightKg(finalWeightKg); + revision.setGrossWeightKg(grossWeightKg); + revision.setNetWeightKg(netWeightKg); revision.setReason(reason); revision.setOperatorId(operatorId); revision.setOperatorName(operatorName); @@ -396,13 +430,46 @@ public class InboundMeasurementService { private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) { return new InboundMeasurementResponses.WeightResult( - weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(), + weight.getId(), weight.getFileId(), weight.getPalletId(), weight.getPalletCode(), + weight.getPalletType(), weight.getPalletCapacityCbm(), weight.getPalletTareWeightKg(), + weight.getOcrProvider(), weight.getOcrRawText(), weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(), - weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(), + weight.getGrossWeightKg(), + weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), + weight.getNetWeightKg(), weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(), weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion()); } + private List loadPalletSnapshots( + InboundOrderEntity inbound) { + if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) { + throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘"); + } + try { + ApiResponse> response = + warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "托盘快照查询失败" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘快照"); + } + } + + private void validatePalletSnapshot(WarehousePalletLocationClient.PalletSnapshot snapshot) { + if (snapshot.palletId() <= 0 || !StringUtils.hasText(snapshot.palletCode()) + || !StringUtils.hasText(snapshot.palletType()) || !isPositive(snapshot.capacityCbm()) + || snapshot.tareWeightKg() == null + || snapshot.tareWeightKg().compareTo(BigDecimal.ZERO) < 0) { + throw new BizException(ResultCode.CONFLICT, "仓库服务返回的托盘快照不完整"); + } + } + private InboundOrderEntity requireInbound(long tenantId, long inboundId) { InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId); if (inbound == null) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 931065259..30af43bd4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -73,9 +73,11 @@ public class MerchantInboundBillingService { .toList(); var weightResults = measurement.weightResults().stream() .map(weight -> new MerchantInboundBillingResponse.WeightResult( - weight.id(), weight.fileId(), weight.ocrWeightValue(), weight.ocrWeightUnit(), - weight.adjustedWeightValue(), weight.adjustedWeightUnit(), - weight.finalWeightKg(), weight.recognitionStatus())) + weight.id(), weight.fileId(), weight.palletId(), weight.palletCode(), weight.palletType(), + weight.palletCapacityCbm(), weight.palletTareWeightKg(), + weight.ocrWeightValue(), weight.ocrWeightUnit(), + weight.grossWeightKg(), weight.adjustedWeightValue(), weight.adjustedWeightUnit(), + weight.netWeightKg(), weight.recognitionStatus())) .toList(); var basis = new MerchantInboundBillingResponse.BillingBasis( fee.chargeMode(), fee.chargeUnit(), chargeableBasis(fee), fee.actualWeightKg(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql index 11869f9a4..a2cd23498 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql @@ -77,7 +77,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 3fc7fa9cb..7962a0d2f 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', - actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', + actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '下游货物实际毛重kg,汇总已确认称重净重', chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg', actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3', chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3', @@ -270,6 +270,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse托盘ID', + pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型快照', + pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘容量CBM快照', + pallet_tare_weight_kg DECIMAL(18,3) NOT NULL COMMENT '托盘皮重KG快照', ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码', ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本', ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值', @@ -277,7 +282,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( confidence DECIMAL(6,5) NULL COMMENT '识别置信度', adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量', adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因', adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID', @@ -293,8 +299,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result ( PRIMARY KEY (id), UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id), KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status), + KEY idx_weight_inbound_pallet (tenant_id, inbound_id, pallet_id), CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) -) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量'; +) ENGINE=InnoDB COMMENT='称重图片、托盘皮重快照、OCR毛重和确认净重'; CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID', @@ -307,7 +314,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( before_weight_unit VARCHAR(8) NULL COMMENT '变更前单位', after_weight_value DECIMAL(18,3) NULL COMMENT '变更后原单位重量', after_weight_unit VARCHAR(8) NULL COMMENT '变更后单位', - final_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的重量kg', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认时统一换算后的含托盘毛重kg', + net_weight_kg DECIMAL(18,3) NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', reason VARCHAR(500) NULL COMMENT '人工修正原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', @@ -664,7 +672,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no), - UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id), KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status), KEY idx_allocation_reservation (reservation_id), CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index b7054cf21..0b12768b7 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -542,13 +542,62 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无测量编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options: + get: + summary: 查询称重可选托盘快照 + description: >- + 需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 查询 Warehouse 公共托盘快照; + 仅限定同仓且未删除,不校验托盘状态或占用资格,Admin 不需要 platform:pallet:list。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/WeightPalletSnapshot'}} + '403': {description: 无入库测量查看或称重编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results: + post: + summary: 登记称重图片 OCR 毛重结果 + description: >- + 需要称重编辑权限。请求必须绑定 palletId;Firstleg 按入库单 warehouseId 读取 Warehouse + 权威快照并固化托盘编码、类型、容量和皮重,不信任前端托盘快照。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateWeightResultRequest'} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} + '403': {description: 无称重编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm: post: summary: 确认 OCR 重量 description: >- 需要 platform:firstleg:inbound:weight:confirm 权限;携带人工修改字段时还需要 platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值, - 分别写入 CORRECT 和 CONFIRM 修订记录,并重算所有已确认图片的实际总毛重。 + 分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG, + 使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。 + 入库单 actualGrossWeightKg 明确保留为下游货物实际毛重字段,值汇总所有已确认 netWeightKg。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -559,14 +608,48 @@ paths: application/json: schema: {$ref: '#/components/schemas/ConfirmWeightResultRequest'} responses: - '200': {description: 成功或业务失败,按响应体 code 判断} + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} '403': {description: 无确认权限,或修改重量时无编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}: + put: + summary: 人工修正 OCR 毛重 + description: >- + 需要称重编辑权限。修正值仍表示含托盘毛重;保留 OCR 原值,已确认结果修正后立即移出净重汇总并回到待确认。 + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CorrectWeightResultRequest'} + responses: + '200': + description: 成功或业务失败,按响应体 code 判断 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/InboundWeightResult'} + '403': {description: 无称重编辑权限} delete: summary: 删除称重图片及重量结果 description: >- 需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。 - 删除已确认图片后重新汇总剩余已确认图片的实际总毛重,删除最后一张时总毛重恢复为空。 + 删除已确认图片后重新汇总剩余已确认图片的净重,删除最后一张时 actualGrossWeightKg 恢复为空。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}} @@ -986,10 +1069,65 @@ components: properties: configId: {type: string, pattern: '^[1-9][0-9]*$'} createdByThisInitialization: {type: boolean} + WeightPalletSnapshot: + type: object + required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg] + properties: + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + palletCode: {type: string} + palletType: {type: string} + capacityCbm: {type: number, format: double, minimum: 0.0001} + tareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + CreateWeightResultRequest: + type: object + required: [fileId, palletId] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + ocrProvider: {type: string, maxLength: 64, nullable: true} + ocrRawText: {type: string, maxLength: 1000, nullable: true} + ocrWeightValue: {type: number, format: double, minimum: 0, nullable: true, description: OCR 原始含托盘毛重} + ocrWeightUnit: {type: string, maxLength: 8, nullable: true} + confidence: {type: number, format: double, minimum: 0, maximum: 1, nullable: true} + CorrectWeightResultRequest: + type: object + required: [version, adjustedWeightValue, adjustedWeightUnit, adjustmentReason] + properties: + version: {type: integer, minimum: 0} + adjustedWeightValue: {type: number, format: double, minimum: 0.001, description: 人工修正后的含托盘毛重} + adjustedWeightUnit: {type: string, enum: [KG, LB]} + adjustmentReason: {type: string, minLength: 1, maxLength: 500} + InboundWeightResult: + type: object + required: [id, fileId, palletId, palletCode, palletType, palletCapacityCbm, palletTareWeightKg, recognitionStatus, version] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + palletId: {type: string, pattern: '^[1-9][0-9]*$'} + palletCode: {type: string} + palletType: {type: string} + palletCapacityCbm: {type: number, format: double, minimum: 0.0001} + palletTareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001} + ocrProvider: {type: string, nullable: true} + ocrRawText: {type: string, nullable: true} + ocrWeightValue: {type: number, format: double, nullable: true, description: OCR 原始含托盘毛重} + ocrWeightUnit: {type: string, nullable: true} + confidence: {type: number, format: double, nullable: true} + grossWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: 确认时标准化的含托盘毛重 KG} + adjustedWeightValue: {type: number, format: double, nullable: true, description: 人工修正后的含托盘毛重} + adjustedWeightUnit: {type: string, nullable: true} + netWeightKg: {type: number, format: double, nullable: true, multipleOf: 0.001, description: grossWeightKg - palletTareWeightKg} + recognitionStatus: {type: string, enum: [PENDING, PENDING_CONFIRM, NEEDS_MANUAL, CONFIRMED]} + adjustmentReason: {type: string, nullable: true} + adjustedBy: {type: string, nullable: true} + adjustedAt: {type: string, format: date-time, nullable: true} + confirmedBy: {type: string, nullable: true} + confirmedAt: {type: string, format: date-time, nullable: true} + version: {type: integer, minimum: 0} ConfirmWeightResultRequest: type: object required: [version] - description: 人工修改字段必须同时提供;全部省略时直接确认现有 OCR 或已修正重量。 + description: 人工修改字段表示含托盘毛重且必须同时提供;全部省略时直接确认现有 OCR 或已修正毛重。 properties: version: {type: integer, minimum: 0} adjustedWeightValue: {type: number, format: double, minimum: 0.001, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java new file mode 100644 index 000000000..1bcd9995c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehousePalletLocationClientContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehousePalletLocationClientContractTest { + @Test + void consumesTheWarehousePalletSnapshotContractExactly() throws Exception { + FeignClient client = WarehousePalletLocationClient.class.getAnnotation(FeignClient.class); + assertThat(client.path()).isEqualTo("/internal/warehouse/v1"); + + Method method = WarehousePalletLocationClient.class.getDeclaredMethod( + "palletSnapshots", String.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/pallets/snapshots"); + assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).value()) + .isEqualTo("warehouseId"); + + var snapshot = new WarehousePalletLocationClient.PalletSnapshot( + 101L, "PLT-101", "STANDARD", new java.math.BigDecimal("2.0000"), + new java.math.BigDecimal("18.500")); + assertThat(snapshot).extracting( + WarehousePalletLocationClient.PalletSnapshot::palletId, + WarehousePalletLocationClient.PalletSnapshot::palletCode, + WarehousePalletLocationClient.PalletSnapshot::palletType, + WarehousePalletLocationClient.PalletSnapshot::capacityCbm, + WarehousePalletLocationClient.PalletSnapshot::tareWeightKg) + .containsExactly(101L, "PLT-101", "STANDARD", + new java.math.BigDecimal("2.0000"), new java.math.BigDecimal("18.500")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 84a4d6774..2a1d81c82 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -17,6 +17,10 @@ class PlatformInboundMeasurementControllerContractTest { void everyMeasurementOperationUsesDedicatedButtonPermission() throws Exception { assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:measure:view')", AuthenticatedUser.class, long.class, long.class); + assertAuthority("weightPalletOptions", + "hasAnyAuthority('platform:firstleg:inbound:measure:view'," + + " 'platform:firstleg:inbound:weight:edit')", + AuthenticatedUser.class, long.class, long.class); assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.SaveDraftRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java new file mode 100644 index 000000000..5937a6f8c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/dto/CreateWeightResultRequestValidationTest.java @@ -0,0 +1,34 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class CreateWeightResultRequestValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void requiresAValidFileAndPalletBinding() { + var valid = request(120L, 501L); + var missingPallet = request(120L, null); + var invalidPallet = request(120L, 0L); + + assertThat(validator.validate(valid)).isEmpty(); + assertThat(validator.validate(missingPallet)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("palletId"); + assertThat(validator.validate(invalidPallet)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("palletId"); + } + + private InboundMeasurementRequests.CreateWeightResultRequest request(Long fileId, Long palletId) { + return new InboundMeasurementRequests.CreateWeightResultRequest( + fileId, palletId, "FILE_WEIGHT_OCR", "30.000 KG", new BigDecimal("30.000"), + "KG", new BigDecimal("0.99000")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 637251088..3d06d0412 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -63,4 +63,26 @@ class InboundWorkflowSqlContractTest { .contains("uk_measure_box_code") .contains("单箱当前测量字段未完整创建"); } + + @Test + @DisplayName("称重结果固化托盘快照、毛重和净重并允许托盘主数据复用") + void shouldPersistWeightPalletSnapshotsAndNetWeight() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + assertThat(schema) + .contains("pallet_tare_weight_kg DECIMAL(18,3) NOT NULL") + .contains("gross_weight_kg DECIMAL(18,3) NULL") + .contains("net_weight_kg DECIMAL(18,3) NULL") + .contains("idx_weight_inbound_pallet") + .doesNotContain("final_weight_kg") + .doesNotContain("uk_pallet_allocation_pallet"); + assertThat(unified) + .contains("CALL phase2_measure_add_column('fl_inbound_weight_result', 'pallet_id'") + .contains("ALTER TABLE fl_inbound_weight_result DROP COLUMN final_weight_kg") + .contains("ALTER TABLE fl_inbound_pallet_allocation DROP INDEX uk_pallet_allocation_pallet") + .contains("称重托盘快照或毛重净重字段未正确收敛") + .contains("同票托盘唯一约束仍阻止托盘主数据复用"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index 189441602..3ab81dcce 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import org.junit.jupiter.api.BeforeEach; @@ -248,7 +249,7 @@ class CartonMeasurementServiceTest { } @Test - void confirmedPackageCountSynchronizesActualWeightAndVolumeForPricingAfterSubmission() { + void confirmedPackageCountUsesCartonWeightAsFallbackWhenNoConfirmedImageExists() { InboundOrderEntity inbound = inbound("MEASURE_PENDING"); InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); measurement.setTotalPackageCount(1); @@ -266,6 +267,7 @@ class CartonMeasurementServiceTest { when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); when(repository.updateItem(item)).thenReturn(true); when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of()); when(repository.updateMeasurement(measurement)).thenReturn(true); when(repository.updateInbound(inbound)).thenReturn(true); @@ -278,6 +280,41 @@ class CartonMeasurementServiceTest { verify(repository).updateInbound(inbound); } + @Test + void cartonMeasurementAfterConfirmedWeightKeepsNetWeightAndUpdatesVolume() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setActualGrossWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2); + measurement.setTotalPackageCount(1); + measurement.setTotalVolumeCbm(new BigDecimal("0.0600")); + measurement.setVolumeWeightKg(new BigDecimal("10.020")); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + item.setUnitWeightKg(new BigDecimal("10.880")); + item.setOuterLengthCm(new BigDecimal("50")); + item.setOuterWidthCm(new BigDecimal("40")); + item.setOuterHeightCm(new BigDecimal("30")); + item.setUnitVolumeCbm(new BigDecimal("0.1496")); + item.setCalculationRuleVersion("ACTUAL_DIMENSION_V1"); + InboundWeightResultEntity confirmedWeight = new InboundWeightResultEntity(); + confirmedWeight.setRecognitionStatus("CONFIRMED"); + confirmedWeight.setNetWeightKg(new BigDecimal("11.500")); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.listWeights(10001L, 70001L)).thenReturn(List.of(confirmedWeight)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5); + + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.7480"); + assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("124.916"); + verify(repository).updateInbound(inbound); + } + private CartonMeasurementCommand command() { return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 009fab68a..d3f975717 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.MeasurementBatchCommand; import com.baiyue.firstleg.dto.InboundMeasurementResponses; @@ -49,8 +50,15 @@ class InboundMeasurementServiceTest { @MockBean private MeasurementBatchService batchService; @MockBean private OrderCargoClient orderCargoClient; @MockBean private MeasurementImageClient imageClient; + @MockBean private WarehousePalletLocationClient warehousePallets; @Resource private InboundMeasurementService service; + @BeforeEach + void stubWarehousePalletSnapshots() { + when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn( + ApiResponse.success(List.of(palletSnapshot()))); + } + @Test void customerCannotReadAnotherCustomersMeasurement() { assertThatThrownBy(() -> service.detail(2L, 10L)) @@ -90,7 +98,8 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("12.500")); InboundWeightResultEntity weight = weight(20L, "12.500", "KG", "CONFIRMED", 4); - weight.setFinalWeightKg(new BigDecimal("12.500")); + weight.setGrossWeightKg(new BigDecimal("12.500")); + weight.setNetWeightKg(new BigDecimal("12.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); when(repository.updateWeight(weight)).thenReturn(true); @@ -102,7 +111,8 @@ class InboundMeasurementServiceTest { 4, new BigDecimal("13.200"), "KG", "秤面显示修正")); assertThat(weight.getRecognitionStatus()).isEqualTo("PENDING_CONFIRM"); - assertThat(weight.getFinalWeightKg()).isNull(); + assertThat(weight.getGrossWeightKg()).isNull(); + assertThat(weight.getNetWeightKg()).isNull(); assertThat(inbound.getActualGrossWeightKg()).isNull(); verify(repository).updateInboundWeightSummary(inbound); } @@ -134,22 +144,55 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.WeightResult result = service.createWeightResult( 1L, 10L, 9L, new InboundMeasurementRequests.CreateWeightResultRequest( - 120L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", + 120L, 501L, "FILE_WEIGHT_OCR", "285.5", new BigDecimal("285.500"), "KG", new BigDecimal("0.5097191334"))); ArgumentCaptor saved = ArgumentCaptor.forClass(InboundWeightResultEntity.class); verify(repository).insertWeight(saved.capture()); assertThat(saved.getValue().getConfidence()).isEqualByComparingTo("0.50972"); + assertThat(saved.getValue().getPalletId()).isEqualTo(501L); + assertThat(saved.getValue().getPalletCode()).isEqualTo("PLT-501"); + assertThat(saved.getValue().getPalletTareWeightKg()).isEqualByComparingTo("18.500"); assertThat(result.confidence()).isEqualByComparingTo("0.50972"); } + @Test + void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + List result = + service.weightPalletOptions(1L, 10L); + + assertThat(result).singleElement().satisfies(snapshot -> { + assertThat(snapshot.palletId()).isEqualTo(501L); + assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500"); + }); + verify(warehousePallets).palletSnapshots("internal-token", 100L); + } + + @Test + void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L, + new InboundMeasurementRequests.CreateWeightResultRequest( + 120L, 999L, "FILE_WEIGHT_OCR", "20.0 KG", new BigDecimal("20.000"), + "KG", new BigDecimal("0.99")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于当前入库仓库"); + + verify(repository, never()).insertWeight(any()); + } + @Test void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundWeightResultEntity current = weight(20L, "10.000", "LB", "PENDING_CONFIRM", 0); InboundWeightResultEntity previous = weight(21L, "2.000", "KG", "CONFIRMED", 1); - previous.setFinalWeightKg(new BigDecimal("2.000")); + previous.setNetWeightKg(new BigDecimal("2.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(current); when(repository.updateWeight(current)).thenReturn(true); @@ -160,12 +203,58 @@ class InboundMeasurementServiceTest { 1L, 10L, 20L, 9L, "操作员", new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); - assertThat(response.finalWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("4.536"); + assertThat(response.netWeightKg()).isEqualByComparingTo("4.536"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536"); assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING"); verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); } + @Test + void confirmingWeightAfterCartonMeasurementReplacesFallbackWithNetWeight() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + inbound.setActualGrossWeightKg(new BigDecimal("54.400")); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + when(repository.updateInboundWeightSummary(inbound)).thenReturn(true); + + InboundMeasurementResponses.WeightResult response = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(response.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(response.netWeightKg()).isEqualByComparingTo("11.500"); + assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("11.500"); + ArgumentCaptor revision = + ArgumentCaptor.forClass(InboundWeightRevisionEntity.class); + verify(repository).insertRevision(revision.capture()); + assertThat(revision.getValue().getGrossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(revision.getValue().getNetWeightKg()).isEqualByComparingTo("11.500"); + } + + @Test + void confirmingWeightRejectsGrossWeightNotAboveFrozenTare() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "18.500", "KG", "PENDING_CONFIRM", 0); + weight.setPalletTareWeightKg(new BigDecimal("18.500")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + + assertThatThrownBy(() -> service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("毛重必须大于托盘皮重"); + + verify(repository, never()).updateWeight(any()); + verify(repository, never()).insertRevision(any()); + verify(repository, never()).updateInboundWeightSummary(any()); + } + @ParameterizedTest @CsvSource({ "kg,2.000", @@ -190,7 +279,8 @@ class InboundMeasurementServiceTest { assertThat(response.adjustedWeightUnit()).isEqualTo( unit.trim().toUpperCase().startsWith("LB") ? "LB" : "KG"); - assertThat(response.finalWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.grossWeightKg()).isEqualByComparingTo(expectedKg); + assertThat(response.netWeightKg()).isEqualByComparingTo(expectedKg); assertThat(response.version()).isEqualTo(2); } @@ -208,7 +298,7 @@ class InboundMeasurementServiceTest { 1L, 10L, 20L, 9L, "操作员", new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); - assertThat(response.finalWeightKg()).isEqualByComparingTo("0.001"); + assertThat(response.netWeightKg()).isEqualByComparingTo("0.001"); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("0.001"); } @@ -283,7 +373,8 @@ class InboundMeasurementServiceTest { 0, new BigDecimal("286.125"), "kg", "OCR 原值 285.500 KG,人工确认 286.125 KG")); assertThat(response.adjustedWeightValue()).isEqualByComparingTo("286.125"); - assertThat(response.finalWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.grossWeightKg()).isEqualByComparingTo("286.125"); + assertThat(response.netWeightKg()).isEqualByComparingTo("286.125"); assertThat(response.recognitionStatus()).isEqualTo("CONFIRMED"); assertThat(response.version()).isEqualTo(2); assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("286.125"); @@ -344,7 +435,8 @@ class InboundMeasurementServiceTest { void confirmingAnAlreadyConfirmedWeightWithCurrentVersionIsIdempotent() { InboundOrderEntity inbound = inbound("MEASURING"); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); @@ -363,7 +455,8 @@ class InboundMeasurementServiceTest { void confirmingWithStaleVersionDoesNotTreatRequestAsIdempotent() { InboundOrderEntity inbound = inbound("MEASURING"); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); @@ -420,7 +513,8 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("285.500")); InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2); - weight.setFinalWeightKg(new BigDecimal("285.500")); + weight.setGrossWeightKg(new BigDecimal("285.500")); + weight.setNetWeightKg(new BigDecimal("285.500")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); @@ -440,9 +534,9 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = inbound("MEASURING"); inbound.setActualGrossWeightKg(new BigDecimal("15.000")); InboundWeightResultEntity deleted = weight(20L, "10.000", "KG", "CONFIRMED", 2); - deleted.setFinalWeightKg(new BigDecimal("10.000")); + deleted.setNetWeightKg(new BigDecimal("10.000")); InboundWeightResultEntity remaining = weight(21L, "5.000", "KG", "CONFIRMED", 1); - remaining.setFinalWeightKg(new BigDecimal("5.000")); + remaining.setNetWeightKg(new BigDecimal("5.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findWeight(1L, 10L, 20L)).thenReturn(deleted); when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true); @@ -488,7 +582,7 @@ class InboundMeasurementServiceTest { item.setPackageCount(3); item.setSourceImageFileId(500L); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); - weight.setFinalWeightKg(new BigDecimal("20.000")); + weight.setNetWeightKg(new BigDecimal("20.000")); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); @@ -592,6 +686,7 @@ class InboundMeasurementServiceTest { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(10L); inbound.setTenantId(1L); + inbound.setWarehouseId(100L); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); inbound.setVersion(0); @@ -605,10 +700,21 @@ class InboundMeasurementServiceTest { weight.setTenantId(1L); weight.setInboundId(10L); weight.setFileId(id + 100L); + weight.setPalletId(501L); + weight.setPalletCode("PLT-501"); + weight.setPalletType("STANDARD"); + weight.setPalletCapacityCbm(new BigDecimal("2.0000")); + weight.setPalletTareWeightKg(BigDecimal.ZERO.setScale(3)); weight.setOcrWeightValue(new BigDecimal(value)); weight.setOcrWeightUnit(unit); weight.setRecognitionStatus(status); weight.setVersion(version); return weight; } + + private WarehousePalletLocationClient.PalletSnapshot palletSnapshot() { + return new WarehousePalletLocationClient.PalletSnapshot( + 501L, "PLT-501", "STANDARD", new BigDecimal("2.0000"), + new BigDecimal("18.500")); + } } diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index d26e917c2..d20a1b119 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘、库位、预占和占用。 +- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。 - 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。 - 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。 @@ -28,10 +28,11 @@ ## 三期托盘、库位与 A0 规则 -- 普通库位保持一托一位;A0 是系统创建的共享临时溢出位,可被多个托盘占用,但每个托盘占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。 +- 托盘是可复用标识和称重主数据,不参与 `AVAILABLE → RESERVED → OCCUPIED → AVAILABLE`;同一托盘主数据可被多条分配复用。 +- 普通库位保持单活动预占/占用;A0 是系统创建的共享临时溢出位,可承载多条占用,但每条库位占用仍必须通过 `reservation_id`、`occupancy_id` 独立识别。 - 头程 PDA 结算接口 `/internal/warehouse/v1/pallet-locations/settlements` 是释放、子单转移和全部释放后重新分配的原子边界。不得将其拆成多个可独立提交的远程操作。 - 部分拣货的资源操作必须按 `reservationId` 定位,不能按 `locationId` 定位;A0 多托盘共享时,按 `locationId` 会误释放或合并资源。 -- 当父单全部资源释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持托盘状态、预占、占用和子单归属一致。 +- 当父单全部库位释放但子单仍有剩余数量时,在同一仓库事务内优先分配自然 A-Z 顺序的普通空闲库位;普通库位不足时使用 A0,并保持预占、占用和子单归属一致。 - A0 换绑入口包括内部接口和平台接口:平台人员使用 `/api/platform/v1/locations/temporary-occupancies/{occupancyId}/rebind`,需要 `platform:location:edit`。目标必须为同仓 `AVAILABLE` 的普通库位;A0 不允许人工编辑、停用或删除。 - 资源结算、释放、转移和换绑必须保存可重放的幂等记录或具有等价的状态幂等语义,并在测试中覆盖并发状态变更。 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java new file mode 100644 index 000000000..921086bec --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletController.java @@ -0,0 +1,67 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.service.PalletApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Min; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +/** 内部托盘主数据只读接口。 */ +@Validated +@RestController +@RequestMapping("/internal/warehouse/v1/pallets") +@Tag(name = "托盘内部接口", description = "内部服务令牌保护的托盘主数据快照") +public class InternalPalletController { + @Resource + private PalletApplicationService service; + @Value("${baiyue.security.internal-service-token}") + private String internalToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); + } + + @GetMapping("/{palletId}/snapshot") + @Operation(summary = "查询托盘只读快照", description = "固定按平台公共作用域校验仓库归属,不返回动态状态或可用性") + public ApiResponse snapshot( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable @Min(1) long palletId, @RequestParam @Min(1) long warehouseId) { + verify(token); + return ApiResponse.success(service.snapshot(0L, warehouseId, palletId)); + } + + @GetMapping("/snapshots") + @Operation(summary = "查询仓库托盘快照列表", description = "返回该仓全部未删除托盘主数据,不按动态状态或占用过滤") + public ApiResponse> snapshots( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam @Min(1) long warehouseId) { + verify(token); + return ApiResponse.success(service.snapshots(0L, warehouseId)); + } + + private void verify(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expectedToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "无效的内部服务凭据"); + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index c50811308..84ce49aed 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -41,7 +41,7 @@ public class InternalPalletLocationController { } @PostMapping("/reservations") - @Operation(summary = "拆分托盘并预占一托一位") + @Operation(summary = "按托盘主数据拆分并预占库位") public ApiResponse reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.ReserveRequest request) { verify(token); @@ -49,7 +49,7 @@ public class InternalPalletLocationController { } @PostMapping("/confirm") - @Operation(summary = "确认托盘和库位占用", description = "只确认仓库资源,不推进头程入库单状态") + @Operation(summary = "确认库位占用", description = "托盘只保存主数据快照;本接口不推进头程入库单状态") public ApiResponse confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.ConfirmRequest request) { verify(token); @@ -111,7 +111,7 @@ public class InternalPalletLocationController { } @PostMapping("/transfer") - @Operation(summary = "将未拣货库位转给子入库单", description = "仅更新业务归属,托盘和库位继续保持已占用") + @Operation(summary = "将未拣货库位转给子入库单", description = "仅更新库位业务归属,托盘主数据不变") public ApiResponse> transfer( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.TransferRequest request) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java index 41970d73a..2296589e1 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -89,7 +89,7 @@ public class PlatformPalletController { @DeleteMapping("/{id}") @PreAuthorize("hasAuthority('platform:pallet:delete')") - @Operation(summary = "删除托盘", description = "仅可删除未预占、未占用的托盘") + @Operation(summary = "删除托盘", description = "托盘是可复用主数据,删除不受库位预占或占用状态限制") public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @RequestParam @Min(1) long warehouseId, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index 65397b801..bdc93b4e5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -74,4 +74,14 @@ public final class PalletRequests { LocalDateTime updatedAt ) { } + + @Schema(name = "InternalPalletSnapshot", description = "内部只读托盘主数据快照;不包含动态资源状态") + public record Snapshot( + @Schema(description = "托盘ID") long palletId, + @Schema(description = "托盘编码") String palletCode, + @Schema(description = "托盘类型") String palletType, + @Schema(description = "容量,单位m³") BigDecimal capacityCbm, + @Schema(description = "皮重,单位kg") BigDecimal tareWeightKg + ) { + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java index 1bfb73b41..08c7bc22b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/PalletEntity.java @@ -10,9 +10,7 @@ import lombok.Setter; import java.math.BigDecimal; -/** - * 托盘主数据实体。状态仅供内部预占、占用流程使用,不属于平台维护字段。 - */ +/** 托盘可复用主数据实体,不承载预占或占用状态。 */ @Getter @Setter @TableName("wh_pallet") @@ -37,15 +35,12 @@ public class PalletEntity extends BaseEntity { @Schema(description = "托盘容积,单位m³", example = "1.200000") private BigDecimal capacityCbm; - @Schema(description = "托盘重量,单位kg", example = "8.000") + @Schema(description = "托盘皮重,单位kg", example = "8.000") private BigDecimal weightKg; @Schema(description = "托盘颜色", example = "黄色") private String color; - @Schema(description = "资源状态", allowableValues = {"AVAILABLE", "RESERVED", "OCCUPIED", "DISABLED"}) - private String status; - @Schema(description = "备注", example = "标准川字托盘") private String remark; } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java index 9497d69b8..588a75b4c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PalletMapper.java @@ -12,4 +12,13 @@ public interface PalletMapper extends BaseMapper { /** 编码序列必须包含逻辑删除记录,避免历史编码被复用。 */ @Select("SELECT pallet_code FROM wh_pallet WHERE tenant_id = #{tenantId} AND pallet_code LIKE 'TP-%'") List selectAllCodes(@Param("tenantId") long tenantId); + + /** 既有库位分配需在主数据逻辑删除后继续读取其不可变属性。 */ + @Select(""" + SELECT * FROM wh_pallet + WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId} AND id = #{id} + """) + PalletEntity selectIncludingDeleted(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId, + @Param("id") long id); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java index d34bf08de..9f44088dd 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PalletRepository.java @@ -11,12 +11,11 @@ import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; -import java.util.Collection; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** 托盘持久化仓储;平台分页支持跨租户筛选,资源操作始终包含租户和仓库边界。 */ +/** 托盘主数据持久化仓储;平台分页支持跨作用域筛选。 */ @Repository public class PalletRepository { private static final Pattern PALLET_CODE = Pattern.compile("TP-(\\d+)"); @@ -43,6 +42,18 @@ public class PalletRepository { .eq(PalletEntity::getId, id)); } + public PalletEntity findOwnedIncludingDeleted(long tenantId, long warehouseId, long id) { + return mapper.selectIncludingDeleted(tenantId, warehouseId, id); + } + + public List findAllByWarehouse(long tenantId, long warehouseId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(PalletEntity::getTenantId, tenantId) + .eq(PalletEntity::getWarehouseId, warehouseId) + .orderByAsc(PalletEntity::getPalletCode) + .orderByAsc(PalletEntity::getId)); + } + public boolean existsCode(long tenantId, long warehouseId, String code, Long excludedId) { return mapper.selectCount(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) @@ -51,16 +62,14 @@ public class PalletRepository { .ne(excludedId != null, PalletEntity::getId, excludedId)) > 0; } - public List findAvailable(long tenantId, long warehouseId, String palletType, - java.math.BigDecimal capacityCbm, int limit) { + public List findReusable(long tenantId, long warehouseId, String palletType, + java.math.BigDecimal capacityCbm) { return mapper.selectList(new LambdaQueryWrapper() .eq(PalletEntity::getTenantId, tenantId) .eq(PalletEntity::getWarehouseId, warehouseId) - .eq(PalletEntity::getStatus, "AVAILABLE") .eq(StringUtils.hasText(palletType), PalletEntity::getPalletType, palletType) .eq(capacityCbm != null, PalletEntity::getCapacityCbm, capacityCbm) - .orderByAsc(PalletEntity::getPalletCode) - .last("LIMIT " + limit)); + .orderByAsc(PalletEntity::getPalletCode)); } /** @@ -92,19 +101,7 @@ public class PalletRepository { .eq(PalletEntity::getWarehouseId, warehouseId) .eq(PalletEntity::getId, id) .eq(PalletEntity::getVersion, version) - .in(PalletEntity::getStatus, List.of("AVAILABLE", "DISABLED")) .set(PalletEntity::getDeleted, true) .set(PalletEntity::getVersion, version + 1)); } - - public int transitionStatus(long tenantId, long warehouseId, long id, - Collection expectedStatuses, String targetStatus) { - return mapper.update(null, new LambdaUpdateWrapper() - .eq(PalletEntity::getTenantId, tenantId) - .eq(PalletEntity::getWarehouseId, warehouseId) - .eq(PalletEntity::getId, id) - .in(PalletEntity::getStatus, expectedStatuses) - .set(PalletEntity::getStatus, targetStatus) - .setSql("version = version + 1")); - } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index 7d5fff487..2ac4c7cec 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -13,6 +13,8 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + /** 托盘主数据应用服务。 */ @Service public class PalletApplicationService { @@ -41,7 +43,6 @@ public class PalletApplicationService { entity.setTenantId(tenantId); entity.setWarehouseId(request.warehouseId()); copy(entity, request); - entity.setStatus("AVAILABLE"); String code = repository.nextCode(tenantId); for (int attempt = 0; attempt < CODE_INSERT_ATTEMPTS; attempt++) { entity.setPalletCode(code); @@ -78,7 +79,7 @@ public class PalletApplicationService { public void delete(long tenantId, long warehouseId, long id) { PalletEntity entity = owned(tenantId, warehouseId, id); if (repository.logicalDelete(tenantId, warehouseId, id, entity.getVersion()) != 1) { - throw new BizException(ResultCode.CONFLICT, "预占、占用或已被并发修改的托盘不能删除"); + throw new BizException(ResultCode.CONFLICT, "托盘已被并发修改,请刷新后重试"); } } @@ -122,4 +123,18 @@ public class PalletApplicationService { entity.getColor(), entity.getRemark(), entity.getCreatedAt(), entity.getUpdatedAt()); } + + public PalletRequests.Snapshot snapshot(long tenantId, long warehouseId, long palletId) { + PalletEntity entity = owned(tenantId, warehouseId, palletId); + return new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(), + entity.getCapacityCbm(), entity.getWeightKg()); + } + + public List snapshots(long tenantId, long warehouseId) { + requireWarehouse(tenantId, warehouseId, false); + return repository.findAllByWarehouse(tenantId, warehouseId).stream() + .map(entity -> new PalletRequests.Snapshot(entity.getId(), entity.getPalletCode(), entity.getPalletType(), + entity.getCapacityCbm(), entity.getWeightKg())) + .toList(); + } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index 2cf48e72c..e8133e04c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -28,7 +28,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; /** - * 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。 + * 托盘主数据复用和库位分配编排。库位资源操作全部委托给 {@link LocationResourceService}。 */ @Service public class PalletLocationAllocationService { @@ -54,7 +54,7 @@ public class PalletLocationAllocationService { private ObjectMapper objectMapper; @Transactional - @BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false) + @BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "复用托盘主数据并预占库位", captureBefore = false) public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) { if (!Boolean.TRUE.equals(request.measurementCompleted())) { throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位"); @@ -67,19 +67,15 @@ public class PalletLocationAllocationService { String palletType = org.springframework.util.StringUtils.hasText(request.palletType()) ? request.palletType() : DEFAULT_PALLET_TYPE; - List pallets = palletRepository.findAvailable(request.tenantId(), request.warehouseId(), - palletType, request.palletCapacityCbm(), split.palletCount()); - if (pallets.size() != split.palletCount()) { - throw new BizException(ResultCode.CONFLICT, "可用托盘数量不足"); + List pallets = palletRepository.findReusable(request.tenantId(), request.warehouseId(), + palletType, request.palletCapacityCbm()); + if (pallets.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未配置匹配类型和容量的托盘主数据"); } - List targets = new ArrayList<>(pallets.size()); - for (int index = 0; index < pallets.size(); index++) { - PalletEntity pallet = pallets.get(index); - if (palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), pallet.getId(), - List.of("AVAILABLE"), "RESERVED") != 1) { - throw new BizException(ResultCode.CONFLICT, "托盘被并发预占,请重试"); - } + List targets = new ArrayList<>(split.palletCount()); + for (int index = 0; index < split.palletCount(); index++) { + PalletEntity pallet = pallets.get(index % pallets.size()); targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(), request.idempotencyKey() + ":" + (index + 1))); } @@ -101,7 +97,7 @@ public class PalletLocationAllocationService { } @Transactional - @BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认托盘和库位占用") + @BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认库位占用") public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) { var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm()); List active = active(request.tenantId(), request.sourceService(), request.businessId()); @@ -123,13 +119,6 @@ public class PalletLocationAllocationService { List occupied = locationResourceService.confirmOccupied( new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(), request.businessId(), request.operatorId(), targets)); - for (LocationDtos.ActiveResource resource : active) { - if ("RESERVED".equals(resource.state()) - && palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), resource.palletId(), - List.of("RESERVED"), "OCCUPIED") != 1) { - throw new BizException(ResultCode.CONFLICT, "托盘确认占用发生并发冲突"); - } - } List results = new ArrayList<>(occupied.size()); for (int index = 0; index < occupied.size(); index++) { var item = occupied.get(index); @@ -141,7 +130,7 @@ public class PalletLocationAllocationService { } @Transactional - @BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放托盘和库位预占") + @BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放库位预占") public List release(PalletAllocationDtos.ReleaseRequest request) { List active = active(request.tenantId(), request.sourceService(), request.businessId()); if (active.stream().anyMatch(item -> "OCCUPIED".equals(item.state()))) { @@ -150,12 +139,6 @@ public class PalletLocationAllocationService { List released = locationResourceService.release( new LocationDtos.ReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(), request.operatorId(), request.reason())); - for (LocationDtos.ActiveResource resource : released) { - if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), - List.of("RESERVED"), "AVAILABLE") != 1) { - throw new BizException(ResultCode.CONFLICT, "托盘释放发生并发冲突"); - } - } return released; } @@ -187,10 +170,6 @@ public class PalletLocationAllocationService { request.operatorId(), request.reason(), request.locationIds())); List result = new ArrayList<>(released.size()); for (LocationDtos.ExactSelectiveReleaseResult resource : released) { - if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(), - List.of("OCCUPIED"), "AVAILABLE") != 1) { - throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release"); - } result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(), resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(), resource.locationCode(), resource.allocatedPackageCount())); @@ -269,10 +248,6 @@ public class PalletLocationAllocationService { request.sourceBusinessId(), request.operatorId(), settlementReason(request), request.releasedReservationIds())); List result = new ArrayList<>(released.size()); for (LocationDtos.ExactSelectiveReleaseResult resource : released) { - if (palletRepository.transitionStatus(PLATFORM_RESOURCE_TENANT_ID, resource.warehouseId(), resource.palletId(), - List.of("OCCUPIED"), "AVAILABLE") != 1) { - throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during PDA settlement"); - } result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(), resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(), resource.locationCode(), resource.allocatedPackageCount())); @@ -307,7 +282,7 @@ public class PalletLocationAllocationService { return sha256(canonical); } - /** 将未拣货的库位和托盘分配转给二级子入库单,物理资源继续保持占用。 */ + /** 将未拣货的库位分配转给二级子入库单,库位继续保持占用。 */ @Transactional @BusinessAudit(event = "PALLET_LOCATION_TRANSFER", resourceType = "INBOUND", resourceId = "#request.sourceBusinessId", description = "将未拣货库位转给子入库单") @@ -352,9 +327,9 @@ public class PalletLocationAllocationService { List transferred) { List result = new ArrayList<>(transferred.size()); for (LocationDtos.TransferResult item : transferred) { - PalletEntity pallet = palletRepository.findOwned(tenantId, item.warehouseId(), item.palletId()); - if (pallet == null || !"OCCUPIED".equals(pallet.getStatus())) { - throw new BizException(ResultCode.CONFLICT, "转移托盘不存在或未处于占用状态"); + PalletEntity pallet = palletRepository.findOwnedIncludingDeleted(tenantId, item.warehouseId(), item.palletId()); + if (pallet == null) { + throw new BizException(ResultCode.NOT_FOUND, "转移记录引用的托盘主数据不存在"); } result.add(new PalletAllocationDtos.TransferResult(item.reservationId(), item.reservationNo(), item.occupancyId(), item.allocationNo(), tenantId, item.warehouseId(), item.palletId(), diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java index d1aaa60a8..d6d41a8bf 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSchemaReadinessService.java @@ -20,9 +20,10 @@ public class WarehouseSchemaReadinessService { "SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0", "SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0", "SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0", + "SELECT id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, weight_kg, deleted, version FROM wh_pallet LIMIT 0", "SELECT id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_type, max_weight_kg, shared_overflow, status, deleted, version FROM wh_location LIMIT 0", "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0", - "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0", + "SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, version FROM wh_location_occupancy LIMIT 0", "SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0", "SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0", "SELECT id, tenant_id, business_type, order_no, warehouse_id, container_no, box_count, status, version FROM wh_domestic_container_order LIMIT 0", diff --git a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql index fe7ec30de..60eb7796c 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-location-schema.sql @@ -15,9 +15,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', pallet_name VARCHAR(100) NULL COMMENT '托盘名称', capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM', - weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘重量kg', + weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '托盘皮重kg', color VARCHAR(32) NOT NULL DEFAULT '' COMMENT '托盘颜色', - status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT '内部状态:AVAILABLE、RESERVED或OCCUPIED', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -27,11 +26,11 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_pallet_tenant_code (tenant_id, pallet_code), - KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted), - KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted), + KEY idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code), + KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted), CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0) -) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例'; +) ENGINE=InnoDB COMMENT='可复用托盘标识和称重主数据,不承载动态资源状态'; CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', @@ -84,19 +83,17 @@ CREATE TABLE IF NOT EXISTS wh_location_reservation ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动预占唯一标记,A0 返回 NULL', - active_pallet_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动预占唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no), UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key), UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation), - UNIQUE KEY uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation), KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status), KEY idx_location_reservation_expire (reservation_status, expires_at), KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status), CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id), CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id) -) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占'; +) ENGINE=InnoDB COMMENT='跨服务入库Saga的库位临时预占,托盘字段仅为主数据快照'; CREATE TABLE IF NOT EXISTS wh_location_occupancy ( id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID', @@ -123,12 +120,10 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', active_location_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED COMMENT '普通库位活动占用唯一标记', - active_pallet_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '托盘活动占用唯一标记(包含A0)', PRIMARY KEY (id), UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no), UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id), UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_location_occupancy), - UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_pallet_occupancy), KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status), KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at), CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id), @@ -136,6 +131,6 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy ( CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id), CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id), CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0) -) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录'; +) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照'; -- 头程入库单属于其他数据库,不建立跨库外键。 diff --git a/warehouse/src/main/resources/db/phase2-pallet-seed.sql b/warehouse/src/main/resources/db/phase2-pallet-seed.sql index 67373e44f..507565fb5 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-seed.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-seed.sql @@ -2,11 +2,11 @@ SET NAMES utf8mb4; USE baiyue_warehouse; --- 编码由平台维护接口自动生成;两个国内作业仓各初始化一个可用托盘。 -INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,status,remark,created_by,updated_by) +-- 编码由平台维护接口自动生成;两个国内作业仓各初始化一条可复用托盘主数据。 +INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,remark,created_by,updated_by) VALUES - (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), - (190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) + (190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0), + (190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0) ON DUPLICATE KEY UPDATE tenant_id=0, warehouse_id=VALUES(warehouse_id), diff --git a/warehouse/src/main/resources/openapi/pallet-v1.yaml b/warehouse/src/main/resources/openapi/pallet-v1.yaml new file mode 100644 index 000000000..a1db02419 --- /dev/null +++ b/warehouse/src/main/resources/openapi/pallet-v1.yaml @@ -0,0 +1,72 @@ +openapi: 3.0.3 +info: + title: Warehouse Pallet API + version: 1.0.0 + description: 托盘是可复用标识和称重主数据,不承载 AVAILABLE、RESERVED 或 OCCUPIED 动态状态。 +paths: + /internal/warehouse/v1/pallets/{palletId}/snapshot: + get: + summary: 查询单条托盘主数据快照 + description: 固定使用平台公共作用域 tenant_id=0,并按 warehouseId 校验归属;不返回状态或可用性。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - { in: path, name: palletId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': + description: 业务响应;不存在或归属不符时 body code 为 A0404 + content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotResponse' } } } + /internal/warehouse/v1/pallets/snapshots: + get: + summary: 查询仓库全部托盘主数据快照 + description: 固定使用平台公共作用域 tenant_id=0,返回该仓全部未删除主数据,不按状态或占用过滤。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - { in: query, name: warehouseId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + responses: + '200': + description: 业务响应 + content: { application/json: { schema: { $ref: '#/components/schemas/PalletSnapshotListResponse' } } } +components: + parameters: + InternalToken: + in: header + name: X-Internal-Service-Token + required: true + schema: { type: string } + schemas: + PalletSnapshot: + type: object + additionalProperties: false + required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg] + properties: + palletId: { type: integer, format: int64 } + palletCode: { type: string } + palletType: { type: string } + capacityCbm: { type: number } + tareWeightKg: { type: number } + ApiEnvelope: + type: object + required: [code, message, timestamp] + properties: + code: { type: string, example: '200' } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } + PalletSnapshotResponse: + allOf: + - { $ref: '#/components/schemas/ApiEnvelope' } + - type: object + required: [data] + properties: + data: { $ref: '#/components/schemas/PalletSnapshot' } + PalletSnapshotListResponse: + allOf: + - { $ref: '#/components/schemas/ApiEnvelope' } + - type: object + required: [data] + properties: + data: + type: array + items: { $ref: '#/components/schemas/PalletSnapshot' } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java new file mode 100644 index 000000000..c67e122ce --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalPalletControllerTest.java @@ -0,0 +1,59 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.service.PalletApplicationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InternalPalletControllerTest { + private static final String TOKEN = "test-internal-token"; + private final PalletApplicationService service = mock(PalletApplicationService.class); + private final InternalPalletController controller = new InternalPalletController(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "internalToken", TOKEN); + controller.initializeToken(); + } + + @Test + void readsSnapshotFromPlatformScope() { + PalletRequests.Snapshot expected = snapshot(30L, "TP-001"); + when(service.snapshot(0L, 20L, 30L)).thenReturn(expected); + + assertThat(controller.snapshot(TOKEN, 30L, 20L).data()).isEqualTo(expected); + verify(service).snapshot(0L, 20L, 30L); + } + + @Test + void listsAllWarehouseSnapshotsFromPlatformScope() { + List expected = List.of(snapshot(30L, "TP-001"), snapshot(31L, "TP-002")); + when(service.snapshots(0L, 20L)).thenReturn(expected); + + assertThat(controller.snapshots(TOKEN, 20L).data()).isEqualTo(expected); + verify(service).snapshots(0L, 20L); + } + + @Test + void rejectsInvalidInternalToken() { + assertThatThrownBy(() -> controller.snapshots("invalid", 20L)).isInstanceOf(BizException.class); + verifyNoInteractions(service); + } + + private static PalletRequests.Snapshot snapshot(long id, String code) { + return new PalletRequests.Snapshot(id, code, "STANDARD", new BigDecimal("1.2"), new BigDecimal("8")); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java new file mode 100644 index 000000000..bb6b61e2d --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PalletOpenApiContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PalletOpenApiContractTest { + @Test + void freezesInternalSnapshotPathsAndFields() throws IOException { + String yaml = Files.readString(Path.of("src/main/resources/openapi/pallet-v1.yaml")); + + assertThat(yaml) + .contains("/internal/warehouse/v1/pallets/{palletId}/snapshot:") + .contains("/internal/warehouse/v1/pallets/snapshots:") + .contains("required: [palletId, palletCode, palletType, capacityCbm, tareWeightKg]") + .contains("additionalProperties: false") + .doesNotContain("tenantId:") + .doesNotContain("available:") + .doesNotContain("status:"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java index 7a4c50d72..d6f2b911d 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java @@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialWarehouseResourceSeedSqlContractTest { @Test - void initializesAvailablePalletAndLocationsForBothDomesticWarehouses() throws Exception { + void initializesReusablePalletMasterDataAndAvailableLocationsForBothDomesticWarehouses() throws Exception { assertModuleSeed(Files.readString(Path.of("src", "main", "resources", "db", "phase2-pallet-seed.sql"), StandardCharsets.UTF_8)); assertUnifiedSeed(Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), @@ -19,6 +19,7 @@ class InitialWarehouseResourceSeedSqlContractTest { private static void assertModuleSeed(String sql) { assertSeed(sql, "190000000000000001", "190000000000000002", "190000000000000"); + assertThat(sql).doesNotContain("color,status,remark"); } private static void assertUnifiedSeed(String sql) { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java index 0a90de248..c319729b3 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/WarehouseSchemaContractTest.java @@ -44,21 +44,29 @@ class WarehouseSchemaContractTest { void shouldHandleCase04() throws IOException { String sql = read("phase2-pallet-location-schema.sql"); assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type", - "capacity_cbm", "weight_kg", "color", "status", "remark", "deleted", "version"); - assertThat(sql).contains("uk_pallet_tenant_code (tenant_id, pallet_code)"); + "capacity_cbm", "weight_kg", "color", "remark", "deleted", "version"); + assertThat(tableDefinition(sql, "wh_pallet")) + .contains("uk_pallet_tenant_code (tenant_id, pallet_code)") + .contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)") + .doesNotContain("status VARCHAR") + .doesNotContain("AVAILABLE") + .doesNotContain("RESERVED") + .doesNotContain("OCCUPIED"); assertColumns(sql, "wh_location", "tenant_id", "warehouse_id", "zone_code", "zone_name", "location_code", "location_type", "max_weight_kg", "shared_overflow", "status", "remark", "deleted", "version"); assertColumns(sql, "wh_location_reservation", "idempotency_key", "business_id", "pallet_id", "location_id", "shared_overflow", "reservation_status", "expires_at", - "active_reservation", "active_pallet_reservation", "version"); + "active_reservation", "version"); assertColumns(sql, "wh_location_occupancy", "reservation_id", "business_id", "pallet_id", "location_id", "shared_overflow", "occupancy_status", - "active_location_occupancy", "active_pallet_occupancy", "version"); + "active_location_occupancy", "version"); assertThat(sql).contains("uk_location_tenant_warehouse_code") .contains("uk_location_active_reservation") - .contains("uk_pallet_active_reservation (tenant_id, pallet_id, active_pallet_reservation)") .contains("uk_location_active_occupancy") - .contains("uk_pallet_active_occupancy"); + .doesNotContain("active_pallet_reservation") + .doesNotContain("active_pallet_occupancy") + .doesNotContain("uk_pallet_active_reservation") + .doesNotContain("uk_pallet_active_occupancy"); } @Test @@ -112,6 +120,24 @@ class WarehouseSchemaContractTest { } } + @Test + void unifiedSqlShouldConvergePalletsToReusableMasterData() throws IOException { + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + String palletDefinition = tableDefinition(unified, "wh_pallet"); + + assertThat(palletDefinition) + .contains("idx_pallet_warehouse (tenant_id, warehouse_id, deleted, pallet_code)") + .contains("idx_pallet_type (tenant_id, warehouse_id, pallet_type, capacity_cbm, deleted)") + .doesNotContain("status VARCHAR") + .doesNotContain("idx_pallet_available"); + assertThat(unified) + .contains("ALTER TABLE wh_pallet DROP COLUMN status") + .contains("ALTER TABLE wh_location_reservation DROP COLUMN active_pallet_reservation") + .contains("ALTER TABLE wh_location_occupancy DROP COLUMN active_pallet_occupancy") + .contains("托盘动态资源状态或活动唯一约束仍存在") + .contains("库位活动预占或占用唯一约束不完整"); + } + private static String read(String file) throws IOException { return Files.readString(Path.of("src/main/resources/db", file)); } @@ -133,4 +159,10 @@ class WarehouseSchemaContractTest { int end = sql.indexOf(' ', start); return sql.substring(start, end); } + + private static String tableDefinition(String sql, String table) { + int start = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " ("); + int end = sql.indexOf("ENGINE=InnoDB", start); + return sql.substring(start, end); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java index be0252b42..0761dbb33 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -27,7 +27,7 @@ class PalletApplicationServiceTest { @Test void shouldHandleCase01() { - PalletEntity entity = pallet("OCCUPIED"); + PalletEntity entity = pallet(); when(repository.findOwned(10001, 20, 30)).thenReturn(entity); org.assertj.core.api.Assertions.assertThat(service.detail(10001, 20, 30)) .extracting("id", "palletCode").containsExactly(30L, null); @@ -41,7 +41,7 @@ class PalletApplicationServiceTest { @Test void pagesWithoutTenantFilter() { PalletRequests.PageQuery query = new PalletRequests.PageQuery(1, 20, null, null, null); - when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet("AVAILABLE")), + when(repository.page(null, query)).thenReturn(new PageResult<>(java.util.List.of(pallet()), 1, 20, 1, 1)); org.assertj.core.api.Assertions.assertThat(service.page(null, query).records()) @@ -63,12 +63,29 @@ class PalletApplicationServiceTest { .hasMessageContaining("仅支持国内仓"); } - private static PalletEntity pallet(String status) { + @Test + void returnsReusablePalletSnapshotsWithoutResourceStatus() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setWarehouseType("DOMESTIC"); + when(warehouseRepository.findOwned(0L, 20L)).thenReturn(warehouse); + PalletEntity entity = pallet(); + entity.setPalletCode("TP-001"); + entity.setPalletType("STANDARD"); + entity.setCapacityCbm(new BigDecimal("1.200000")); + entity.setWeightKg(new BigDecimal("8.000")); + when(repository.findAllByWarehouse(0L, 20L)).thenReturn(java.util.List.of(entity)); + + org.assertj.core.api.Assertions.assertThat(service.snapshots(0L, 20L)).singleElement().satisfies(snapshot -> { + org.assertj.core.api.Assertions.assertThat(snapshot.palletId()).isEqualTo(30L); + org.assertj.core.api.Assertions.assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("8.000"); + }); + } + + private static PalletEntity pallet() { PalletEntity entity = new PalletEntity(); entity.setId(30L); entity.setTenantId(10001L); entity.setWarehouseId(20L); - entity.setStatus(status); entity.setVersion(0); return entity; } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java index 81ebd4c76..042cfbc12 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -52,22 +52,35 @@ class PalletLocationAllocationServiceTest { } @Test - void shouldHandleCase01() { + void reusesOnePalletMasterForMultipleAllocations() { when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of()); - when(pallets.findAvailable(10001, 20, "木托盘", new BigDecimal("2"), 1)) + when(pallets.findReusable(10001, 20, "木托盘", new BigDecimal("2"))) .thenReturn(List.of(pallet(30, "TP-001"))); - when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(0); + when(locations.reserveAvailable(any())).thenReturn(List.of( + new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A-01", false, + LocalDateTime.now().plusMinutes(30)), + new LocationDtos.ReservationResult(102, "LR102", 30, "TP-001", 41, "A-02", false, + LocalDateTime.now().plusMinutes(30)))); - assertThatThrownBy(() -> service.reserve(reserve(88))).isInstanceOf(BizException.class); - verify(locations, never()).reserveAvailable(any()); + var request = new PalletAllocationDtos.ReserveRequest(10001, 20, "FIRSTLEG", 88, "RK-001", "measure-001", + 9001, new BigDecimal("4"), new BigDecimal("2"), "木托盘", LocalDateTime.now().plusMinutes(30), true); + assertThat(service.reserve(request).allocations()).hasSize(2) + .extracting(PalletAllocationDtos.AllocationResult::palletId) + .containsOnly(30L); + + org.mockito.ArgumentCaptor command = + org.mockito.ArgumentCaptor.forClass(LocationDtos.ReserveCommand.class); + verify(locations).reserveAvailable(command.capture()); + assertThat(command.getValue().targets()).hasSize(2) + .extracting(LocationDtos.ReservationTarget::palletId) + .containsOnly(30L); } @Test void shouldHandleCase02() { when(locations.findActiveResources(10001, "FIRSTLEG", 99)).thenReturn(List.of()); - when(pallets.findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1)) + when(pallets.findReusable(10001, 20, "STANDARD", new BigDecimal("2"))) .thenReturn(List.of(pallet(30, "TP-001"))); - when(pallets.transitionStatus(10001, 20, 30, List.of("AVAILABLE"), "RESERVED")).thenReturn(1); when(locations.reserveAvailable(any())).thenReturn(List.of( new LocationDtos.ReservationResult(101, "LR101", 30, "TP-001", 40, "A0", true, LocalDateTime.now().plusMinutes(30)))); @@ -77,7 +90,7 @@ class PalletLocationAllocationServiceTest { var result = service.reserve(request); assertThat(result.allocations().get(0).locationCode()).isEqualTo("A0"); - verify(pallets).findAvailable(10001, 20, "STANDARD", new BigDecimal("2"), 1); + verify(pallets).findReusable(10001, 20, "STANDARD", new BigDecimal("2")); } @Test @@ -86,13 +99,12 @@ class PalletLocationAllocationServiceTest { 101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED"); when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource)); when(locations.release(any())).thenReturn(List.of(resource)); - when(pallets.transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE")).thenReturn(1); List released = service.release( new PalletAllocationDtos.ReleaseRequest(10001, "FIRSTLEG", 88, 9001, "入库补偿")); assertThat(released).containsExactly(resource); - verify(pallets).transitionStatus(10001, 20, 30, List.of("RESERVED"), "AVAILABLE"); + verifyNoInteractions(pallets); } @Test @@ -117,7 +129,7 @@ class PalletLocationAllocationServiceTest { assertThat(result.palletCount()).isEqualTo(1); assertThat(result.allocations().get(0).state()).isEqualTo("RESERVED"); - verify(pallets, never()).findAvailable(any(Long.class), any(Long.class), any(), any(), any(Integer.class)); + verify(pallets, never()).findReusable(any(Long.class), any(Long.class), any(), any()); } @Test @@ -127,8 +139,6 @@ class PalletLocationAllocationServiceTest { when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource)); when(locations.confirmOccupied(any())).thenReturn(List.of(new LocationDtos.OccupancyResult( 301L, "OC-301", 101L, 30L, 40L, "A-01", false))); - when(pallets.transitionStatus(10001L, 20L, 30L, List.of("RESERVED"), "OCCUPIED")) - .thenReturn(1); service.confirm(new PalletAllocationDtos.ConfirmRequest(10001L, 20L, "FIRSTLEG", 88L, 9001L, new BigDecimal("1.0"), new BigDecimal("2.0"), 20)); @@ -153,8 +163,6 @@ class PalletLocationAllocationServiceTest { "TP-" + index, locationId, "A-" + index, false, "RESERVED")); occupancyResults.add(new LocationDtos.OccupancyResult(400L + index, "OC-" + index, reservationId, palletId, locationId, "A-" + index, false)); - when(pallets.transitionStatus(10001L, 20L, palletId, List.of("RESERVED"), "OCCUPIED")) - .thenReturn(1); } when(locations.findActiveResources(10001L, "FIRSTLEG", 89L)).thenReturn(resources); when(locations.confirmOccupied(any())).thenReturn(occupancyResults); @@ -198,8 +206,6 @@ class PalletLocationAllocationServiceTest { }); when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation); when(locations.releaseSelectedExactly(any())).thenReturn(List.of(resource)); - when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")) - .thenReturn(1); var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest( 10001L, "FIRSTLEG", 88L, "pda-release-88-202", 9001L, "PDA partial pick", List.of(40L))); @@ -209,7 +215,7 @@ class PalletLocationAllocationServiceTest { assertThat(item.locationId()).isEqualTo(40L); assertThat(item.allocatedPackageCount()).isEqualTo(4); }); - verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"); + verifyNoInteractions(pallets); verify(releaseOperations).complete(org.mockito.ArgumentMatchers.eq(701L), anyString()); } @@ -265,14 +271,12 @@ class PalletLocationAllocationServiceTest { .thenReturn(operation); when(locations.releaseSelectedReservations(any())).thenReturn(List.of(new LocationDtos.ExactSelectiveReleaseResult( 101L, 201L, 1, 20L, 30L, 40L, "A-01", 8))); - when(pallets.transitionStatus(0L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")).thenReturn(1); when(locations.transferToChildByReservation(any())).thenReturn(List.of(new LocationDtos.TransferResult( 102L, "LR102", 202L, 2, 20L, 31L, "TP-002", 41L, "A-02", new BigDecimal("1.200000"), 12, "OCCUPIED"))); PalletEntity pallet = pallet(31L, "TP-002"); - pallet.setStatus("OCCUPIED"); pallet.setCapacityCbm(new BigDecimal("1.500000")); - when(pallets.findOwned(0L, 20L, 31L)).thenReturn(pallet); + when(pallets.findOwnedIncludingDeleted(0L, 20L, 31L)).thenReturn(pallet); var result = service.settle(settlementRequest()); @@ -407,9 +411,8 @@ class PalletLocationAllocationServiceTest { 101L, "LR101", 201L, 1, 20L, 30L, "TP-001", 40L, "A-01", new BigDecimal("1.200000"), 5, "OCCUPIED"))); PalletEntity pallet = pallet(30L, "TP-001"); - pallet.setStatus("OCCUPIED"); pallet.setCapacityCbm(new BigDecimal("1.500000")); - when(pallets.findOwned(10001L, 20L, 30L)).thenReturn(pallet); + when(pallets.findOwnedIncludingDeleted(10001L, 20L, 30L)).thenReturn(pallet); var result = service.transfer(new PalletAllocationDtos.TransferRequest( 10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L, @@ -421,7 +424,6 @@ class PalletLocationAllocationServiceTest { assertThat(item.remainingPackageCount()).isEqualTo(5); assertThat(item.state()).isEqualTo("OCCUPIED"); }); - verify(pallets, never()).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"); verify(transferOperations).complete(org.mockito.ArgumentMatchers.eq(501L), anyString()); } From 2752cfb5b268d866fe863b49f4bfc90e58e72c14 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 17:45:43 +0800 Subject: [PATCH 011/112] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E8=BF=9C=E7=A8=8B=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 8 +- Jenkinsfile.service | 11 +- deploy/scripts/deploy-development-service.sh | 109 +++++++++++++++++++ deploy/scripts/deploy-services.sh | 5 + 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 deploy/scripts/deploy-development-service.sh diff --git a/Jenkinsfile b/Jenkinsfile index c9a2d0c64..b8adbf11d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -52,7 +52,7 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') - choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') + choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') @@ -66,9 +66,9 @@ pipeline { steps { script { String requestedRepository - if (params.ENVIRONMENT == 'test') { - requestedRepository = 'baiyue-firstleg-test' - echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry' + if (['development', 'test'].contains(params.ENVIRONMENT)) { + requestedRepository = "baiyue-firstleg-${params.ENVIRONMENT}" + echo "${params.ENVIRONMENT} 环境使用本地构建镜像,不登录、不推送远程 Registry" } else { requestedRepository = params.IMAGE_REPOSITORY?.trim() if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 9fc9e69b7..438112a60 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -36,7 +36,7 @@ pipeline { parameters { string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') - choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') + choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签') @@ -47,6 +47,9 @@ pipeline { REGISTRY_CREDENTIALS = 'gitea-test-token' MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml' MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' + DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8' + DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy' + DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/application' } stages { @@ -81,7 +84,7 @@ pipeline { if (!imageRepository || imageRepository.contains('://')) { error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://') } - if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) { + if (!['development', 'test'].contains(params.ENVIRONMENT) && !imageRepository.contains('/')) { error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') } @@ -137,8 +140,8 @@ pipeline { sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'" sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'" - if (params.ENVIRONMENT == 'test') { - echo "测试镜像已保存在当前 Docker 节点:${image}" + if (['development', 'test'].contains(params.ENVIRONMENT)) { + echo "${params.ENVIRONMENT} 镜像已保存在当前 Docker 节点:${image}" } else { String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh new file mode 100644 index 000000000..4803077dd --- /dev/null +++ b/deploy/scripts/deploy-development-service.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 + exit 2 +fi + +IMAGE_REPOSITORY="$1" +IMAGE_TAG="$2" +SERVICE_CSV="$3" +ENV_FILE="$4" + +SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" +SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" +REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" +COMPOSE_FILE="deploy/docker-compose.yml" + +if [ ! -f "$SSH_KEY" ]; then + echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 + exit 2 +fi +if [ ! -f "$ENV_FILE" ]; then + echo "开发环境变量文件不存在: $ENV_FILE" >&2 + exit 2 +fi +if [ ! -f "$COMPOSE_FILE" ]; then + echo "Compose 文件不存在: $COMPOSE_FILE" >&2 + exit 2 +fi + +SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') +for service in $SERVICES; do + case "$service" in + *[!A-Za-z0-9_-]*|'') + echo "非法服务名: $service" >&2 + exit 2 + ;; + esac +done + +run_ssh() { + ssh -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + -o ConnectTimeout=10 \ + "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + -o ConnectTimeout=10 \ + "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" + +SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" +REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" +REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" + +run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" + +run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE' +set -eu +remote_dir="$1" +env_tmp="$2" +compose_tmp="$3" +chmod 600 "$env_tmp" +chmod 644 "$compose_tmp" +mv -f "$env_tmp" "$remote_dir/.env" +mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" +REMOTE_PREPARE + +for service in $SERVICES; do + image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG" + docker image inspect "$image" >/dev/null + echo "传输开发镜像: $image" + docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' +done + +run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +image_repository="$2" +image_tag="$3" +service_csv="$4" +services=$(printf '%s' "$service_csv" | tr ',' ' ') + +cd "$remote_dir" +export DEPLOY_ENV=development +export IMAGE_REPOSITORY="$image_repository" +export IMAGE_TAG="$image_tag" + +docker compose \ + --project-name firstleg-development \ + --env-file .env \ + -f docker-compose.yml \ + up -d --remove-orphans --pull never $services + +docker compose \ + --project-name firstleg-development \ + --env-file .env \ + -f docker-compose.yml \ + ps $services +REMOTE_DEPLOY diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index d29243ad8..40bdad59d 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -74,6 +74,11 @@ esac export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG +if [ "$DEPLOY_ENV" = "development" ]; then + exec sh deploy/scripts/deploy-development-service.sh \ + "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" +fi + PULL_POLICY_ARGS="" if [ "$DEPLOY_ENV" = "test" ]; then PULL_POLICY_ARGS="--pull never" From e33b2c8645a0fddee089827c6a293b54b6dd5d46 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 17:53:09 +0800 Subject: [PATCH 012/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbound-weight-ocr-contract.test.mjs | 59 ++++++ .../inbound-weight-ocr.mjs | 173 ++++++++++++++---- 2 files changed, 194 insertions(+), 38 deletions(-) create mode 100644 scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs new file mode 100644 index 000000000..c26281c7d --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + confirmedNetSummary, + normalizedGrossKg, + selectWeightPallet, + workspaceWeightMatchesBaseline, +} from './inbound-weight-ocr.mjs' + +test('selects a stable complete pallet snapshot without status or availability', () => { + const selected = selectWeightPallet([ + { palletId: '20', palletCode: 'P-20', palletType: 'STANDARD', capacityCbm: 2, tareWeightKg: 18.5 }, + { palletId: '3', palletCode: 'P-03', palletType: 'STANDARD', capacityCbm: 1.5, tareWeightKg: 12 }, + ]) + assert.equal(selected.palletId, '3') + assert.equal(selectWeightPallet([]), null) + assert.equal(selectWeightPallet([ + { palletId: '3', palletCode: 'P-03', palletType: 'STANDARD', capacityCbm: 1.5 }, + ]), null) +}) + +test('normalizes gross weight and summarizes confirmed net weight only', () => { + assert.equal(normalizedGrossKg(50.125, 'LB'), 22.736) + assert.equal(normalizedGrossKg(30, 'KG'), 30) + assert.equal(normalizedGrossKg(30, 'UNKNOWN'), null) + assert.deepEqual(confirmedNetSummary({ weightResults: [ + { recognitionStatus: 'CONFIRMED', netWeightKg: 11.5 }, + { recognitionStatus: 'PENDING_CONFIRM', netWeightKg: 99 }, + { recognitionStatus: 'CONFIRMED', netWeightKg: 2.125 }, + ] }), { count: 2, complete: true, total: 13.625 }) +}) + +test('cleanup restores a non-empty carton fallback when there were no confirmed images', () => { + const initial = { actualGrossWeightKg: 54.4, weightResults: [] } + assert.deepEqual(confirmedNetSummary(initial), { count: 0, complete: true, total: 0 }) + assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: 54.4 }, 54.4), true) + assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: null }, 54.4), false) + assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: null }, null), true) +}) + +test('HTTP scenario uses pallet option and net weight contracts without legacy final weight', () => { + const source = readFileSync(new URL('inbound-weight-ocr.mjs', import.meta.url), 'utf8') + assert.match(source, /\/weight-pallet-options\?tenantId=/) + assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath')) + assert.match(source, /form\.append\('file'/) + assert.doesNotMatch(source, /form\.append\('palletId'/) + assert.match(source, /palletId:\s*id\(pallet\.palletId\)/) + assert.match(source, /grossWeightKg/) + assert.match(source, /netWeightKg/) + assert.match(source, /palletTareWeightKg/) + assert.doesNotMatch(source, /finalWeightKg/) + assert.doesNotMatch(source, /pallet\.(?:status|available)/) + assert.doesNotMatch(source, /platform:pallet:list/) + assert.match(source, /verifyDeleted\(context, target, original, baselineWorkspaceWeight\)/) + assert.match(source, /workspaceWeightMatchesBaseline\(cleanup\.workspace, baselineWorkspaceWeight\)/) + assert.doesNotMatch(source, /verifyDeleted\([^\n]+baselineConfirmedNet/) +}) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 96c01e29e..a0962a2d1 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -22,6 +22,47 @@ function closeNumber(actual, expected, tolerance = 0.0005) { return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance } +function palletSnapshotComplete(pallet) { + return Boolean(id(pallet?.palletId)) + && typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0 + && typeof pallet?.palletType === 'string' && pallet.palletType.length > 0 + && Number.isFinite(Number(pallet?.capacityCbm)) && Number(pallet.capacityCbm) > 0 + && Number.isFinite(Number(pallet?.tareWeightKg)) && Number(pallet.tareWeightKg) >= 0 +} + +export function selectWeightPallet(options) { + if (!Array.isArray(options)) return null + return [...options] + .filter(palletSnapshotComplete) + .sort((left, right) => id(left.palletId).localeCompare(id(right.palletId), 'en', { numeric: true }))[0] || null +} + +export function normalizedGrossKg(value, unit) { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return null + const normalizedUnit = String(unit || '').trim().toUpperCase() + if (!['KG', 'KGS', 'LB', 'LBS'].includes(normalizedUnit)) return null + const kg = ['LB', 'LBS'].includes(normalizedUnit) ? numeric * 0.45359237 : numeric + return Math.round(kg * 1000) / 1000 +} + +export function confirmedNetSummary(workspace) { + const confirmed = (workspace?.weightResults || []) + .filter((row) => row?.recognitionStatus === 'CONFIRMED') + const values = confirmed.map((row) => Number(row?.netWeightKg)) + return { + count: confirmed.length, + complete: values.every((value) => Number.isFinite(value) && value > 0), + total: Math.round(values.reduce((sum, value) => sum + value, 0) * 1000) / 1000, + } +} + +export function workspaceWeightMatchesBaseline(workspace, baselineWorkspaceWeight) { + return baselineWorkspaceWeight == null + ? workspace?.actualGrossWeightKg == null + : closeNumber(workspace?.actualGrossWeightKg, baselineWorkspaceWeight, 0.0011) +} + function expectedConflict(response) { return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) } @@ -161,7 +202,11 @@ function deletePath(tenantId, inboundId, resultId, version) { return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}` } -async function createUploadedResult(context, target, image, overrides = {}) { +function palletOptionsPath(tenantId, inboundId) { + return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?tenantId=${encodeURIComponent(tenantId)}` +} + +async function createUploadedResult(context, target, pallet, image, overrides = {}) { const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}` const uploadResponse = await multipart(context, imagePath, image) const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, @@ -195,6 +240,7 @@ async function createUploadedResult(context, target, image, overrides = {}) { const payload = { fileId, + palletId: id(pallet.palletId), ocrProvider: `ADMIN_ACCEPTANCE_${context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-24)}`, ocrRawText: overrides.manual ? null : (recognized.ocrRawText || null), ocrWeightValue: overrides.manual ? null : recognized.weightValue, @@ -215,8 +261,9 @@ async function createUploadedResult(context, target, image, overrides = {}) { const resultId = id(result?.id) context.assert(MODULE, '重量结果 ID、文件 ID 和初始版本契约正确', createResponse, Boolean(resultId) && typeof result?.id === 'string' && typeof result?.fileId === 'string' - && id(result?.fileId) === fileId && Number(result?.version) === 0, - `resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, version=${result?.version ?? 'missing'}`, + && id(result?.fileId) === fileId && id(result?.palletId) === id(pallet.palletId) + && Number(result?.version) === 0, + `resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, palletId=${id(result?.palletId) || 'missing'}, version=${result?.version ?? 'missing'}`, SCENARIO_OPTIONS) if (!resultId) throw new Error(`文件 ${fileId} 已上传但重量结果登记失败,需要人工清理该测试文件`) return { upload: recognized, fileId, result, resultId, version: Number(result.version), deleted: false } @@ -267,14 +314,12 @@ async function deleteResult(context, target, item, name, options = NEW_CONTRACT_ return { response, passed } } -async function verifyDeleted(context, target, item, baselineWeight) { +async function verifyDeleted(context, target, item, baselineWorkspaceWeight) { const check = await loadWorkspace(context, target, '删除后回查重量工作区') const missing = !check.workspace?.weightResults?.some((row) => id(row.id) === item.resultId) - context.assert(MODULE, '删除后结果消失且总毛重恢复基线', check.response, - missing && (baselineWeight == null - ? check.workspace?.actualGrossWeightKg == null - : closeNumber(check.workspace?.actualGrossWeightKg, baselineWeight)), - `resultMissing=${missing}, baseline=${baselineWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`, + context.assert(MODULE, '删除后结果消失且工作区重量恢复运行前基线', check.response, + missing && workspaceWeightMatchesBaseline(check.workspace, baselineWorkspaceWeight), + `resultMissing=${missing}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`, NEW_CONTRACT_OPTIONS) const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}` @@ -313,12 +358,50 @@ export async function run(context) { const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' } const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区') if (!initial.workspace) throw new Error('无法读取测试入库单重量工作区') - const baselineWeight = initial.workspace.actualGrossWeightKg + const baselineWorkspaceWeight = initial.workspace.actualGrossWeightKg + const baselineImageSummary = confirmedNetSummary(initial.workspace) const baselineIds = new Set((initial.workspace.weightResults || []).map((row) => id(row.id))) context.assert(MODULE, '测试入库单身份与基线可追踪', initial.response, id(initial.workspace.inboundId) === target.inboundId - && initial.workspace.processStatus === target.detail.summary.processStatus, - `existingResults=${baselineIds.size}, baselineWeight=${baselineWeight ?? 'null'}`, SCENARIO_OPTIONS) + && initial.workspace.processStatus === target.detail.summary.processStatus + && baselineImageSummary.complete, + `existingResults=${baselineIds.size}, confirmedImageCount=${baselineImageSummary.count}, baselineImageNetTotal=${baselineImageSummary.total}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}`, + NEW_CONTRACT_OPTIONS) + if (!baselineImageSummary.complete) { + const steps = context.steps.slice(scenarioStart) + context.createdResources.inboundWeightOcr.cleaned = true + context.scenarios.push({ + name: SCENARIO, module: MODULE, success: false, + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) + return + } + + const palletOptionsResponse = await context.request( + 'GET', palletOptionsPath(target.tenantId, target.inboundId), + ) + const palletOptions = context.apiSuccess(MODULE, '读取同仓称重托盘候选', palletOptionsResponse, + `inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS) + const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0 + && palletOptions.every(palletSnapshotComplete) + context.assert(MODULE, '托盘候选包含稳定快照字段且无需状态或占用资格', palletOptionsResponse, + palletOptionsComplete, + `count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, fields=palletId/palletCode/palletType/capacityCbm/tareWeightKg`, + NEW_CONTRACT_OPTIONS) + const pallet = palletOptionsComplete ? selectWeightPallet(palletOptions) : null + if (!pallet) { + const steps = context.steps.slice(scenarioStart) + context.createdResources.inboundWeightOcr.cleaned = true + context.scenarios.push({ + name: SCENARIO, module: MODULE, success: false, + deploymentBlocked: steps.some((step) => step.deploymentBlocker), + passed: steps.filter((step) => step.passed).length, + failed: steps.filter((step) => !step.passed).length, + }) + return + } const uploadPath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}` const noAuth = await multipart(context, uploadPath, image, '') @@ -337,7 +420,7 @@ export async function run(context) { expectedRejection(oversized), `HTTP=${oversized.http}, code=${oversized.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) try { - const original = await createUploadedResult(context, target, image, { retry: true }) + const original = await createUploadedResult(context, target, pallet, image, { retry: true }) created.push(original) context.createdResources.inboundWeightOcr.resultIds.push(original.resultId) await verifyImages(context, target, original, image) @@ -359,29 +442,41 @@ export async function run(context) { const recognizedValue = Number(original.result.ocrWeightValue) const recognizedUnit = original.result.ocrWeightUnit - const canConfirmOriginal = Number.isFinite(recognizedValue) && recognizedValue > 0 - && ['KG', 'KGS', 'LB', 'LBS'].includes(String(recognizedUnit || '').trim().toUpperCase()) + const recognizedGrossKg = normalizedGrossKg(recognizedValue, recognizedUnit) + const canConfirmOriginal = recognizedGrossKg != null + && recognizedGrossKg > Number(pallet.tareWeightKg) const confirmPayload = canConfirmOriginal ? { version: original.version } : { - version: original.version, adjustedWeightValue: 1.234, adjustedWeightUnit: 'KG', + version: original.version, + adjustedWeightValue: Math.round((Number(pallet.tareWeightKg) + 1.234) * 1000) / 1000, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} OCR未返回有效重量,人工验收`, } const confirmResponse = await context.request('POST', confirmPath(target.tenantId, target.inboundId, original.resultId), confirmPayload) - const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入实际总毛重', confirmResponse, + const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入货物总重量', confirmResponse, `usedOriginal=${canConfirmOriginal}`, NEW_CONTRACT_OPTIONS) - context.assert(MODULE, '确认结果状态、版本和最终KG正确', confirmResponse, + const confirmedGross = Number(confirmed?.grossWeightKg) + const confirmedTare = Number(confirmed?.palletTareWeightKg) + const confirmedNet = Number(confirmed?.netWeightKg) + context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse, confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1 - && Number(confirmed?.finalWeightKg) > 0, - `status=${confirmed?.recognitionStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}, finalKg=${confirmed?.finalWeightKg ?? 'missing'}`, + && id(confirmed?.palletId) === id(pallet.palletId) + && confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType + && closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm) + && closeNumber(confirmed?.palletTareWeightKg, pallet.tareWeightKg) + && confirmedGross > confirmedTare && confirmedNet > 0 + && closeNumber(confirmedNet, confirmedGross - confirmedTare, 0.0011), + `status=${confirmed?.recognitionStatus ?? 'missing'}, palletId=${id(confirmed?.palletId) || 'missing'}, grossKg=${confirmed?.grossWeightKg ?? 'missing'}, tareKg=${confirmed?.palletTareWeightKg ?? 'missing'}, netKg=${confirmed?.netWeightKg ?? 'missing'}`, NEW_CONTRACT_OPTIONS) original.version = Number(confirmed?.version ?? original.version) - const afterConfirm = await loadWorkspace(context, target, '确认后回查实际总毛重') - const expectedTotal = Number(baselineWeight || 0) + Number(confirmed?.finalWeightKg || 0) - context.assert(MODULE, '确认后实际总毛重按服务端结果重算', afterConfirm.response, - closeNumber(afterConfirm.workspace?.actualGrossWeightKg, expectedTotal), - `baseline=${baselineWeight ?? 'null'}, added=${confirmed?.finalWeightKg ?? 'missing'}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`, + const afterConfirm = await loadWorkspace(context, target, '确认后回查货物总重量') + const confirmedSummary = confirmedNetSummary(afterConfirm.workspace) + context.assert(MODULE, '货物总重量按全部已确认净重由服务端汇总', afterConfirm.response, + confirmedSummary.complete && confirmedSummary.count === baselineImageSummary.count + 1 + && closeNumber(afterConfirm.workspace?.actualGrossWeightKg, confirmedSummary.total, 0.0011), + `confirmedCount=${confirmedSummary.count}, confirmedNetTotal=${confirmedSummary.total}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const staleDelete = await context.request('DELETE', @@ -389,9 +484,9 @@ export async function run(context) { context.assert(MODULE, '旧版本删除返回并发冲突', staleDelete, expectedConflict(staleDelete), `HTTP=${staleDelete.http}, code=${staleDelete.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const deletedOriginal = await deleteResult(context, target, original, '删除本轮已确认图片及重量结果') - if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWeight) + if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWorkspaceWeight) - const manual = await createUploadedResult(context, target, image, { manual: true }) + const manual = await createUploadedResult(context, target, pallet, image, { manual: true }) created.push(manual) context.createdResources.inboundWeightOcr.resultIds.push(manual.resultId) await rejectWithoutMutation(context, target, manual, '零重量确认被拒绝', { @@ -408,7 +503,7 @@ export async function run(context) { adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) - const adjustedValue = 10.125 + const adjustedValue = Math.ceil(((Number(pallet.tareWeightKg) + 5) / 0.45359237) * 1000) / 1000 const adjustedConfirmResponse = await context.request('POST', confirmPath(target.tenantId, target.inboundId, manual.resultId), { version: manual.version, adjustedWeightValue: adjustedValue, adjustedWeightUnit: 'LB', @@ -416,16 +511,19 @@ export async function run(context) { }) const adjusted = context.apiSuccess(MODULE, '人工改重与确认原子完成', adjustedConfirmResponse, `adjusted=${adjustedValue} LB`, NEW_CONTRACT_OPTIONS) - const expectedKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000 - context.assert(MODULE, 'LB 人工改重保留输入并正确换算最终KG', adjustedConfirmResponse, + const expectedGrossKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000 + const expectedNetKg = Math.round((expectedGrossKg - Number(pallet.tareWeightKg)) * 1000) / 1000 + context.assert(MODULE, 'LB 人工改重保留毛重输入并正确换算去皮净重', adjustedConfirmResponse, adjusted?.recognitionStatus === 'CONFIRMED' && Number(adjusted?.version) === manual.version + 2 && closeNumber(adjusted?.adjustedWeightValue, adjustedValue) - && adjusted?.adjustedWeightUnit === 'LB' && closeNumber(adjusted?.finalWeightKg, expectedKg), - `adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, finalKg=${adjusted?.finalWeightKg ?? 'missing'}, expectedKg=${expectedKg}`, + && adjusted?.adjustedWeightUnit === 'LB' + && closeNumber(adjusted?.grossWeightKg, expectedGrossKg, 0.0011) + && closeNumber(adjusted?.netWeightKg, expectedNetKg, 0.0011), + `adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, grossKg=${adjusted?.grossWeightKg ?? 'missing'}, netKg=${adjusted?.netWeightKg ?? 'missing'}, expectedNetKg=${expectedNetKg}`, NEW_CONTRACT_OPTIONS) manual.version = Number(adjusted?.version ?? manual.version) const deletedManual = await deleteResult(context, target, manual, '删除本轮人工改重图片及结果') - if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWeight) + if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWorkspaceWeight) } finally { for (const item of created.filter((value) => !value.deleted)) { await deleteResult(context, target, item, '异常收尾清理本轮重量结果', NEW_CONTRACT_OPTIONS) @@ -433,11 +531,10 @@ export async function run(context) { const cleanup = await loadWorkspace(context, target, '最终清理后回查重量工作区') const createdIds = new Set(created.map((item) => item.resultId)) const leftovers = (cleanup.workspace?.weightResults || []).filter((row) => createdIds.has(id(row.id))) - const restored = leftovers.length === 0 && (baselineWeight == null - ? cleanup.workspace?.actualGrossWeightKg == null - : closeNumber(cleanup.workspace?.actualGrossWeightKg, baselineWeight)) - context.assert(MODULE, '仅清理本轮结果且订单总毛重恢复运行前基线', cleanup.response, - restored, `leftovers=${leftovers.length}, baseline=${baselineWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`, + const restored = leftovers.length === 0 + && workspaceWeightMatchesBaseline(cleanup.workspace, baselineWorkspaceWeight) + context.assert(MODULE, '仅清理本轮结果且工作区重量恢复运行前基线', cleanup.response, + restored, `leftovers=${leftovers.length}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`, NEW_CONTRACT_OPTIONS) context.createdResources.inboundWeightOcr.cleaned = restored } From 9b369784480797515e718b294a7c4045dd711690 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 17:54:53 +0800 Subject: [PATCH 013/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=20SSH=20=E6=8C=87=E7=BA=B9=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/scripts/deploy-development-service.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index 4803077dd..6819b9113 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -13,6 +13,7 @@ ENV_FILE="$4" SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" +SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" COMPOSE_FILE="deploy/docker-compose.yml" @@ -20,6 +21,10 @@ if [ ! -f "$SSH_KEY" ]; then echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 exit 2 fi +if [ ! -f "$SSH_KNOWN_HOSTS" ]; then + echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2 + exit 2 +fi if [ ! -f "$ENV_FILE" ]; then echo "开发环境变量文件不存在: $ENV_FILE" >&2 exit 2 @@ -43,6 +48,7 @@ run_ssh() { ssh -i "$SSH_KEY" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ -o ConnectTimeout=10 \ "$SSH_TARGET" "$@" } @@ -51,6 +57,7 @@ run_scp() { scp -i "$SSH_KEY" \ -o BatchMode=yes \ -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ -o ConnectTimeout=10 \ "$@" } From 10d3390bb10340ce519c76fe850f4f1531060bef Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 18:50:07 +0800 Subject: [PATCH 014/112] =?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 | 8 +- .../PlatformOceanBookingController.java | 6 + .../oceanbooking/dto/OceanBookingDtos.java | 4 +- .../service/OceanBookingService.java | 58 +++++++- .../resources/openapi/ocean-bookings-v1.yaml | 21 ++- ...ormOceanBookingControllerContractTest.java | 14 +- .../service/OceanBookingServiceTest.java | 131 ++++++++++++++++++ .../initialization-baseline-contract.test.mjs | 4 + .../manual-state-loading-pda.mjs | 2 +- .../manual-state-ocean-trucking.mjs | 2 +- .../ocean-booking-channel-contract.test.mjs | 47 +++++++ .../ocean-booking-fixture-contract.test.mjs | 48 ++++++- .../ocean-booking-fixture.mjs | 24 +++- .../phase3-container-loading.mjs | 106 +++++++++----- .../admin-test-env-modules/phase3-loading.mjs | 2 +- .../phase3-order-splitting.mjs | 2 +- .../phase3-producer-chain-contract.test.mjs | 43 +++++- 17 files changed, 461 insertions(+), 61 deletions(-) create mode 100644 scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4cd343579..622883624 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6144,14 +6144,14 @@ ON DUPLICATE KEY UPDATE deleted = 0, updated_at = CURRENT_TIMESTAMP(3); --- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。 +-- 平台托盘初始化主数据;托盘是可复用主数据,不维护动态占用状态。 INSERT INTO wh_pallet ( id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm, - weight_kg, color, status, remark, created_by, updated_by + weight_kg, color, remark, created_by, updated_by ) VALUES - (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0), - (193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0) + (193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0), + (193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0) ON DUPLICATE KEY UPDATE tenant_id = 0, warehouse_id = VALUES(warehouse_id), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index 02269da22..f499f46e4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -16,6 +16,7 @@ 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 @@ -26,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long originWarehouseId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(originWarehouseId)); + } @GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index de546a2ee..06277cdfd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -42,7 +42,6 @@ public final class OceanBookingDtos { public record DraftData(@NotNull @Positive Long customerId, @NotBlank @Size(max = 128) String customerName, @NotBlank @Size(max = 64) String channelCode, - @NotBlank @Size(max = 128) String channelName, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 128) String warehouseName, @Size(max = 128) String billOfLadingNo, @@ -92,4 +91,7 @@ public final class OceanBookingDtos { List containerRequirements, List containerAllocations, List histories) {} + + public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName, + String priceModel, List pricingTaxModes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 9eb7c19fc..f2cbfe7e1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -10,6 +10,9 @@ import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; import com.baiyue.firstleg.service.LoadingPickService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,12 +28,14 @@ import java.time.LocalDateTime; import java.util.HashSet; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Set; @Service public class OceanBookingService { private static final Set PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); @Resource private OceanBookingRepository repository; + @Resource private PriceBookQueryRepository priceBooks; @Resource private ObjectMapper objectMapper; @Resource private LoadingPickService loadingPickService; @@ -40,21 +45,36 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public List channels(long originWarehouseId) { + if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) return List.of(); + List rows = priceBooks.channels(versionId, originWarehouseId); + Map> taxModes = priceBooks.channelTaxModes(versionId, originWarehouseId).stream() + .filter(row -> row != null && StringUtils.hasText(row.getChannelCode()) + && StringUtils.hasText(row.getPricingTaxMode())) + .collect(java.util.stream.Collectors.groupingBy(row -> row.getChannelCode().trim(), + java.util.stream.Collectors.mapping(ChannelTaxModeRow::getPricingTaxMode, + java.util.stream.Collectors.toList()))); + return rows.stream().map(row -> channelOption(row, taxModes)).toList(); + } + public Detail detail(long oceanBookingId) { return detail(required(oceanBookingId)); } @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { if (request.expectedVersion() != 0) throw concurrent(); - validateDraft(request.data()); + validateDraftStructure(request.data()); String action = "CREATE"; String hash = hash(action, null, request); Detail replay = claimOrReplay(null, operatorId, action, key, hash); if (replay != null) return replay; + ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode()); OceanBookingEntity value = new OceanBookingEntity(); value.setId(IdWorker.getId()); value.setBookingNo("OB" + value.getId()); value.setStatus(OceanBookingStatus.DRAFT.name()); - copy(value, request.data()); + copy(value, request.data(), channel.channelName()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); @@ -67,13 +87,14 @@ public class OceanBookingService { @Transactional public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key, UpdateDraftRequest request) { - validateDraft(request.data()); + validateDraftStructure(request.data()); String action = "UPDATE_DRAFT"; Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request)); if (replay != null) return replay; OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion()); requireStatus(before, OceanBookingStatus.DRAFT); - OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data()); + ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode()); + OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data(), channel.channelName()); if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties())); repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements())); @@ -90,6 +111,7 @@ public class OceanBookingService { if (replay != null) return replay; OceanBookingEntity before = requiredVersion(id, request.expectedVersion()); requireStatus(before, OceanBookingStatus.SUBMITTED); + requireActiveChannel(before.getWarehouseId(), before.getChannelCode()); validateConfirmation(before, repository.requirements(id)); if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(), null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); @@ -263,7 +285,7 @@ public class OceanBookingService { private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; } private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } - private void validateDraft(DraftData data) { + private void validateDraftStructure(DraftData data) { if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null || StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName()) || StringUtils.hasText(data.voyageNo())) { @@ -284,9 +306,31 @@ public class OceanBookingService { } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } - private static void copy(OceanBookingEntity v, DraftData d) { + private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) { + if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); + String normalized = normalizeChannel(channelCode); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); + ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream() + .filter(value -> normalized.equals(normalizeChannel(value.getChannelCode()))) + .findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); + if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效"); + return channelOption(row, java.util.Map.of()); + } + + private static ChannelOption channelOption(ChannelRow row, java.util.Map> taxModes) { + return new ChannelOption(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(), + row.getPriceModel(), List.copyOf(taxModes.getOrDefault(row.getChannelCode().trim(), List.of()))); + } + + private static String normalizeChannel(String value) { + if (!StringUtils.hasText(value)) throw badRequest("channelCode不能为空"); + return value.trim(); + } + + private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) { v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); - v.setChannelCode(d.channelCode().trim()); v.setChannelName(d.channelName().trim()); + v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); v.setBillOfLadingNo(null); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 8705ab8b1..f7b375a5b 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -1,6 +1,13 @@ openapi: 3.0.3 info: {title: Phase 3 Ocean Booking API, version: 1.1.0} paths: + /api/platform/v1/ocean-bookings/channels: + get: + summary: List logistics channels from the current ACTIVE price book for an origin warehouse + description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side on draft save.' + parameters: + - {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}} /api/platform/v1/ocean-bookings: post: summary: Create an ocean booking draft with immutable channel and warehouse snapshots @@ -33,12 +40,11 @@ components: data: {$ref: '#/components/schemas/DraftData'} DraftData: type: object - required: [customerId, customerName, channelCode, channelName, warehouseId, warehouseName, parties, containerRequirements] + required: [customerId, customerName, channelCode, warehouseId, warehouseName, parties, containerRequirements] properties: customerId: {type: integer, format: int64, minimum: 1} customerName: {type: string, maxLength: 128} - channelCode: {type: string, maxLength: 64} - channelName: {type: string, maxLength: 128} + channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.} warehouseId: {type: integer, format: int64, minimum: 1} warehouseName: {type: string, maxLength: 128} billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} @@ -57,6 +63,15 @@ components: capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0} maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0} quantity: {type: integer, minimum: 1} + ChannelOption: + type: object + required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes] + properties: + priceBookVersionId: {type: integer, format: int64} + channelCode: {type: string, maxLength: 64} + channelName: {type: string, maxLength: 128} + priceModel: {type: string} + pricingTaxModes: {type: array, items: {type: string}} ActionRequest: type: object required: [expectedVersion] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 030fb265f..133726ac0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -18,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"), @@ -34,7 +37,7 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -49,6 +52,15 @@ class PlatformOceanBookingControllerContractTest { .allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue(); } + @Test + void openApiFreezesServerResolvedChannelContract() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:", + "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") + .contains("required: [customerId, customerName, channelCode, warehouseId, warehouseName") + .doesNotContain("required: [customerId, customerName, channelCode, channelName"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 121390e9d..6aa70a1dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -3,9 +3,14 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequirementRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest; import com.baiyue.firstleg.service.LoadingPickService; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -14,6 +19,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRepository; +import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,7 +30,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -32,6 +41,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; + @Mock private PriceBookQueryRepository priceBooks; @Mock private LoadingPickService loadingPickService; @InjectMocks private OceanBookingService service; @@ -172,12 +182,133 @@ class OceanBookingServiceTest { verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class)); } + @Test + void listsOnlyChannelsFromCurrentActivePriceBook() { + ChannelRow channel = channel("SEA-US", "美西海运"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + when(priceBooks.channelTaxModes(51L, 701L)).thenReturn(List.of()); + + var result = service.channels(701L); + + assertThat(result).singleElement().satisfies(value -> { + assertThat(value.channelCode()).isEqualTo("SEA-US"); + assertThat(value.channelName()).isEqualTo("美西海运"); + assertThat(value.priceBookVersionId()).isEqualTo(51L); + }); + } + + @Test + void freezesAuthoritativeChannelNameWhenCreatingDraft() { + ChannelRow channel = channel("SEA-US", "价格簿美西海运"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + + Detail result = service.create(9L, "admin", "create-channel", new CreateRequest(0, draft())); + + assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US"); + assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运"); + } + + @Test + void replaysCreateAfterChannelIsNoLongerActive() throws Exception { + ChannelRow channel = channel("SEA-US", "价格簿美西海运"); + CreateRequest request = new CreateRequest(0, draft()); + when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + + Detail created = service.create(9L, "admin", "create-replay-channel", request); + + OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); + operation.setRequestHash(hashForCreate(request)); operation.setStatus("SUCCESS"); + operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(created)); + when(repository.claimOperation(isNull(), eq(9L), eq("CREATE"), eq("create-replay-channel"), anyString())) + .thenReturn(false); + when(repository.operation(9L, "CREATE", "create-replay-channel")).thenReturn(operation); + + Detail replay = service.create(9L, "admin", "create-replay-channel", request); + + assertThat(replay.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运"); + verify(priceBooks, times(1)).activeVersionId(); + } + + @Test + void replaysDraftUpdateAfterChannelIsNoLongerActive() throws Exception { + UpdateDraftRequest request = new UpdateDraftRequest(5, draft()); + Detail expected = new Detail(booking("DRAFT", 6), List.of(), List.of(), List.of(), List.of()); + OceanBookingOperationEntity operation = new OceanBookingOperationEntity(); + operation.setRequestHash(hashForUpdate(request)); operation.setStatus("SUCCESS"); + operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected)); + when(repository.claimOperation(eq(1L), eq(9L), eq("UPDATE_DRAFT"), eq("update-replay-channel"), anyString())) + .thenReturn(false); + when(repository.operation(9L, "UPDATE_DRAFT", "update-replay-channel")).thenReturn(operation); + + Detail replay = service.updateDraft(1L, 9L, "admin", "update-replay-channel", request); + + assertThat(replay.oceanBooking().getVersion()).isEqualTo(6); + verifyNoInteractions(priceBooks); + verify(repository, never()).find(anyLong()); + } + + @Test + void rejectsConfirmationWhenFrozenChannelIsNoLongerActive() { + OceanBookingEntity submitted = booking("SUBMITTED", 3); + submitted.setWarehouseId(701L); submitted.setWarehouseName("深圳仓"); submitted.setChannelName("美西海运"); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setCapacityCbm(java.math.BigDecimal.ONE); requirement.setMaxWeightKg(java.math.BigDecimal.ONE); + when(repository.find(1L)).thenReturn(submitted); + when(repository.requirements(1L)).thenReturn(List.of(requirement)); + when(priceBooks.activeVersionId()).thenReturn(null); + + assertThatThrownBy(() -> service.confirm(1L, 9L, "admin", "confirm-channel", new ActionRequest(3, "确认"))) + .isInstanceOf(BizException.class).hasMessageContaining("ACTIVE"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString()); + } + + private static ChannelRow channel(String code, String name) { + ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code); + value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value; + } + + private static DraftData draft() { + List parties = List.of( + new PartyRequest("SHIPPER", "发货人", null, null, null), + new PartyRequest("CONSIGNEE", "收货人", null, null, null), + new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); + List requirements = List.of( + new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1)); + return new DraftData(100L, "客户", "SEA-US", 701L, "深圳仓", null, + 81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶", + null, null, null, null, null, null, parties, requirements); + } + private String hashForSubmit() throws Exception { var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); method.setAccessible(true); return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交")); } + private String hashForCreate(CreateRequest request) throws Exception { + var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); + method.setAccessible(true); + return (String) method.invoke(service, "CREATE", (Long) null, request); + } + + private String hashForUpdate(UpdateDraftRequest request) throws Exception { + var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class); + method.setAccessible(true); + return (String) method.invoke(service, "UPDATE_DRAFT", 1L, request); + } + private static OceanBookingEntity booking(String status, int version) { OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1"); value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 4923994c7..255c546c5 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -26,4 +26,8 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => "'CUS-001'", "'CUSTOMER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'", ]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`) assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/) + + const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0] + assert.ok(palletSeed, 'missing pallet initialization seed') + assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) 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 d9f3ec0ba..b8ebc31a5 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -100,7 +100,7 @@ async function createConfirmedContainer(context, source, label) { const summary = source.detail.summary const draft = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', - channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId, + channelCode: summary.channelCode, warehouseId: source.warehouseId, warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`, bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 099820204..13b063761 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -20,7 +20,7 @@ function oceanPayload(context, template, index) { const source = template.oceanBooking return { expectedVersion: 0, data: { customerId: source.customerId, customerName: source.customerName, - channelCode: source.channelCode, channelName: source.channelName, + channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, shippingCompanyId: source.shippingCompanyId, shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, diff --git a/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs new file mode 100644 index 000000000..17db7be4d --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-channel-contract.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import test from 'node:test' + +const adminPageUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url) +const adminTestUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url) +const adminApiUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', import.meta.url) +const openapiUrl = new URL('../../firstleg/src/main/resources/openapi/', import.meta.url) + +function source(url) { return readFileSync(url, 'utf8') } + +test('Admin ocean booking selects a warehouse-scoped logistics channel instead of accepting free text', () => { + const page = source(adminPageUrl) + const api = source(adminApiUrl) + const pageTest = source(adminTestUrl) + + assert.doesNotMatch(page, /]*v-model="form\.data\.channelCode"/) + assert.doesNotMatch(page, /]*v-model="form\.data\.channelName"/) + assert.match(page, /]*label="物流渠道"[^>]*>[\s\S]*?]*:model-value="form\.data\.channelCode"[^>]*@update:model-value="selectChannel\(/) + assert.match(`${page}\n${api}`, /originWarehouseId/) + assert.match(api, /basePath\s*=\s*['"]\/platform\/v1\/ocean-bookings['"][\s\S]*\$\{basePath\}\/channels/) + assert.doesNotMatch(api, /price-books[\s\S]*for \(const version of versions\)/) + assert.match(page, /shippingCompanyId/) + assert.match(page, /channelCode/) + assert.doesNotMatch(pageTest, /expect\(source\)\.toContain\(['"]v-model="form\.data\.channel(?:Code|Name)"['"]\)/) + assert.match(pageTest, /expect\(source\)\.toContain\(['"]:model-value="form\.data\.channelCode" filterable['"]\)/) + assert.match(pageTest, /expect\(source\)\.not\.toContain\(['"] { + const documents = readdirSync(openapiUrl) + .filter((name) => name.endsWith('.yaml')) + .map((name) => source(new URL(name, openapiUrl))) + const platformChannelContract = documents.find((value) => + /\/api\/platform\/v1\/ocean-bookings\/channels/.test(value) && /originWarehouseId/.test(value) + && /server validates it against the current ACTIVE price book/.test(value)) + assert.ok(platformChannelContract, + 'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI') +}) + +test('container confirmation acceptance locks the selected channel onto the generated container', () => { + const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url)) + assert.match(fixture, /containerAllocations\?\.\[0\]/) + assert.match(fixture, /containerDetail\?\.channelCode === channelCode/) + assert.match(fixture, /selectPlatformChannelIsolationPair/) + assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/) +}) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index a4eff3ad3..1ff5aa980 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' +import { oceanBookingDraft, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => { const steps = [] @@ -31,14 +31,15 @@ test('ocean booking fixture blocks creation instead of inventing missing master assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/) }) -test('shared booking draft carries all required DTO fields without release transport data', () => { +test('shared booking draft omits server-owned channelName and release transport data', () => { const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', { customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运', warehouseId: '41', warehouseName: '深圳仓', }) - for (const key of ['customerId', 'customerName', 'channelCode', 'channelName', 'warehouseId', 'warehouseName']) { + for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId', 'warehouseName']) { assert.ok(draft[key], `${key} is required`) } + assert.equal(draft.channelName, undefined) assert.equal(draft.containerRequirements[0].capacityCbm, 68) assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { @@ -46,6 +47,45 @@ test('shared booking draft carries all required DTO fields without release trans } }) +test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => { + const calls = [] + const context = { + marker: 'ADMIN-RUN-CHANNELS', createdResources: {}, + request: async (method, path) => { + calls.push({ method, path }) + return { body: { code: '200', data: [ + { channelCode: 'SEA-A', channelName: '海运 A' }, + { channelCode: 'SEA-B', channelName: '海运 B' }, + ] } } + }, + apiSuccess: (_module, _name, response) => response.body.data, + assert: (_module, _name, _response, condition) => assert.equal(condition, true), + } + const catalog = await publishPlatformLogisticsChannels(context, '41', 'test') + assert.deepEqual(calls, [{ method: 'GET', path: '/api/platform/v1/ocean-bookings/channels?originWarehouseId=41' }]) + assert.equal(catalog.marker, context.marker) + assert.equal(catalog.originWarehouseId, '41') + assert.deepEqual(catalog.channels.map((value) => value.channelCode), ['SEA-A', 'SEA-B']) + assert.deepEqual(context.createdResources.platformLogisticsChannels, catalog) +}) + +test('all direct ocean booking payload builders omit channelName under the frozen DraftData contract', () => { + const regions = [ + ['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse'], + ['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean'], + ['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate'], + ['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment'], + ['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'"], + ] + for (const [name, start, end] of regions) { + const value = readFileSync(new URL(name, import.meta.url), 'utf8') + const region = value.slice(value.indexOf(start), value.indexOf(end)) + assert.doesNotMatch(region, /channelName\s*:/, name) + } + const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8') + assert.doesNotMatch(container, /data:\s*\{[^}]*channelName\s*:/s) +}) + test('all independent ocean booking producers use the shared master-data contract', () => { for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) { const source = readFileSync(new URL(name, import.meta.url), 'utf8') @@ -53,6 +93,6 @@ test('all independent ocean booking producers use the shared master-data contrac assert.match(source, /oceanBookingDraft\(context, suffix, master/, name) } const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8') - for (const snippet of ['channelCode: channelCode', 'channelName', 'warehouseId', 'warehouseName', + for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName', 'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 38439d421..8582ca4f7 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -1,4 +1,25 @@ function text(value) { return value == null ? '' : String(value).trim() } +const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels' + +export async function publishPlatformLogisticsChannels(context, originWarehouseId, module) { + const warehouseId = text(originWarehouseId) + const sourcePath = `${PLATFORM_CHANNELS}?originWarehouseId=${encodeURIComponent(warehouseId)}` + const response = await context.request('GET', sourcePath) + const data = context.apiSuccess(module, '按起运仓查询平台正式物流渠道', response, + `originWarehouseId=${warehouseId}`, { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true }) + const channels = (Array.isArray(data) ? data : []).flatMap((value) => { + const channelCode = text(value?.channelCode) + const channelName = text(value?.channelName) + return channelCode && channelName ? [{ channelCode, channelName }] : [] + }) + const resource = { marker: context.marker, originWarehouseId: warehouseId, sourcePath, channels } + context.createdResources.platformLogisticsChannels = resource + context.assert(module, '平台渠道目录归属本轮起运仓且不含空渠道', response, + Boolean(warehouseId) && channels.length > 0, + `originWarehouseId=${warehouseId}, channels=${channels.map((value) => value.channelCode).join('|') || 'none'}`, + { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true }) + return resource +} export async function resolveOceanBookingMaster(context, module) { const resources = context.createdResources || {} @@ -32,8 +53,9 @@ export async function resolveOceanBookingMaster(context, module) { } export function oceanBookingDraft(context, suffix, master, labels = {}) { + const { channelName: _readOnlyChannelName, ...writableMaster } = master return { - ...master, + ...writableMaster, shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`, bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`, vesselName: labels.vesselName, voyageNo: labels.voyageNo, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index ae601f063..35088630f 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,3 +1,5 @@ +import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' + const MODULE = 'phase3-container-loading' const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } const CONTAINERS = '/api/platform/v1/containers' @@ -46,6 +48,32 @@ export function selectContainerLoadingFixture(context) { return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0] } +export function selectPlatformChannelIsolationPair(catalog, marker, originWarehouseId, primaryChannelCode) { + const catalogMarker = id(catalog?.marker) + const catalogWarehouseId = id(catalog?.originWarehouseId) + const sourcePath = id(catalog?.sourcePath) + if (catalogMarker !== id(marker) || catalogWarehouseId !== id(originWarehouseId) + || !sourcePath.startsWith('/api/platform/')) { + return { pair: null, reason: 'BLOCKED_FIXTURE: missing current-marker platform channel catalog for origin warehouse' } + } + const channels = [] + const seen = new Set() + for (const value of Array.isArray(catalog?.channels) ? catalog.channels : []) { + const channelCode = id(value?.channelCode).trim() + const channelName = id(value?.channelName).trim() + const status = id(value?.status).trim().toUpperCase() + if (!channelCode || !channelName || (status && status !== 'ACTIVE') || seen.has(channelCode)) continue + seen.add(channelCode) + channels.push({ channelCode, channelName }) + } + const primary = channels.find((value) => value.channelCode === id(primaryChannelCode)) + const foreign = channels.find((value) => value.channelCode !== id(primaryChannelCode)) + if (!primary || !foreign) { + return { pair: null, reason: `BLOCKED_FIXTURE: ACTIVE platform channels=${channels.length}, distinct pair unavailable` } + } + return { pair: { primary, foreign }, reason: null } +} + export async function run(context) { const source = selectContainerLoadingFixture(context) const tenantId = id(source?.tenantId) @@ -70,11 +98,19 @@ export async function run(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) const channelCode = id(inbound?.summary?.channelCode || source?.channelCode) - const channelName = inbound?.summary?.channelName || source?.channelName || channelCode const warehouseName = inbound?.summary?.warehouseName || source?.warehouseName + if (!channelCode || !warehouseName) return + const channelCatalog = await publishPlatformLogisticsChannels(context, warehouseId, MODULE) + const selectedChannel = channelCatalog.channels.find((value) => value.channelCode === channelCode) + context.assert(MODULE, '入库渠道存在于当前起运仓 ACTIVE 平台渠道目录', inboundResponse, + Boolean(selectedChannel), + `originWarehouseId=${warehouseId}, channelCode=${channelCode}, catalog=${channelCatalog.channels.map((value) => value.channelCode).join('|') || 'none'}`, + OPTIONS) + if (!selectedChannel) return + const channelName = selectedChannel.channelName const bookingDraft = { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, - channelCode: channelCode || 'FBA', channelName, warehouseId, warehouseName, + channelCode, warehouseId, warehouseName, shippingCompanyId: null, shippingCompanyName: `验收船公司-${suffix}`, bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', @@ -87,7 +123,6 @@ export async function run(context) { } context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), `channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) - if (!channelCode || !warehouseName) return const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的隔离订舱', bookingCreate, '', OPTIONS) @@ -118,35 +153,42 @@ export async function run(context) { `candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!candidate?.inboundDetailId) return - const foreignChannelCode = `${channelCode}-OTHER`.slice(0, 64) - const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { - expectedVersion: 0, - data: { ...bookingDraft, channelCode: foreignChannelCode, channelName: `异渠道-${suffix}`, remark: `${context.marker}-other-channel` }, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) }) - const foreignBookingCreated = context.apiSuccess(MODULE, '创建异渠道隔离订舱', foreignBookingCreate, '', OPTIONS) - const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id) - if (!foreignBookingId) return - const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, { - expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) }) - const foreignSubmitted = context.apiSuccess(MODULE, '提交异渠道隔离订舱', foreignSubmit, '', OPTIONS) - const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, { - expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker, - }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) }) - const foreignConfirmed = context.apiSuccess(MODULE, '确认异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS) - const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId) - if (!foreignContainerId) return - const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { - bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, - }) - const foreignPlan = context.apiSuccess(MODULE, '创建异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) - const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { - inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, - expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), - }) - context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse, - foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), - `HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) + const channelIsolation = selectPlatformChannelIsolationPair( + context.createdResources?.platformLogisticsChannels, context.marker, warehouseId, channelCode) + if (!channelIsolation.pair) { + context.addStep(MODULE, '两个真实有效渠道的柜次隔离 fixture', null, false, + `${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS) + } else { + const foreignChannel = channelIsolation.pair.foreign + const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { + expectedVersion: 0, + data: { ...bookingDraft, channelCode: foreignChannel.channelCode, remark: `${context.marker}-other-channel` }, + }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) }) + const foreignBookingCreated = context.apiSuccess(MODULE, '创建真实异渠道隔离订舱', foreignBookingCreate, '', OPTIONS) + const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id) + if (!foreignBookingId) return + const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, { + expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) }) + const foreignSubmitted = context.apiSuccess(MODULE, '提交真实异渠道隔离订舱', foreignSubmit, '', OPTIONS) + const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, { + expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) }) + const foreignConfirmed = context.apiSuccess(MODULE, '确认真实异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS) + const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId) + if (!foreignContainerId) return + const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { + bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, + }) + const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) + const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { + inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), + }) + context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse, + foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)), + `primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) + } const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 1f55b18f8..1406170fa 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -120,7 +120,7 @@ export async function run(context) { const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, warehouseName: initial?.summary?.warehouseName, - channelCode: initial?.summary?.channelCode, channelName: initial?.summary?.channelName, + channelCode: initial?.summary?.channelCode, shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index f25ab4945..c0487690b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -120,7 +120,7 @@ function bookingPayload(marker, sequence, source) { const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) return { customerId: source.tenantId, customerName: source.customerName, - channelCode: source.channelCode, channelName: source.channelName, + channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128), diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 356cd1c9b..c9587f339 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test' import { selectCustomsFlow } from './phase3-customs.mjs' import { buildFinalizeResource } from './phase3-loading-finalize.mjs' -import { selectContainerLoadingFixture } from './phase3-container-loading.mjs' +import { selectContainerLoadingFixture, selectPlatformChannelIsolationPair } from './phase3-container-loading.mjs' import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' function source(name) { @@ -141,7 +141,8 @@ test('container loading creates and confirms booking before scoped candidates', "const planResponse = await request(context, 'POST', `${SMART}/plans`", "const assignResponse = await request", ]) - assert.match(value, /channelCode: channelCode/) + assert.match(value, /channelCode, warehouseId, warehouseName/) + assert.match(value, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/) assert.match(value, /warehouseId, warehouseName/) const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\'')) assert.doesNotMatch(draft, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/) @@ -182,12 +183,46 @@ test('container loading keeps an unmatched candidate so marker failure stays obs assert.equal(selected.marker, 'ADMIN-RUN-CAB') }) +test('channel isolation accepts only two active channels from the current platform warehouse catalog', () => { + const selected = selectPlatformChannelIsolationPair({ + marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', + sourcePath: '/api/platform/v1/frozen-channel-contract?originWarehouseId=41', + channels: [ + { channelCode: 'SEA-A', channelName: '海运 A', status: 'ACTIVE' }, + { channelCode: 'SEA-B', channelName: '海运 B', status: 'ACTIVE' }, + { channelCode: 'SEA-C', channelName: '海运 C', status: 'DISABLED' }, + ], + }, 'ADMIN-RUN-AUTO1', '41', 'SEA-A') + assert.deepEqual(selected.pair, { + primary: { channelCode: 'SEA-A', channelName: '海运 A' }, + foreign: { channelCode: 'SEA-B', channelName: '海运 B' }, + }) + assert.equal(selected.reason, null) +}) + +test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second channel', () => { + for (const catalog of [ + { marker: 'OLD-RUN', originWarehouseId: '41', sourcePath: '/api/platform/v1/channels', channels: [] }, + { marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '42', sourcePath: '/api/platform/v1/channels', channels: [] }, + { marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', sourcePath: '/api/merchant/v1/first-leg/pricing/channels', channels: [] }, + { marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', sourcePath: '/api/platform/v1/channels', + channels: [{ channelCode: 'SEA-A', channelName: '海运 A', status: 'ACTIVE' }] }, + ]) { + const selected = selectPlatformChannelIsolationPair(catalog, 'ADMIN-RUN-AUTO1', '41', 'SEA-A') + assert.equal(selected.pair, null) + assert.match(selected.reason, /^BLOCKED_FIXTURE:/) + } + const value = source('./phase3-container-loading.mjs') + assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/) + assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/) +}) + test('container loading enforces channel isolation and stable PDA identity across container number changes', () => { const value = source('./phase3-container-loading.mjs') assertOrder(value, [ '确认订舱并自动生成真实柜次', - '创建异渠道隔离订舱', - '异渠道入库单拒绝装入当前柜次', + '创建真实异渠道隔离订舱', + '两个真实有效渠道之间拒绝跨渠道装柜', '无真实柜号时计划可排柜但 PDA 任务不开放', '在放舱前绑定本轮真实箱号', '订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务', From 7d4256dbd2dcd0c4ae80d8fb0ca6a407b69786e0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 19:09:51 +0800 Subject: [PATCH 015/112] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/README.md | 2 +- scripts/full-regression-entry-contract.test.mjs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 8bc936577..cb33ebcf6 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -45,7 +45,7 @@ export async function run(context) { | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | -| 三期柜次概览、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-container-regression-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 三期柜次概览、渠道隔离、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 882134ce2..25dd920e3 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -28,3 +28,19 @@ test('test-environment full acceptance registers container overview module', () assert.fail('default acceptance module phase3-container-overview is not registered yet') } }) + +test('test-environment full acceptance executes warehouse-scoped booking channel isolation', () => { + const loadingModulePath = resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs') + const fixturePath = resolve(scriptsDir, 'admin-test-env-modules', 'ocean-booking-fixture.mjs') + assert.match(adminAcceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'phase3-container-loading'/s) + assert.ok(existsSync(loadingModulePath), 'phase3-container-loading module is missing') + assert.ok(existsSync(fixturePath), 'shared ocean booking fixture is missing') + + const loadingModule = readFileSync(loadingModulePath, 'utf8') + const fixture = readFileSync(fixturePath, 'utf8') + assert.match(loadingModule, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/) + assert.match(loadingModule, /selectPlatformChannelIsolationPair/) + assert.match(loadingModule, /BLOCKED_FIXTURE/) + assert.match(fixture, /\/api\/platform\/v1\/ocean-bookings\/channels/) + assert.match(fixture, /createdResources\.platformLogisticsChannels/) +}) From 993b3dfe03f025470fa0af4b643d018f4ff51787 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 19:59:26 +0800 Subject: [PATCH 016/112] =?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 | 8 +- .../PlatformOceanBookingController.java | 5 +- .../oceanbooking/dto/OceanBookingDtos.java | 8 +- .../service/OceanBookingService.java | 8 +- .../db/phase3-ocean-booking-schema.sql | 8 +- .../resources/openapi/ocean-bookings-v1.yaml | 8 +- ...ormOceanBookingControllerContractTest.java | 6 +- .../OceanBookingSqlContractTest.java | 4 + .../service/OceanBookingServiceTest.java | 51 +++++++++- scripts/admin-test-env-modules/README.md | 2 +- .../ocean-booking-fixture-contract.test.mjs | 24 ++++- .../ocean-booking-fixture.mjs | 11 ++- .../ocean-booking-prototype-contract.test.mjs | 97 +++++++++++++++++++ .../admin-test-env-modules/ocean-bookings.mjs | 55 ++++++++++- .../full-regression-entry-contract.test.mjs | 17 ++++ 15 files changed, 282 insertions(+), 30 deletions(-) create mode 100644 scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 622883624..2544a3a4e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8663,8 +8663,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', @@ -8677,8 +8677,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照', + service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照', + freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index f499f46e4..16357506b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -37,10 +37,10 @@ public class PlatformOceanBookingController { @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')") @BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false) - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } @PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") @BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") - public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } + public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } @PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')") @BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱") public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); } @@ -68,4 +68,5 @@ public class PlatformOceanBookingController { @PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')") @BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); } + private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 06277cdfd..c7d480efc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -39,8 +39,8 @@ public final class OceanBookingDtos { @NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, @NotNull @Positive Integer quantity) {} - public record DraftData(@NotNull @Positive Long customerId, - @NotBlank @Size(max = 128) String customerName, + public record DraftData(@Positive Long customerId, + @Size(max = 128) String customerName, @NotBlank @Size(max = 64) String channelCode, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 128) String warehouseName, @@ -56,8 +56,8 @@ public final class OceanBookingDtos { @Size(max = 32) String destinationPortCode, @Size(max = 128) String destinationPortName, @Size(max = 128) String placeOfDelivery, - @Size(max = 32) String serviceTerm, - @Size(max = 32) String freightTerm, + @NotBlank @Size(max = 32) String serviceTerm, + @NotBlank @Size(max = 32) String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index f2cbfe7e1..febed8f48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -286,6 +286,12 @@ public class OceanBookingService { private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } private void validateDraftStructure(DraftData data) { + boolean hasCustomerId = data.customerId() != null; + boolean hasCustomerName = StringUtils.hasText(data.customerName()); + if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空"); + if (!StringUtils.hasText(data.serviceTerm()) || !StringUtils.hasText(data.freightTerm())) { + throw badRequest("Service Term和Freight Term不能为空"); + } if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null || StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName()) || StringUtils.hasText(data.voyageNo())) { @@ -329,7 +335,7 @@ public class OceanBookingService { } private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) { - v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); + v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); v.setBillOfLadingNo(null); diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 4e3c9478b..e7ff8ae84 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -5,8 +5,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照', channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照', @@ -23,8 +23,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照', + service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照', + freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名', diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index f7b375a5b..94739036c 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -40,13 +40,15 @@ components: data: {$ref: '#/components/schemas/DraftData'} DraftData: type: object - required: [customerId, customerName, channelCode, warehouseId, warehouseName, parties, containerRequirements] + required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements] properties: - customerId: {type: integer, format: int64, minimum: 1} - customerName: {type: string, maxLength: 128} + customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.} + customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.} channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.} warehouseId: {type: integer, format: int64, minimum: 1} warehouseName: {type: string, maxLength: 128} + serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.} + freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.} billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 133726ac0..6a1e24b47 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -57,7 +57,11 @@ class PlatformOceanBookingControllerContractTest { String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:", "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") - .contains("required: [customerId, customerName, channelCode, warehouseId, warehouseName") + .contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]") + .contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true") + .contains("customerName: {type: string, maxLength: 128, nullable: true") + .contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text") + .contains("freightTerm: {type: string, maxLength: 32, description: Required free-text") .doesNotContain("required: [customerId, customerName, channelCode, channelName"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index f106d67b9..ae728915a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -18,6 +18,10 @@ class OceanBookingSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") + .contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") + .contains("service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照'") + .contains("freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照'") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("REFERENCES fl_container_booking(id)") .doesNotContain("ALTER TABLE fl_container_booking"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 6aa70a1dd..3006b68a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -214,6 +214,49 @@ class OceanBookingServiceTest { assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运"); } + @Test + void savesEmptyCustomerSnapshotAndFreeTextTransportTerms() { + ChannelRow channel = channel("SEA-US", "价格簿美西海运"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + + Detail result = service.create(9L, "admin", "create-without-customer", + new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO"))); + + assertThat(result.oceanBooking().getCustomerId()).isNull(); + assertThat(result.oceanBooking().getCustomerName()).isNull(); + assertThat(result.oceanBooking().getServiceTerm()).isEqualTo("CY-CY"); + assertThat(result.oceanBooking().getFreightTerm()).isEqualTo("NINGBO"); + } + + @Test + void rejectsPartialCustomerSnapshot() { + assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-id", + new CreateRequest(0, draft(100L, null, null, null)))) + .isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空"); + assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-name", + new CreateRequest(0, draft(null, "客户", null, null)))) + .isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空"); + + verifyNoInteractions(priceBooks); + } + + @Test + void rejectsMissingRequiredTransportTerms() { + assertThatThrownBy(() -> service.create(9L, "admin", "missing-service-term", + new CreateRequest(0, draft(100L, "客户", null, "NINGBO")))) + .isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空"); + assertThatThrownBy(() -> service.create(9L, "admin", "blank-freight-term", + new CreateRequest(0, draft(100L, "客户", "CY-CY", " ")))) + .isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空"); + + verifyNoInteractions(priceBooks); + } + @Test void replaysCreateAfterChannelIsNoLongerActive() throws Exception { ChannelRow channel = channel("SEA-US", "价格簿美西海运"); @@ -280,15 +323,19 @@ class OceanBookingServiceTest { } private static DraftData draft() { + return draft(100L, "客户", "CY-CY", "NINGBO"); + } + + private static DraftData draft(Long customerId, String customerName, String serviceTerm, String freightTerm) { List parties = List.of( new PartyRequest("SHIPPER", "发货人", null, null, null), new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); List requirements = List.of( new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1)); - return new DraftData(100L, "客户", "SEA-US", 701L, "深圳仓", null, + return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", null, 81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶", - null, null, null, null, null, null, parties, requirements); + null, serviceTerm, freightTerm, null, null, null, parties, requirements); } private String hashForSubmit() throws Exception { diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index cb33ebcf6..790c5c650 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -47,7 +47,7 @@ export async function run(context) { | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 三期柜次概览、渠道隔离、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | -| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | +| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 1ff5aa980..5a50432e3 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { oceanBookingDraft, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' +import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => { const steps = [] @@ -40,11 +40,31 @@ test('shared booking draft omits server-owned channelName and release transport assert.ok(draft[key], `${key} is required`) } assert.equal(draft.channelName, undefined) + assert.equal(draft.serviceTerm, 'CY-CY') + assert.equal(draft.freightTerm, 'NINGBO') assert.equal(draft.containerRequirements[0].capacityCbm, 68) assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { - assert.equal(draft[key], undefined, `${key} belongs to release, not draft`) + assert.equal(Object.hasOwn(draft, key), false, `${key} belongs to release, not draft`) } + const fixtureSource = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8') + const builderSource = fixtureSource.slice(fixtureSource.indexOf('export function oceanBookingDraft'), fixtureSource.indexOf('export function oceanBookingDraftWithoutCustomer')) + for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.doesNotMatch(builderSource, new RegExp(`${key}\\s*:`), `${key} must not be emitted by the shared draft builder`) + } +}) + +test('shared booking draft can omit customer identity without inventing placeholders', () => { + const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', { + customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运', + warehouseId: '41', warehouseName: '深圳仓', + }) + assert.equal(Object.hasOwn(draft, 'customerId'), false) + assert.equal(Object.hasOwn(draft, 'customerName'), false) + assert.equal(draft.channelCode, 'SEA-US') + assert.equal(draft.warehouseId, '41') + assert.equal(draft.containerRequirements[0].capacityCbm, 68) + assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) }) test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => { diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 8582ca4f7..8879ac72c 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -58,10 +58,8 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { ...writableMaster, shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`, bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`, - vesselName: labels.vesselName, voyageNo: labels.voyageNo, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', - estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt, + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, @@ -69,3 +67,10 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }], } } + +export function oceanBookingDraftWithoutCustomer(context, suffix, master, labels = {}) { + const draft = oceanBookingDraft(context, suffix, master, labels) + delete draft.customerId + delete draft.customerName + return draft +} diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs new file mode 100644 index 000000000..90384dd07 --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const adminPage = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url), 'utf8') +const adminTest = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url), 'utf8') +const adminTypes = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/types.ts', import.meta.url), 'utf8') +const prototype = readFileSync(new URL('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html', import.meta.url), 'utf8') +const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', import.meta.url), 'utf8') +const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', import.meta.url), 'utf8') +const schema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') +const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8') +const bookingModule = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8') +const containerLoading = readFileSync(new URL('./phase3-container-loading.mjs', import.meta.url), 'utf8') + +const releaseFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + +test('prototype evidence keeps service term freight term and optional customer fields', () => { + assert.match(prototype, /主要运输条款 Service Term \*<\/label>