This commit is contained in:
2026-08-14 14:26:56 +08:00
parent 06c264db0a
commit 261a8bf2df
65 changed files with 1040 additions and 670 deletions
@@ -1,8 +1,10 @@
package com.baiyue.audit; package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
@@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/ */
@MapperScan("com.baiyue.audit.mapper") @MapperScan("com.baiyue.audit.mapper")
@EnableScheduling @EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication @SpringBootApplication
public class BaiyueAuditApplication { public class BaiyueAuditApplication {
@@ -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);
}
}
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
.doesNotContain("beforeData", "afterData"); .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<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).hasSize(1);
assertThat(result.total()).isEqualTo(13);
assertThat(result.pages()).isEqualTo(1);
}
@Test @Test
void detailReturnsSanitizedSnapshots() { void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L)); when(repository.findById(8L)).thenReturn(log(8L));
@@ -515,7 +515,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', 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_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', 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) KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; ) 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 ( CREATE TABLE IF NOT EXISTS ord_order_party (
id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID',
tenant_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 '默认价格模型', price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组', 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停用', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', 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) KEY idx_price_channel_status (status, price_model)
) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID', id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID',
warehouse_code VARCHAR(64) 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='价格版本渠道及船期快照'; ) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照';
INSERT INTO fl_price_channel_catalog 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 VALUES
(1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ZBYHK', 'ZBYHWK', 'ACTIVE'),
(2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'BYHK', 'BYHWK', 'ACTIVE'),
(3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'EXXHK', 'EXXHWK', 'ACTIVE'),
(4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'HDHK', 'HDHWK', 'ACTIVE'),
(5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), '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('洛杉矶普船海卡'), 'ACTIVE'), (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'PCHK', 'PCHWK', 'ACTIVE'),
(7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'PCKK', NULL, 'ACTIVE'),
(8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'CPHT', NULL, 'ACTIVE'),
(9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'HPHK', NULL, 'ACTIVE'),
(10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'NPHK', 'NPHWK', 'ACTIVE'),
(11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'SPHK', NULL, 'ACTIVE'),
(12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ZBYHP', NULL, 'ACTIVE'),
(13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'BYHP', NULL, 'ACTIVE'),
(14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'HDHP', NULL, 'ACTIVE'),
(15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'PCHP', NULL, 'ACTIVE')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name), price_model = VALUES(price_model), 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) INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status)
VALUES VALUES
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -43,9 +44,32 @@ public class InternalLoadingEvidenceController {
return ApiResponse.success(service.validate(tenantId, inboundId, fileId)); return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
} }
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
public ResponseEntity<byte[]> 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) { private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); 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;
}
}
} }
@@ -36,7 +36,7 @@ public class PlatformLoadingEvidenceController {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId); requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId); var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) 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() .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content()); .filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
} }
@@ -51,4 +51,13 @@ public class PlatformLoadingEvidenceController {
private static void requirePositive(long... ids) { private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0"); 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;
}
}
} }
@@ -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);
}
}
+1 -1
View File
@@ -39,7 +39,7 @@
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 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 复制财务状态机。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
## 三期装柜与 PDA 资源结算 ## 三期装柜与 PDA 资源结算
@@ -5,6 +5,7 @@ import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.ResponseEntity;
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */ /** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient", @FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
@@ -15,5 +16,10 @@ public interface LoadingEvidenceFileClient {
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long tenantId, @PathVariable long inboundId,
@PathVariable long fileId); @PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
ResponseEntity<byte[]> 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) {} record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
} }
@@ -31,12 +31,12 @@ public class InternalPriceBookController {
} }
@PostMapping("/channels/validate") @PostMapping("/channels/validate")
public ApiResponse<Void> validateChannel( public ApiResponse<PriceBookChannelValidation.Response> validateChannel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PriceBookChannelValidation.Request request) { @Valid @RequestBody PriceBookChannelValidation.Request request) {
requireToken(token); requireToken(token);
service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode()); return ApiResponse.success(service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(),
return ApiResponse.success(null); request.channelCode(), request.destinationType()));
} }
private void requireToken(String token) { private void requireToken(String token) {
@@ -55,6 +55,18 @@ public class PlatformContainerController {
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) { public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id)); FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
} }
@GetMapping("/{id}/pda/photos/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ResponseEntity<byte[]> 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')") @PostMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@@ -79,7 +79,7 @@ public class PlatformFbaPricingController {
@PutMapping("/published") @PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')") @PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId", @BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整已发布入库费用,不回退入库状态") description = "调整初算或已发布入库费用,不回退待分柜状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished( public ApiResponse<FeeSnapshotResponse> adjustPublished(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey, @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@@ -8,11 +8,11 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest; import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity; import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService; 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.service.FirstlegScopeGuard;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -36,15 +36,15 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/inbounds") @RequestMapping("/api/platform/v1/inbounds")
public class PlatformInboundMeasurementReviewController { public class PlatformInboundMeasurementReviewController {
@Resource private MeasurementReviewService service; @Resource private MeasurementReviewService service;
@Resource private AutomaticInboundCompletionService automaticCompletion; @Resource private InitialInboundPricingService initialPricing;
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND", @BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过") resourceId = "#inboundId", description = "测量审核通过")
@Operation(summary = "测量审核通过", @Operation(summary = "测量审核通过",
description = "客服确认测量数据后直接进入待分柜;费用和其他资料可在后续继续维护。") description = "客服确认测量数据后直接进入待分柜,并按已审核测量数据尝试生成初算费用;初算失败不阻塞状态流转。")
@PostMapping("/{inboundId}/measurements/approve") @PostMapping("/{inboundId}/measurements/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')") @PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<FeeSnapshotResponse> approve( public ApiResponse<MeasurementApprovalResponse> approve(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @RequestParam long tenantId,
@PathVariable long inboundId, @PathVariable long inboundId,
@@ -52,12 +52,9 @@ public class PlatformInboundMeasurementReviewController {
@RequestHeader("Idempotency-Key") String idempotencyKey, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) { @Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId); validateScope(user, tenantId);
// 审核服务事务在此调用返回时已提交;随后自动编排从新事务/新查询读取 WAIT_PRICING,
// 避免在未提交的审核事务中读取旧状态。自动流程失败时保留已审核状态,原样返回业务错误,
// 使用同一幂等键重试会从持久化阶段继续推进。
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request); service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(automaticCompletion.completeApproved( return ApiResponse.success(initialPricing.tryCalculate(tenantId, user.userId(), inboundId,
tenantId, inboundId, user.userId(), user.username(), request.measurementId(), request.expectedVersion() + 1)); request.measurementId(), request.expectedVersion() + 1));
} }
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND", @BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
@@ -80,7 +77,7 @@ public class PlatformInboundMeasurementReviewController {
@BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND", @BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量驳回异常放行") resourceId = "#inboundId", description = "测量驳回异常放行")
@Operation(summary = "测量驳回异常放行", @Operation(summary = "测量驳回异常放行",
description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。") description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_SPLIT。")
@PostMapping("/{inboundId}/measurements/exception-release") @PostMapping("/{inboundId}/measurements/exception-release")
@PreAuthorize("hasAuthority('firstleg:inbound:exception-release')") @PreAuthorize("hasAuthority('firstleg:inbound:exception-release')")
public ApiResponse<Void> exceptionRelease( public ApiResponse<Void> exceptionRelease(
@@ -8,29 +8,33 @@ public final class PriceBookChannelCatalog {
private PriceBookChannelCatalog() {} private PriceBookChannelCatalog() {}
public static final List<ChannelDefinition> CHANNELS = List.of( public static final List<ChannelDefinition> CHANNELS = List.of(
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"), fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"),
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"), fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"),
fba("EXX_TRUCK", "EXX海卡", "EXX海卡"), fba("EXX_TRUCK", "EXX海卡", "EXX海卡", "EXXHK", "EXXHWK"),
fba("HEDE_TRUCK", "合德海卡", "合德海卡"), fba("HEDE_TRUCK", "合德海卡", "合德海卡", "HDHK", "HDHWK"),
fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"), fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁", "NYEXX", null),
fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"), fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡", "PCHK", "PCHWK"),
fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"), fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡", "PCKK", null),
fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"), fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁", "CPHT", null),
fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"), fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡", "HPHK", null),
fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"), fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡", "NPHK", "NPHWK"),
fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"), fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡", "SPHK", null),
parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"), parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派", "ZBYHP", null),
parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"), parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派", "BYHP", null),
parcel("HEDE_PARCEL", "合德海派", "海派"), parcel("HEDE_PARCEL", "合德海派", "海派", "HDHP", null),
parcel("OA_PARCEL", "普船海派", "海派") parcel("OA_PARCEL", "普船海派", "海派", "PCHP", null)
); );
private static ChannelDefinition fba(String code, String name, String sheet) { private static ChannelDefinition fba(String code, String name, String sheet,
return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name)); 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) { private static ChannelDefinition parcel(String code, String name, String sheet,
return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name)); String inboundCodePrefix, String overseasInboundCodePrefix) {
return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name),
inboundCodePrefix, overseasInboundCodePrefix);
} }
public record ChannelDefinition( public record ChannelDefinition(
@@ -38,6 +42,8 @@ public final class PriceBookChannelCatalog {
String displayName, String displayName,
PriceModel priceModel, PriceModel priceModel,
String sourceSheetName, String sourceSheetName,
Set<String> aliases Set<String> aliases,
String inboundCodePrefix,
String overseasInboundCodePrefix
) {} ) {}
} }
@@ -16,7 +16,7 @@ public final class ContainerOverviewDtos {
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
Integer version) {} Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, 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<PhotoCount> photoCounts, List<Photo> photos, List<String> exceptionReasons) {} List<PhotoCount> photoCounts, List<Photo> photos, List<String> exceptionReasons) {}
public record PhotoCount(String evidenceType, Integer count) {} public record PhotoCount(String evidenceType, Integer count) {}
public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType, public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType,
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -52,7 +53,9 @@ public final class FbaPricingDtos {
public record CalculateRequest(LocalDateTime pricingAt) {} public record CalculateRequest(LocalDateTime pricingAt) {}
public record AdjustFeeRequest( 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, @NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason @NotBlank @Size(max = 500) String reason
) {} ) {}
@@ -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
) {}
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
public final class PriceBookChannelValidation { public final class PriceBookChannelValidation {
private PriceBookChannelValidation() {} private PriceBookChannelValidation() {}
@@ -10,6 +11,9 @@ public final class PriceBookChannelValidation {
public record Request( public record Request(
@Min(1) long tenantId, @Min(1) long tenantId,
@Min(1) long originWarehouseId, @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) {}
} }
@@ -15,5 +15,7 @@ public class PriceChannelCatalogEntity {
private String priceModel; private String priceModel;
private String sourceSheetName; private String sourceSheetName;
private String aliasesJson; private String aliasesJson;
private String inboundCodePrefix;
private String overseasInboundCodePrefix;
private String status; private String status;
} }
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -53,4 +54,12 @@ public class TruckAppointmentEntity {
private String remark; private String remark;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; 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;
} }
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> { public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update(""" @Update("""
UPDATE fl_inbound_order 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) actual_gross_weight_kg = (SELECT COALESCE(SUM(item.total_weight_kg), 0)
FROM fl_inbound_measurement_item item FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
@@ -21,7 +21,7 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0) volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0)
FROM fl_inbound_measurement_item item FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), 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 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion} AND version = #{expectedVersion}
@@ -45,7 +45,8 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update(""" @Update("""
UPDATE fl_inbound_order 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 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED' AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED'
AND version = #{expectedVersion} AND version = #{expectedVersion}
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; 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.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
@@ -12,6 +13,15 @@ import java.util.List;
/** Dedicated read model for merchant channel selection and reference estimates. */ /** Dedicated read model for merchant channel selection and reference estimates. */
@Mapper @Mapper
public interface PriceBookQueryMapper { 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("""
SELECT id SELECT id
FROM fl_price_book_version FROM fl_price_book_version
@@ -192,15 +192,30 @@ public class FbaPricingRepository {
.orderByAsc(InboundFeeItemEntity::getItemNo)); .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<InboundFeeEntity>() return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId) .eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getId, feeId) .eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeStatus, "SENT") .eq(InboundFeeEntity::getFeeStatus, feeStatus)
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED") .set(InboundFeeEntity::getFeeStatus, "SUPERSEDED")
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now())); .set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
} }
public int markInboundAdjusted(long tenantId, long inboundId, String feeStatus,
String chargeUnit, java.math.BigDecimal chargeableQuantity) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.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, public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
java.math.BigDecimal chargeable) { java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
@@ -46,4 +46,8 @@ public class PriceBookQueryRepository {
return channels(versionId, originWarehouseId).stream() return channels(versionId, originWarehouseId).stream()
.anyMatch(channel -> channelCode.equals(channel.getChannelCode())); .anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
} }
public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) {
return mapper.findChannelPrefix(channelCode);
}
} }
@@ -10,6 +10,9 @@ import java.time.LocalDate;
public final class PriceBookQueryRows { public final class PriceBookQueryRows {
private PriceBookQueryRows() {} private PriceBookQueryRows() {}
public record ChannelPrefixRow(String channelCode, String inboundCodePrefix,
String overseasInboundCodePrefix) {}
@Getter @Getter
@Setter @Setter
public static class ChannelRow { public static class ChannelRow {
@@ -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;
}
}
@@ -8,10 +8,14 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -25,6 +29,8 @@ import java.util.List;
public class ContainerOverviewService { public class ContainerOverviewService {
@Resource private ContainerRepository containers; @Resource private ContainerRepository containers;
@Resource private ContainerOverviewMapper mapper; @Resource private ContainerOverviewMapper mapper;
@Resource private LoadingEvidenceFileClient evidenceFiles;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public Overview overview(long id) { public Overview overview(long id) {
ContainerBookingEntity booking = required(id); ContainerBookingEntity booking = required(id);
@@ -94,18 +100,47 @@ public class ContainerOverviewService {
private PdaSummary pda(ContainerOverviewMapper.PickTaskRow task) { private PdaSummary pda(ContainerOverviewMapper.PickTaskRow task) {
List<Photo> photos = mapper.photos(task.loadingPlanId()).stream().map(value -> new Photo(value.evidenceId(), List<Photo> photos = mapper.photos(task.loadingPlanId()).stream().map(value -> new Photo(value.evidenceId(),
value.tenantId(), value.inboundId(), value.evidenceType(), value.fileId(), value.fileName(), value.tenantId(), value.inboundId(), value.evidenceType(), value.fileId(), value.fileName(),
value.status(), "/api/platform/v1/files/loading-evidence/" + value.tenantId() + '/' value.status(), "/api/platform/v1/containers/" + task.containerBookingId() + "/pda/photos/"
+ value.inboundId() + '/' + value.fileId(), value.uploadedAt())).toList(); + value.fileId() + "/download", value.uploadedAt())).toList();
var counts = new LinkedHashMap<String, Integer>(); var counts = new LinkedHashMap<String, Integer>();
photos.stream().filter(photo -> "ACTIVE".equals(photo.status())) photos.stream().filter(photo -> "ACTIVE".equals(photo.status()))
.forEach(photo -> counts.merge(photo.evidenceType(), 1, Integer::sum)); .forEach(photo -> counts.merge(photo.evidenceType(), 1, Integer::sum));
List<PhotoCount> photoCounts = counts.entrySet().stream() List<PhotoCount> photoCounts = counts.entrySet().stream()
.map(entry -> new PhotoCount(entry.getKey(), entry.getValue())).toList(); .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(), return new PdaSummary(task.id(), task.loadingPlanId(), task.containerBookingId(), task.taskStatus(),
task.version(), mapper.itemCount(task.id()), mapper.completedItemCount(task.id()), workflowStatus, task.version(), itemCount, mapper.completedItemCount(task.id()),
mapper.unresolvedItemCount(task.id()), photoCounts, photos, mapper.pickExceptionReasons(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<byte[]> 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<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) { private List<TimelineEntry> timeline(long id, Long taskId, List<ExceptionHistory> exceptionHistory) {
var result = new ArrayList<TimelineEntry>(); var result = new ArrayList<TimelineEntry>();
mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(), mapper.transport(id).forEach(value -> result.add(new TimelineEntry("TRANSPORT_UPDATE", value.getReason(),
@@ -144,14 +144,18 @@ public class FbaPricingService {
AdjustFeeRequest request, String idempotencyKey) { AdjustFeeRequest request, String idempotencyKey) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId); InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!isCompletedInbound(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) { if (!isCompletedInbound(inbound.getProcessStatus())) {
throw conflict("只能修改已完成入库且已发布的费用"); throw conflict("只能在待分柜阶段修改初算或已发布费用");
} }
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
if (current == null || !"SENT".equals(current.getFeeStatus())) { if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的已发布费用"); 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(); + request.additionalAmount() + ":" + request.reason().trim();
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
@@ -166,30 +170,33 @@ public class FbaPricingService {
adjusted.setId(null); adjusted.setId(null);
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion); adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
adjusted.setFeeVersion(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.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount())); adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount()));
adjusted.setFeeStatus("SENT"); boolean published = "SENT".equals(current.getFeeStatus());
adjusted.setReviewStatus("NOT_REQUIRED"); adjusted.setFeeStatus(published ? "SENT" : "CALCULATED");
adjusted.setReviewStatus(published ? "NOT_REQUIRED" : "NOT_SUBMITTED");
adjusted.setCalculatedBy(operatorId); adjusted.setCalculatedBy(operatorId);
adjusted.setCalculatedAt(now); adjusted.setCalculatedAt(now);
adjusted.setConfirmedBy(operatorId); adjusted.setConfirmedBy(published ? operatorId : null);
adjusted.setConfirmedAt(now); adjusted.setConfirmedAt(published ? now : null);
adjusted.setSentBy(operatorId); adjusted.setSentBy(published ? operatorId : null);
adjusted.setSentAt(now); adjusted.setSentAt(published ? now : null);
adjusted.setCreatedAt(now); adjusted.setCreatedAt(now);
adjusted.setUpdatedAt(now); adjusted.setUpdatedAt(now);
if (repository.supersedeSent(tenantId, current.getId()) != 1) { if (repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) {
throw conflict("费用已被其他操作员修改,请刷新后重试"); throw conflict("费用已被其他操作员修改,请刷新后重试");
} }
repository.insertFee(adjusted); repository.insertFee(adjusted);
BigDecimal quantity = current.getChargeableQuantity(); BigDecimal quantity = adjusted.getChargeableQuantity();
InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费", InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费",
adjusted.getSubtotalAmount(), now); adjusted.getSubtotalAmount(), now);
base.setQuantity(quantity); base.setQuantity(quantity);
base.setUnit(current.getChargeUnit()); base.setUnit(current.getChargeUnit());
base.setUnitPrice(quantity.signum() == 0 ? BigDecimal.ZERO base.setUnitPrice(request.unitPrice().setScale(6, RoundingMode.HALF_UP));
: adjusted.getSubtotalAmount().divide(quantity, 6, RoundingMode.HALF_UP));
base.setAutomaticFlag(false); base.setAutomaticFlag(false);
base.setRemark(request.reason().trim()); base.setRemark(request.reason().trim());
repository.insertItem(base); repository.insertItem(base);
@@ -200,9 +207,15 @@ public class FbaPricingService {
extra.setRemark(request.reason().trim()); extra.setRemark(request.reason().trim());
repository.insertItem(extra); repository.insertItem(extra);
} }
feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now); if (repository.markInboundAdjusted(tenantId, inboundId, adjusted.getFeeStatus(),
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED, adjusted.getChargeUnit(), quantity) != 1) {
tenantId, inboundId, adjusted.getId(), nextVersion, now); 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); complete(token, adjusted.getId(), now);
return response(tenantId, adjusted.getId(), inbound.getProcessStatus()); return response(tenantId, adjusted.getId(), inbound.getProcessStatus());
} }
@@ -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;
/**
* 发送费用并完成入库的状态编排骨架。
*
* <p>本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用;
* 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用
* {@link #completeSendFeeAndInbound} 发布费用并完成入库。</p>
*/
@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, "入库单已被并发更新,请刷新后重试");
}
}
@@ -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);
}
}
@@ -21,7 +21,7 @@ import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
/** /**
* 测量审核应用服务。审核通过后进入内部计费编排,完成费用发布和入库后再进入 WAIT_SPLIT。 * 测量审核应用服务。审核通过后直接进入 WAIT_SPLIT,费用初算和人工维护不阻塞主流程。
*/ */
@Service @Service
public class MeasurementReviewService { public class MeasurementReviewService {
@@ -99,7 +99,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName)); request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", 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)); approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId); idempotency.complete(context, inboundId);
} }
@@ -135,7 +135,7 @@ public class MeasurementReviewService {
} }
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED", repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED",
null, true, request.reason(), true, operatorId, operatorName)); 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)); "MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId); idempotency.complete(context, inboundId);
} }
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel; import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
@@ -47,14 +48,22 @@ public class MerchantPriceBookService {
estimate.amount(), estimate.currency(), DISCLAIMER); 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); requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId(); Long versionId = repository.activeVersionId();
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, String normalized = normalizeChannel(channelCode);
normalizeChannel(channelCode))) { if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) {
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); 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<String, List<PricingTaxMode>> taxModes) { private CurrentChannel toChannel(ChannelRow row, Map<String, List<PricingTaxMode>> taxModes) {
@@ -35,20 +35,21 @@ public class TruckAppointmentService {
@Resource TruckAppointmentFileClient files; @Resource TruckAppointmentFileClient files;
@Resource ObjectMapper objectMapper; @Resource ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") String internalToken; @Value("${baiyue.security.internal-service-token}") String internalToken;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);} public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){required(id);return repo.attachments(id);} public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> 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 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 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 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 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("当前状态不可编辑"); 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 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 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);} @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 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 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 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 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 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);}} 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);}}
@@ -421,6 +421,8 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型', price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组', 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停用', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', 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='价格版本渠道及船期快照'; ) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照';
INSERT INTO fl_price_channel_catalog 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 VALUES
(1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'), (1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ZBYHK', 'ZBYHWK', 'ACTIVE'),
(2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'), (2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'BYHK', 'BYHWK', 'ACTIVE'),
(3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'), (3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'EXXHK', 'EXXHWK', 'ACTIVE'),
(4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'), (4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'HDHK', 'HDHWK', 'ACTIVE'),
(5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), '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('洛杉矶普船海卡'), 'ACTIVE'), (6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'PCHK', 'PCHWK', 'ACTIVE'),
(7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'), (7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'PCKK', NULL, 'ACTIVE'),
(8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'), (8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'CPHT', NULL, 'ACTIVE'),
(9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'), (9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'HPHK', NULL, 'ACTIVE'),
(10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'), (10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'NPHK', 'NPHWK', 'ACTIVE'),
(11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'), (11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'SPHK', NULL, 'ACTIVE'),
(12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'), (12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ZBYHP', NULL, 'ACTIVE'),
(13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'), (13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'BYHP', NULL, 'ACTIVE'),
(14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'), (14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'HDHP', NULL, 'ACTIVE'),
(15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE') (15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'PCHP', NULL, 'ACTIVE')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name), price_model = VALUES(price_model), 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) INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status)
VALUES VALUES
@@ -153,8 +153,8 @@ paths:
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/published: /api/platform/v1/inbounds/{inboundId}/fees/published:
put: put:
summary: 调整已发布费用 summary: 调整初算或已发布费用
description: 需要 firstleg:fba-pricing:adjust 权限;生成新 SENT 版本并将旧版本标记 SUPERSEDED,不回退 WAIT_SPLIT 状态。 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'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -175,9 +175,11 @@ components:
schemas: schemas:
AdjustFeeRequest: AdjustFeeRequest:
type: object type: object
required: [subtotalAmount, additionalAmount, reason] required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason]
properties: 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} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
reason: {type: string, minLength: 1, maxLength: 500} reason: {type: string, minLength: 1, maxLength: 500}
CalculateRequest: CalculateRequest:
@@ -356,7 +356,7 @@ paths:
type: object type: object
required: [boxCode, length, width, height, machineNo, picture] required: [boxCode, length, width, height, machineNo, picture]
properties: 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箱标实际扫描得到,有则原样上传;系统只保存,不生成且不覆盖客户下单值'} shipment_id: {type: string, maxLength: 64, example: 'FBA19L5KMY2CU000002', description: '可选;设备从Amazon FBA箱标实际扫描得到,有则原样上传;系统只保存,不生成且不覆盖客户下单值'}
length: {type: number, format: decimal, minimum: 0.001} length: {type: number, format: decimal, minimum: 0.001}
width: {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: /api/platform/v1/inbounds/{inboundId}/measurements/approve:
post: post:
summary: 审核通过测量 summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限。审核事务提交后服务端自动执行计费、费用发布和入库编排并返回费用快照;若后续编排失败,审核结果保持已提交,使用同一 Idempotency-Key 重试将从持久化阶段继续,接口不会伪造 WAIT_SPLIT。 description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。
parameters: parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, 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} expectedVersion: {type: integer, minimum: 0}
reason: {type: string, maxLength: 1000} reason: {type: string, maxLength: 1000}
responses: 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: /api/platform/v1/inbounds/{inboundId}/measurements/reject:
post: post:
summary: 驳回测量 summary: 驳回测量
@@ -982,7 +982,7 @@ components:
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID}
orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string, nullable: true} 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} customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
customerName: {type: string, minLength: 1} customerName: {type: string, minLength: 1}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} 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} 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} externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001}
warehouseCode: {type: string, maxLength: 50, example: CN-YW-01} 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} inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID}
deviceCode: {type: string, maxLength: 64, example: DEVICE-01} deviceCode: {type: string, maxLength: 64, example: DEVICE-01}
measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'} measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'}
@@ -11,6 +11,12 @@ paths:
parameters: [{$ref: '#/components/parameters/ContainerId'}] 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}}} 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}}} 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}: /api/platform/v1/containers/{id}/documents/{documentType}:
get: get:
operationId: getContainerDocument operationId: getContainerDocument
@@ -50,7 +56,7 @@ components:
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} 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}}} 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}}} 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}}} 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}}} 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}}}
@@ -24,7 +24,7 @@ paths:
get: get:
summary: Query a truck appointment summary: Query a truck appointment
responses: responses:
'200': {description: Appointment detail} '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
put: put:
summary: Update an editable truck appointment with optimistic locking summary: Update an editable truck appointment with optimistic locking
requestBody: requestBody:
@@ -78,16 +78,20 @@ paths:
parameters: [{$ref: '#/components/parameters/AppointmentId'}] parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get: get:
summary: Query attachment references owned by the appointment summary: Query attachment references owned by the appointment
description: 'Requires platform:firstleg:truck-appointment:list.'
responses: responses:
'200': {description: Attachment references} '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}}
post: post:
summary: Reference a file already owned by the appointment customer and business scope 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: 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: /api/platform/v1/truck-appointments/{id}/attachments/upload:
parameters: [{$ref: '#/components/parameters/AppointmentId'}] parameters: [{$ref: '#/components/parameters/AppointmentId'}]
post: post:
summary: Upload and reference a truck appointment attachment summary: Upload and reference a truck appointment attachment
description: 'Requires platform:firstleg:truck-appointment:edit.'
requestBody: requestBody:
required: true required: true
content: content:
@@ -96,7 +100,7 @@ paths:
type: object type: object
required: [file] required: [file]
properties: properties:
attachmentType: {type: string, maxLength: 32} attachmentType: {type: string, maxLength: 64}
file: {type: string, format: binary} file: {type: string, format: binary}
responses: responses:
'200': {description: Uploaded attachment reference} '200': {description: Uploaded attachment reference}
@@ -106,6 +110,7 @@ paths:
- {$ref: '#/components/parameters/AttachmentId'} - {$ref: '#/components/parameters/AttachmentId'}
delete: delete:
summary: Delete an attachment reference owned by the appointment 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: responses:
'200': {description: Reference deleted} '200': {description: Reference deleted}
components: components:
@@ -122,6 +127,26 @@ components:
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} 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}$'}} RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
schemas: 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: ActionRequest:
type: object type: object
required: [expectedVersion] required: [expectedVersion]
@@ -137,3 +162,23 @@ components:
loadingPlanId: {type: integer, format: int64, minimum: 1} loadingPlanId: {type: integer, format: int64, minimum: 1}
plannedLoadingAt: {type: string, format: date-time} plannedLoadingAt: {type: string, format: date-time}
expectedVersion: {type: integer, minimum: 0} 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}
@@ -25,6 +25,7 @@ class ContainerControllerContractTest {
assertPermission("updateTransport", "platform:firstleg:container:edit"); assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("uploadClearance", "platform:firstleg:customs:manage");
assertPermission("overview", "platform:firstleg:container:list"); assertPermission("overview", "platform:firstleg:container:list");
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
assertPermission("markException", "platform:firstleg:container:edit"); assertPermission("markException", "platform:firstleg:container:edit");
assertPermission("cancelException", "platform:firstleg:container:edit"); assertPermission("cancelException", "platform:firstleg:container:edit");
assertPermission("logisticsDocument", "platform:firstleg:container:list"); assertPermission("logisticsDocument", "platform:firstleg:container:list");
@@ -38,7 +39,8 @@ class ContainerControllerContractTest {
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "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"); .doesNotContain("?containerNo", "containerNo in: query");
} }
@@ -36,7 +36,7 @@ class FbaPricingContractTest {
} }
@Test @Test
@DisplayName("契约声明二期边界计费、已发布费用调整和幂等头") @DisplayName("契约声明二期边界计费、人工费用调整和幂等头")
void documentsPricingLifecycle() throws Exception { void documentsPricingLifecycle() throws Exception {
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml") String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
@@ -53,6 +53,10 @@ class FbaPricingContractTest {
.contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置") .contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用") .contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 WAIT_SPLIT 状态") .contains("不回退 WAIT_SPLIT 状态")
.contains("expectedFeeVersion")
.contains("chargeableQuantity")
.contains("unitPrice")
.doesNotContain("required: [subtotalAmount, additionalAmount, reason]")
.doesNotContain("应收") .doesNotContain("应收")
.doesNotContain("应付") .doesNotContain("应付")
.doesNotContain("待客户确认"); .doesNotContain("待客户确认");
@@ -1,10 +1,10 @@
package com.baiyue.firstleg.controller; package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest; import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.dto.MeasurementReviewRequest; 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 com.baiyue.firstleg.service.MeasurementReviewService;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -46,50 +46,39 @@ class InboundMeasurementReviewContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") .contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.contains("firstleg:inbound:exception-release") .contains("firstleg:inbound:exception-release")
.contains("Idempotency-Key") .contains("Idempotency-Key")
.contains("审核事务提交后服务端自动执行计费") .contains("初算异常只返回非阻断提示")
.contains("接口不会伪造 WAIT_SPLIT") .contains("data.processStatus 固定为 WAIT_SPLIT")
.contains("成功 data 为 FeeSnapshotResponse") .contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("nextStatus=WAIT_SPLIT")
.contains("name: inboundId") .contains("name: inboundId")
.contains("schema: {type: string, pattern:") .contains("schema: {type: string, pattern:")
.doesNotContain("待客户确认"); .doesNotContain("待客户确认");
} }
@Test @Test
@DisplayName("审核事务完成后才启动自动编排并返回真实费用快照") @DisplayName("审核先提交待分柜,再执行不阻塞的初算")
void shouldCompleteApprovedReviewAfterReviewTransactionReturns() { void shouldApproveBeforeBestEffortInitialPricing() {
MeasurementReviewService review = mock(MeasurementReviewService.class); MeasurementReviewService review = mock(MeasurementReviewService.class);
AutomaticInboundCompletionService completion = mock(AutomaticInboundCompletionService.class); InitialInboundPricingService initialPricing = mock(InitialInboundPricingService.class);
PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController(); PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController();
ReflectionTestUtils.setField(controller, "service", review); ReflectionTestUtils.setField(controller, "service", review);
ReflectionTestUtils.setField(controller, "automaticCompletion", completion); ReflectionTestUtils.setField(controller, "initialPricing", initialPricing);
AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null, AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null,
"客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review")); "客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review"));
MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过"); MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过");
var fee = mock(com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse.class); var result = new MeasurementApprovalResponse(88L, 30L, "WAIT_SPLIT", "UNPRICED",
when(completion.completeApproved(10001L, 88L, 9L, "客服", 30L, 5)).thenReturn(fee); false, "待人工维护费用", null);
when(initialPricing.tryCalculate(10001L, 9L, 88L, 30L, 5)).thenReturn(result);
var first = controller.approve(user, 10001L, 88L, "approve-1", request); var first = controller.approve(user, 10001L, 88L, "approve-1", request);
var replay = controller.approve(user, 10001L, 88L, "approve-1", request); var replay = controller.approve(user, 10001L, 88L, "approve-1", request);
assertThat(first.data()).isSameAs(fee); assertThat(first.data()).isSameAs(result);
assertThat(replay.data()).isSameAs(fee); assertThat(replay.data()).isSameAs(result);
var ordered = inOrder(review, completion); var ordered = inOrder(review, initialPricing);
ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request); 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(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);
}
@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();
} }
private void assertAuthority(String name, String expression, Class<?>... types) throws Exception { private void assertAuthority(String name, String expression, Class<?>... types) throws Exception {
@@ -23,6 +23,12 @@ class PriceBookContractTest {
@DisplayName("固定十五个渠道且海派使用区域重量模型") @DisplayName("固定十五个渠道且海派使用区域重量模型")
void keepsStableChannelCatalog() { void keepsStableChannelCatalog() {
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15); 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() assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode)) .map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
.containsExactly( .containsExactly(
@@ -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 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 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 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() { @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries( Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -11,10 +11,11 @@ import static org.assertj.core.api.Assertions.assertThat;
class InboundOrderStatusSqlContractTest { class InboundOrderStatusSqlContractTest {
@Test @Test
void advancesThroughPricingBeforeOpeningWaitSplit() { void opensWaitSplitImmediatelyAfterMeasurementApproval() {
assertThat(sql("approveMeasurement")) assertThat(sql("approveMeasurement"))
.contains("process_status = 'WAIT_PRICING'") .contains("process_status = 'WAIT_SPLIT'")
.doesNotContain("inbounded_at ="); .contains("inbounded_at = CURRENT_TIMESTAMP(3)")
.doesNotContain("process_status = 'WAIT_PRICING'");
assertThat(sql("completeInbound")) assertThat(sql("completeInbound"))
.contains("process_status = 'WAIT_SPLIT'") .contains("process_status = 'WAIT_SPLIT'")
.contains("inbounded_at = CURRENT_TIMESTAMP(3)") .contains("inbounded_at = CURRENT_TIMESTAMP(3)")
@@ -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;
}
}
@@ -5,6 +5,10 @@ import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import com.baiyue.firstleg.repository.ContainerRepository; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -13,6 +17,7 @@ import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -20,9 +25,67 @@ import static org.mockito.Mockito.*;
class ContainerOverviewServiceTest { class ContainerOverviewServiceTest {
@Mock ContainerRepository containers; @Mock ContainerRepository containers;
@Mock ContainerOverviewMapper mapper; @Mock ContainerOverviewMapper mapper;
@Mock LoadingEvidenceFileClient evidenceFiles;
@InjectMocks ContainerOverviewService service; @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() { @Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3); ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
@@ -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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
@@ -302,7 +303,9 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; }) doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; })
.when(repository).insertIdempotency(any()); .when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); 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 -> { doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0); InboundFeeEntity fee = invocation.getArgument(0);
fee.setId(502L); fee.setId(502L);
@@ -314,18 +317,80 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 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.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("SENT"); assertThat(response.feeStatus()).isEqualTo("SENT");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inbound.getProcessStatus()).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), verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); 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<InboundFeeEntity> 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() { private void readyRepository() {
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return 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) {
}
}
@@ -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("未匹配有效价格");
}
}
@@ -78,7 +78,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class); ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture()); verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING"); assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING"); assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
} }
@Test @Test
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -41,6 +42,32 @@ class MerchantPriceBookServiceTest {
@MockBean private PriceBookWarehouseClient warehouses; @MockBean private PriceBookWarehouseClient warehouses;
@Resource private MerchantPriceBookService service; @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 @Test
void shouldReturnEmptyChannelsWithoutActiveVersion() { void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null); when(repository.activeVersionId()).thenReturn(null);
@@ -41,6 +41,28 @@ class TruckAppointmentServiceTest {
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @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() { @Test void createRequiresZeroVersionAndInitialisesDraft() {
SaveRequest invalid = request(1); SaveRequest invalid = request(1);
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class); assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
@@ -63,10 +85,13 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L); when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L); when(request.customerId()).thenReturn(7L);
when(request.loadingPlanId()).thenReturn(null); 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); when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request); service.create(7L, "admin", request);
verify(repository).insert(any(TruckAppointmentEntity.class)); ArgumentCaptor<TruckAppointmentEntity> 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)); verify(repository).history(any(TruckAppointmentHistoryEntity.class));
} }
@@ -76,7 +101,7 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L); when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L); when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L); 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); when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(oceanBookings.containerBooking(88L)).thenReturn(container); when(oceanBookings.containerBooking(88L)).thenReturn(container);
@@ -97,7 +122,7 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L); when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L); when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L); 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); when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(oceanBookings.containerBooking(88L)).thenReturn(container); when(oceanBookings.containerBooking(88L)).thenReturn(container);
@@ -10,8 +10,10 @@ import org.springframework.web.bind.annotation.RequestHeader;
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing") url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
public interface FirstlegPricingClient { public interface FirstlegPricingClient {
@PostMapping("/channels/validate") @PostMapping("/channels/validate")
ApiResponse<Void> validateChannel(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<ChannelValidationResponse> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request); @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) {}
} }
@@ -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;
}
@@ -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);
}
@@ -33,6 +33,15 @@ public class OrderRepository {
@Resource private OutboxEventMapper outboxMapper; @Resource private OutboxEventMapper outboxMapper;
@Resource private OrderChildPurgeMapper childPurgeMapper; @Resource private OrderChildPurgeMapper childPurgeMapper;
@Resource private OrderIdempotencyRecordMapper idempotencyMapper; @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 void insertOrder(OrderEntity order) { orderMapper.insert(order); }
public int saveOrder(long tenantId, OrderEntity order) { public int saveOrder(long tenantId, OrderEntity order) {
@@ -33,9 +33,12 @@ import java.time.*;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.time.format.DateTimeFormatter;
@Service @Service
public class OrderApplicationService { 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 OrderRepository repository;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard; @Resource private OrderEligibilityGuard eligibilityGuard;
@@ -61,12 +64,15 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) { private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
productImageGuard.requireOwned(tenantId, request.cargoes()); productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request); 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(); OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId(); long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
order.setInboundCode("RK-" + orderId); order.setStatus(OrderStatus.DRAFT); order.setStatus(OrderStatus.DRAFT);
apply(order, request, snapshots); order.setCreatedBy(userId); order.setUpdatedBy(userId); apply(order, request, snapshots);
order.setInboundCode(nextInboundCode(inboundCodePrefix));
order.setCreatedBy(userId); order.setUpdatedBy(userId);
repository.insertOrder(order); repository.insertOrder(order);
saveChildren(tenantId, userId, order.getId(), request); saveChildren(tenantId, userId, order.getId(), request);
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null); record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null);
@@ -100,11 +106,9 @@ public class OrderApplicationService {
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) { && !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException(ResultCode.CONFLICT, "当前订单状态不允许编辑订单资料"); 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); 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) { if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED); order.setStatus(OrderStatus.SUBMITTED);
} }
@@ -122,23 +126,22 @@ public class OrderApplicationService {
OrderStatus from = order.getStatus(); OrderStatus from = order.getStatus();
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target); from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target);
String inboundCodePrefix = null;
if (target == OrderStatus.SUBMITTED) { if (target == OrderStatus.SUBMITTED) {
validatePersistedPrototypeFields(tenantId, order); validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType()); 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)) { if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
throw new BizException("Reason is required"); throw new BizException("Reason is required");
} }
order.setStatus(target); order.setUpdatedBy(userId); order.setStatus(target); order.setUpdatedBy(userId);
if (target == OrderStatus.SUBMITTED) { 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()); 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 (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
record(tenantId, id, from, target, target.name(), reason, userId, username); record(tenantId, id, from, target, target.name(), reason, userId, username);
@@ -159,6 +162,20 @@ public class OrderApplicationService {
public PageResult<OrderEntity> page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } public PageResult<OrderEntity> 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<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) { public List<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) {
var counts = repository.countByTenantIds(tenantIds); var counts = repository.countByTenantIds(tenantIds);
return tenantIds.stream().distinct() return tenantIds.stream().distinct()
@@ -14,10 +14,12 @@ public class OrderPricingChannelGuard {
@Resource private FirstlegPricingClient client; @Resource private FirstlegPricingClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @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 { try {
ApiResponse<Void> response = client.validateChannel(internalToken, ApiResponse<FirstlegPricingClient.ChannelValidationResponse> response = client.validateChannel(internalToken,
new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode)); new FirstlegPricingClient.ChannelValidationRequest(
tenantId, originWarehouseId, channelCode, destinationType));
if (response == null) { if (response == null) {
throw unavailable(); throw unavailable();
} }
@@ -27,6 +29,11 @@ public class OrderPricingChannelGuard {
? code.message() : response.message(); ? code.message() : response.message();
throw new BizException(code, 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) { } catch (BizException exception) {
throw exception; throw exception;
} catch (FeignException exception) { } catch (FeignException exception) {
+7 -1
View File
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', 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_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', 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) KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; ) 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 ( CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -11,7 +11,7 @@ paths:
summary: 平台统一订单列表 summary: 平台统一订单列表
description: >- description: >-
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+全局票序;有入库单时同时返回
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
@@ -52,7 +52,7 @@ paths:
/api/platform/v1/orders/{orderId}: /api/platform/v1/orders/{orderId}:
get: get:
summary: 平台统一订单详情 summary: 平台统一订单详情
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。 description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;所有状态直接返回 Order 创建时生成的 inboundCode;平台可见内部备注。
parameters: parameters:
- $ref: '#/components/parameters/UnifiedOrderId' - $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId' - $ref: '#/components/parameters/UnifiedTenantId'
@@ -620,7 +620,7 @@ components:
success: {type: boolean} success: {type: boolean}
orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string} 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} errorCode: {type: string}
errorMessage: {type: string} errorMessage: {type: string}
FirstLegOrderCargo: FirstLegOrderCargo:
@@ -721,7 +721,7 @@ components:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT]
UnifiedInboundCode: 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: UnifiedInternalOrderStatus:
name: internalOrderStatus name: internalOrderStatus
in: query in: query
@@ -22,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalTime; import java.time.LocalTime;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
@@ -57,7 +58,7 @@ class OrderApplicationServiceStateTest {
assertThat(orderId).isNotNull(); assertThat(orderId).isNotNull();
verify(eligibility, never()).requireApproved(anyLong()); verify(eligibility, never()).requireApproved(anyLong());
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT
&& ("RK-" + order.getId()).equals(order.getInboundCode()))); && order.getInboundCode().matches("PCHWK\\d{6}0897")));
} }
@Test @Test
@@ -105,13 +106,14 @@ class OrderApplicationServiceStateTest {
OrderRequests.SaveRequest request = request(); OrderRequests.SaveRequest request = request();
when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); 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); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "validate-channel-on-create"); service.createDraft(10001L, 2L, request, "validate-channel-on-create");
var ordered = inOrder(pricing, repository); 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 -> ordered.verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode()) "CH".equals(order.getChannelCode())
&& "TAX_INCLUDED".equals(order.getPricingTaxMode()))); && "TAX_INCLUDED".equals(order.getPricingTaxMode())));
@@ -133,12 +135,13 @@ class OrderApplicationServiceStateTest {
source.amazonWarehouseCode(), source.shipmentId(), source.referenceId()); source.amazonWarehouseCode(), source.shipmentId(), source.referenceId());
when(masterData.validate(10001L, request)).thenReturn(snapshots()); when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); 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); OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "canonical-channel-code"); 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 -> verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode()) "CH".equals(order.getChannelCode())
&& "BUY_DECLARATION".equals(order.getTaxMode()) && "BUY_DECLARATION".equals(order.getTaxMode())
@@ -182,7 +185,7 @@ class OrderApplicationServiceStateTest {
service.updateDraft(10001L, 2L, 1L, request); service.updateDraft(10001L, 2L, 1L, request);
var ordered = inOrder(pricing, repository); 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); ordered.verify(repository).saveOrder(10001L, order);
} }
@@ -244,7 +247,7 @@ class OrderApplicationServiceStateTest {
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
assertThat(order.getInboundCode()).isEqualTo("RK-1"); assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0897");
assertThat(order.getSubmittedAt()).isNotNull(); assertThat(order.getSubmittedAt()).isNotNull();
verify(repository).saveOrder(20002L, order); verify(repository).saveOrder(20002L, order);
} }
@@ -274,7 +277,7 @@ class OrderApplicationServiceStateTest {
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo())); when(repository.findCargo(20002L, 1L)).thenReturn(List.of(completeCargo()));
when(repository.findParties(20002L, 1L)).thenReturn(List.of()); when(repository.findParties(20002L, 1L)).thenReturn(List.of());
doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择")) 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), OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)); mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
@@ -486,13 +489,24 @@ class OrderApplicationServiceStateTest {
verify(repository, never()).saveOrder(anyLong(), any()); 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, private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility,
OrderMasterDataGuard masterData) { OrderMasterDataGuard masterData) {
OrderApplicationService service = springService; OrderApplicationService service = springService;
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility); ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility);
ReflectionTestUtils.setField(service, "masterDataGuard", masterData); 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, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper()); ReflectionTestUtils.setField(service, "json", new ObjectMapper());
return service; return service;
@@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -30,21 +31,22 @@ class OrderPricingChannelGuardTest {
@Test @Test
void forwardsCustomerOriginAndChannelToFirstleg() { void forwardsCustomerOriginAndChannelToFirstleg() {
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(ApiResponse.success(null)); 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); verify(client).validateChannel("token", request);
} }
@Test @Test
void removedOrForgedChannelKeepsFirstlegBusinessError() { 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( when(client.validateChannel("token", request)).thenReturn(
ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null)); ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null));
assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE")) assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE", "FBA"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessage("所选渠道已下线,请重新选择") .hasMessage("所选渠道已下线,请重新选择")
.extracting(exception -> ((BizException) exception).getResultCode()) .extracting(exception -> ((BizException) exception).getResultCode())
@@ -53,18 +55,18 @@ class OrderPricingChannelGuardTest {
@Test @Test
void nullResponseMapsToServiceUnavailable() { 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); when(client.validateChannel("token", request)).thenReturn(null);
assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK", "FBA"));
} }
@Test @Test
void downstreamHttpFailureMapsToServiceUnavailable() { 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)); 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) { private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) {
@@ -4,6 +4,20 @@ const CONTAINERS = '/api/platform/v1/containers'
function id(value) { return value == null ? '' : String(value) } 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) { export async function run(context) {
const loadingRequested = context.options?.modules?.includes('phase3-loading') === true const loadingRequested = context.options?.modules?.includes('phase3-loading') === true
const fixture = loadingRequested const fixture = loadingRequested
@@ -39,7 +53,7 @@ export async function run(context) {
if (overview?.pda) { if (overview?.pda) {
const expectsPhotos = fixture === context.createdResources?.phase3Loading const expectsPhotos = fixture === context.createdResources?.phase3Loading
&& Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0 && 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) const missingFields = requiredFields.filter((field) => overview.pda[field] === undefined)
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse, context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
missingFields.length === 0 missingFields.length === 0
@@ -48,10 +62,29 @@ export async function run(context) {
&& Array.isArray(overview.pda.exceptionReasons) && Array.isArray(overview.pda.exceptionReasons)
&& (!expectsPhotos || overview.pda.photos.length > 0), && (!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) `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()) { for (const [index, photo] of overview.pda.photos.entries()) {
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse, context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined), ['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined),
`fileId=${id(photo.fileId)}`, OPTIONS) `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)
} }
} }
@@ -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/)
})
@@ -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) 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' }) 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) 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) 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) 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)