优化
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -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
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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) {
|
||||||
|
|||||||
+12
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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 +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 {
|
||||||
|
|||||||
-70
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
-147
@@ -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}
|
||||||
|
|||||||
+3
-1
@@ -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("待客户确认");
|
||||||
|
|||||||
+17
-28
@@ -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(
|
||||||
|
|||||||
+1
@@ -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"),
|
||||||
|
|||||||
+4
-3
@@ -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)")
|
||||||
|
|||||||
-92
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+64
-1
@@ -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; })
|
||||||
|
|||||||
-150
@@ -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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+46
@@ -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("未匹配有效价格");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
+29
-4
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user