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] =?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)