Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb60517ba | ||
|
|
b4ff441a70 | ||
|
|
9fb39604c6 | ||
|
|
6b2df287bd | ||
|
|
7a9ab27fd2 | ||
|
|
4d12b56cac | ||
|
|
91b6574d53 | ||
|
|
e5f9cdcf01 | ||
|
|
b072c0a884 | ||
|
|
f00d0e16cf | ||
|
|
af5cf1e3bd | ||
|
|
35a1b835fe | ||
|
|
9f6e43ef3f |
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.common.core.number;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.function.ToLongFunction;
|
||||||
|
|
||||||
|
/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */
|
||||||
|
public final class DailyDocumentNumber {
|
||||||
|
public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
|
||||||
|
/** 工具类不允许实例化。 */
|
||||||
|
private DailyDocumentNumber() {}
|
||||||
|
|
||||||
|
/** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */
|
||||||
|
public static String next(String prefix, Clock clock, ToLongFunction<String> sequences) {
|
||||||
|
requirePrefix(prefix);
|
||||||
|
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
|
||||||
|
String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
long value = sequences.applyAsLong(prefix + ":" + dateText);
|
||||||
|
return format(prefix, date, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */
|
||||||
|
public static String format(String prefix, LocalDate date, long value) {
|
||||||
|
requirePrefix(prefix);
|
||||||
|
if (value < 1 || value > 9999) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败");
|
||||||
|
}
|
||||||
|
return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE)
|
||||||
|
+ String.format(Locale.ROOT, "%04d", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型只能使用两至四位大写字母。 */
|
||||||
|
private static void requirePrefix(String prefix) {
|
||||||
|
if (prefix == null || !prefix.matches("[A-Z]{2,4}")) {
|
||||||
|
throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.common.core.number;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class DailyDocumentNumberTest {
|
||||||
|
/** 午夜前后取号使用北京时间日期,与主机和 Clock 的时区无关。 */
|
||||||
|
@Test void rollsAtShanghaiMidnightAndKeepsPrefixesIndependent() {
|
||||||
|
var values = new HashMap<String, Long>();
|
||||||
|
java.util.function.ToLongFunction<String> allocate = key -> values.merge(key, 1L, Long::sum);
|
||||||
|
var before = Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"), ZoneOffset.UTC);
|
||||||
|
var after = Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"), ZoneId.of("America/Los_Angeles"));
|
||||||
|
assertThat(DailyDocumentNumber.next("FL", before, allocate)).isEqualTo("FL202610190001");
|
||||||
|
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200001");
|
||||||
|
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200002");
|
||||||
|
assertThat(DailyDocumentNumber.next("ZGRK", after, allocate)).isEqualTo("ZGRK202610200001");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字符集和流水上限为硬边界,未知输入不能生成近似号码。 */
|
||||||
|
@Test void rejectsInvalidPrefixAndSequence() {
|
||||||
|
for (String prefix : new String[]{"F", "ABCDE", "Fl", "F1", " FL", ""}) {
|
||||||
|
assertThatThrownBy(() -> DailyDocumentNumber.format(prefix, LocalDate.of(2026, 10, 20), 1))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
for (long value : new long[]{-1, 0, 10000}) {
|
||||||
|
assertThatThrownBy(() -> DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), value))
|
||||||
|
.isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 9999)).isEqualTo("FL202610209999");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局 Locale 不得让数字变为本地化字符。 */
|
||||||
|
@Test void alwaysUsesAsciiDigits() {
|
||||||
|
Locale old = Locale.getDefault();
|
||||||
|
try {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("ar"));
|
||||||
|
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 1)).isEqualTo("FL202610200001");
|
||||||
|
} finally { Locale.setDefault(old); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private MeterRegistry meterRegistry;
|
private MeterRegistry meterRegistry;
|
||||||
|
|
||||||
|
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||||
@ExceptionHandler(BizException.class)
|
@ExceptionHandler(BizException.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
||||||
|
if (exception.getResultCode().httpStatus().is5xxServerError()) {
|
||||||
|
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
|
||||||
|
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
|
||||||
|
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
|
||||||
|
}
|
||||||
return failure(exception.getResultCode(), exception.getMessage(), request);
|
return failure(exception.getResultCode(), exception.getMessage(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput;
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
import org.springframework.core.MethodParameter;
|
import org.springframework.core.MethodParameter;
|
||||||
import org.springframework.validation.BeanPropertyBindingResult;
|
import org.springframework.validation.BeanPropertyBindingResult;
|
||||||
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
|
||||||
|
@Test
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
|
||||||
|
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
|
||||||
|
request.setQueryString("keyword=private-query");
|
||||||
|
request.addHeader("X-Internal-Service-Token", "private-service-token");
|
||||||
|
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
|
||||||
|
exception.initCause(new IllegalStateException("缺少尾程来源"));
|
||||||
|
|
||||||
|
var response = handler.handleBusiness(exception, request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo("S0503");
|
||||||
|
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
|
||||||
|
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
|
||||||
|
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
|
||||||
|
.doesNotContain("private-query", "private-service-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预期的业务冲突不应升级为系统错误日志。 */
|
||||||
|
@Test
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
|
||||||
|
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
|
||||||
|
new MockHttpServletRequest());
|
||||||
|
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
|||||||
|
|
||||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||||
|---|---|---|---:|
|
|---|---|---|---:|
|
||||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
|
||||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
|
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 |
|
||||||
|
|
||||||
|
|
||||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||||
@@ -84,7 +84,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
|||||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||||
|
|
||||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||||
|
|
||||||
## 平台管理员密码
|
## 平台管理员密码
|
||||||
|
|
||||||
@@ -180,3 +180,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
|||||||
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
||||||
|
|
||||||
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
||||||
|
|
||||||
|
## 统一业务单号
|
||||||
|
|
||||||
|
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
|
||||||
|
|||||||
@@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result (
|
|||||||
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
||||||
|
|
||||||
CREATE TABLE fl_document_sequence (
|
CREATE TABLE fl_document_sequence (
|
||||||
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
||||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
||||||
|
|
||||||
CREATE TABLE fl_fee_item (
|
CREATE TABLE fl_fee_item (
|
||||||
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
||||||
@@ -4856,7 +4856,7 @@ CREATE TABLE fin_document_sequence (
|
|||||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE fin_fee_source (
|
CREATE TABLE fin_fee_source (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
@@ -4983,7 +4983,7 @@ CREATE TABLE fin_receipt_bill (
|
|||||||
CREATE TABLE fin_recharge_request (
|
CREATE TABLE fin_recharge_request (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||||
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
||||||
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
||||||
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
||||||
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||||
@@ -5234,7 +5234,7 @@ CREATE TABLE fin_wallet_hold (
|
|||||||
CREATE TABLE fin_wallet_ledger (
|
CREATE TABLE fin_wallet_ledger (
|
||||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||||
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
||||||
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||||
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||||
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||||
@@ -6848,6 +6848,12 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
|||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
|
||||||
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
|
||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
@@ -8377,4 +8383,20 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
|||||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
|
USE baiyue_order;
|
||||||
|
CREATE TABLE ord_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||||
|
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
|
USE baiyue_warehouse;
|
||||||
|
CREATE TABLE wh_document_sequence (
|
||||||
|
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||||
|
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||||
|
PRIMARY KEY (sequence_key),
|
||||||
|
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
||||||
|
|||||||
@@ -1860,12 +1860,6 @@ CREATE TABLE ll_order_manual_node_history (
|
|||||||
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
|
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
|
||||||
|
|
||||||
CREATE TABLE ll_order_no_daily_sequence (
|
|
||||||
business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
|
||||||
last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号',
|
|
||||||
PRIMARY KEY (business_date)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
|
|
||||||
|
|
||||||
CREATE TABLE ll_dispatch_daily_sequence (
|
CREATE TABLE ll_dispatch_daily_sequence (
|
||||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||||
@@ -2078,6 +2072,7 @@ CREATE TABLE ll_overseas_procurement_fee (
|
|||||||
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
||||||
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
||||||
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
||||||
|
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
|
||||||
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
||||||
id bigint NOT NULL COMMENT '主键ID',
|
id bigint NOT NULL COMMENT '主键ID',
|
||||||
order_id bigint NOT NULL COMMENT '采购订单ID',
|
order_id bigint NOT NULL COMMENT '采购订单ID',
|
||||||
@@ -2099,6 +2094,7 @@ CREATE TABLE ll_overseas_procurement_fee (
|
|||||||
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 '更新时间',
|
||||||
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
||||||
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
||||||
|
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
||||||
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
||||||
@@ -2369,14 +2365,6 @@ CREATE TABLE ll_pda_stocktake_task (
|
|||||||
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
|
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
|
||||||
|
|
||||||
CREATE TABLE ll_procurement_daily_sequence (
|
|
||||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
|
||||||
business_date date NOT NULL COMMENT '北京时间业务日期',
|
|
||||||
sequence_value int NOT NULL COMMENT '客户当日已分配最大序号',
|
|
||||||
PRIMARY KEY (customer_id,business_date),
|
|
||||||
CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999))
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列';
|
|
||||||
|
|
||||||
CREATE TABLE ll_receiving_container_operation (
|
CREATE TABLE ll_receiving_container_operation (
|
||||||
id bigint NOT NULL COMMENT '柜号收货操作ID',
|
id bigint NOT NULL COMMENT '柜号收货操作ID',
|
||||||
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
|
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
|
||||||
@@ -4831,12 +4819,6 @@ CREATE TABLE ll_appointment_work_history (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
||||||
|
|
||||||
-- 海外业务短单号分配时钟。
|
-- 海外业务短单号分配时钟。
|
||||||
CREATE TABLE ll_compact_number_clock (
|
|
||||||
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
|
||||||
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
|
||||||
PRIMARY KEY (number_key)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
|
||||||
|
|
||||||
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
|
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
|
||||||
USE baiyue_lastleg;
|
USE baiyue_lastleg;
|
||||||
CREATE TABLE ll_fleet_pickup_plan (
|
CREATE TABLE ll_fleet_pickup_plan (
|
||||||
@@ -4995,18 +4977,12 @@ CREATE TABLE ll_dispatch_transport_command (
|
|||||||
PRIMARY KEY(dispatch_no,request_key)
|
PRIMARY KEY(dispatch_no,request_key)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||||
|
|
||||||
-- Region: OVERSEAS
|
USE baiyue_lastleg;
|
||||||
-- Schema: baiyue_lastleg
|
CREATE TABLE ll_document_sequence (
|
||||||
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
|
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||||
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
|
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||||
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
|
PRIMARY KEY (sequence_key),
|
||||||
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
|
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
||||||
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||||
EXECUTE looseboard_demand_stmt;
|
|
||||||
DEALLOCATE PREPARE looseboard_demand_stmt;
|
|
||||||
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
|
|
||||||
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
|
||||||
EXECUTE looseboard_demand_stmt;
|
|
||||||
DEALLOCATE PREPARE looseboard_demand_stmt;
|
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 236 AS initialized_tables;
|
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables;
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@
|
|||||||
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
||||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||||
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
|
|||||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||||
String reviewStatus, String sourceStatus,
|
String reviewStatus, String sourceStatus,
|
||||||
boolean statementGenerated) {}
|
boolean statementGenerated) {}
|
||||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
|
||||||
BigDecimal amount, String method, String referenceNo,
|
BigDecimal amount, String method, String referenceNo,
|
||||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.finance.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
||||||
|
@Mapper
|
||||||
|
public interface DocumentSequenceMapper {
|
||||||
|
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
||||||
|
@Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
||||||
|
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
||||||
|
int allocate(@Param("key") String key);
|
||||||
|
|
||||||
|
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
||||||
|
@Select("SELECT LAST_INSERT_ID()")
|
||||||
|
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||||
|
Long allocatedValue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.finance.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
|
||||||
|
@Mapper
|
||||||
|
public interface FinanceSettlementDetailMapper {
|
||||||
|
String SUMMARY_SELECT =
|
||||||
|
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||||
|
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||||
|
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||||
|
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||||
|
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||||
|
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||||
|
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||||
|
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||||
|
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||||
|
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||||
|
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||||
|
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||||
|
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||||
|
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||||
|
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||||
|
/** 读取平台结算主单及来源摘要。 */
|
||||||
|
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
|
||||||
|
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
|
||||||
|
/** 读取该客户该结算的费用明细。 */
|
||||||
|
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
|
||||||
|
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||||
|
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||||
|
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
|
||||||
|
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||||
|
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||||
|
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
|
||||||
|
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
|
||||||
|
/** 读取该客户结算修订历史。 */
|
||||||
|
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
|
||||||
|
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
|
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
||||||
|
@Repository
|
||||||
|
public class DocumentSequenceRepository {
|
||||||
|
private final DocumentSequenceMapper mapper;
|
||||||
|
|
||||||
|
/** 只访问所属服务的编号表。 */
|
||||||
|
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
||||||
|
|
||||||
|
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public long next(String key) {
|
||||||
|
mapper.allocate(key);
|
||||||
|
Long value = mapper.allocatedValue();
|
||||||
|
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
|
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
|
||||||
|
@Repository
|
||||||
|
public class FinanceSettlementDetailRepository {
|
||||||
|
private final FinanceSettlementDetailMapper mapper;
|
||||||
|
/** 注入结算详情持久化入口。 */
|
||||||
|
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
|
||||||
|
/** 保留原数据库列名不区分大小写的读取语义。 */
|
||||||
|
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
||||||
|
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
|
||||||
|
}
|
||||||
|
/** 读取平台结算主单及来源摘要。 */
|
||||||
|
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
|
||||||
|
/** 读取该客户该结算的费用明细。 */
|
||||||
|
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
|
||||||
|
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||||
|
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
|
||||||
|
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||||
|
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
|
||||||
|
/** 读取该客户结算修订历史。 */
|
||||||
|
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
|
||||||
|
}
|
||||||
+14
-35
@@ -1,55 +1,34 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.number.DailyDocumentNumber;
|
||||||
|
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||||
@Component
|
@Component
|
||||||
public class FinanceDocumentNumberGenerator {
|
public class FinanceDocumentNumberGenerator {
|
||||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
private static final Set<String> PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY");
|
||||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
private final DocumentSequenceRepository sequences;
|
||||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
|
|
||||||
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** 正式运行使用统一业务时区。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
|
public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
||||||
this(jdbc, Clock.system(BUSINESS_ZONE));
|
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
/** 固定时钟用于跨日及前缀独立性验证。 */
|
||||||
this.jdbc = jdbc;
|
FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
||||||
|
this.sequences = sequences;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(propagation = Propagation.MANDATORY)
|
/** 只为本服务已登记的业务类型分配号码。 */
|
||||||
public String next(String prefix) {
|
public String next(String prefix) {
|
||||||
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
||||||
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
|
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
||||||
}
|
|
||||||
LocalDate sequenceDate = LocalDate.now(clock);
|
|
||||||
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
|
||||||
jdbc.update(
|
|
||||||
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
|
|
||||||
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
|
||||||
"current_value=LAST_INSERT_ID(current_value+1)",
|
|
||||||
sequenceKey);
|
|
||||||
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
|
||||||
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
|
||||||
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
|
|
||||||
}
|
|
||||||
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
|
||||||
String.format(Locale.ROOT, "%04d", sequence);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
|||||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import feign.FeignException;
|
import feign.FeignException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -437,7 +438,14 @@ public class FinancePhase1OperationService {
|
|||||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||||
|
private String hash(Object value) {
|
||||||
|
try {
|
||||||
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(value);
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
||||||
|
}
|
||||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||||
private static Long jsonLong(JsonNode node, String field) {
|
private static Long jsonLong(JsonNode node, String field) {
|
||||||
JsonNode value = node.path(field);
|
JsonNode value = node.path(field);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
@@ -306,7 +307,14 @@ public class FinanceService {
|
|||||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||||
|
private String hash(Object request) {
|
||||||
|
try {
|
||||||
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(request == null ? List.of() : request);
|
||||||
|
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
|
||||||
|
}
|
||||||
|
|
||||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -44,44 +45,29 @@ import java.util.UUID;
|
|||||||
public class FinanceSettlementService {
|
public class FinanceSettlementService {
|
||||||
private static final String CNY = "CNY";
|
private static final String CNY = "CNY";
|
||||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||||
private static final String SETTLEMENT_SUMMARY_SELECT =
|
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
||||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
|
||||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
|
||||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
|
||||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
|
||||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
|
||||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
|
||||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
|
||||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
|
||||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
|
||||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
|
||||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
|
||||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
|
||||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
|
||||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
|
||||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceVoucherValidator voucherValidator;
|
private final FinanceVoucherValidator voucherValidator;
|
||||||
private final FinanceSupplierValidator supplierValidator;
|
private final FinanceSupplierValidator supplierValidator;
|
||||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||||
FinanceSupplierValidator supplierValidator) {
|
|
||||||
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||||
FinanceSupplierValidator supplierValidator,
|
FinanceSupplierValidator supplierValidator,
|
||||||
FinanceDocumentNumberGenerator documentNumbers) {
|
FinanceDocumentNumberGenerator documentNumbers,
|
||||||
|
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
this.details = details;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.voucherValidator = voucherValidator;
|
this.voucherValidator = voucherValidator;
|
||||||
this.supplierValidator = supplierValidator;
|
this.supplierValidator = supplierValidator;
|
||||||
this.documentNumbers = documentNumbers;
|
this.documentNumbers = documentNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
||||||
CreateReceivableSettlementRequest request) {
|
CreateReceivableSettlementRequest request) {
|
||||||
@@ -185,33 +171,21 @@ public class FinanceSettlementService {
|
|||||||
return getSettlement(settlementId);
|
return getSettlement(settlementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
||||||
public SettlementDetail getSettlement(long settlementId) {
|
public SettlementDetail getSettlement(long settlementId) {
|
||||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||||
Map<String, Object> row = one(
|
List<Map<String, Object>> records = details.summary(settlementId);
|
||||||
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||||
"结算单不存在", settlementId);
|
SettlementSummary summary = summary(records.get(0));
|
||||||
SettlementSummary summary = summary(row);
|
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||||
List<SettlementLine> lines = jdbc.queryForList(
|
|
||||||
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
|
||||||
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
|
|
||||||
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
|
||||||
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
|
||||||
.stream().map(source -> line(source, summary)).toList();
|
.stream().map(source -> line(source, summary)).toList();
|
||||||
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||||
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
|
.stream().map(this::transaction).toList());
|
||||||
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
|
|
||||||
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
|
||||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||||
transactions.addAll(jdbc.queryForList(
|
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
||||||
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
|
.stream().map(this::transaction).toList());
|
||||||
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
|
||||||
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
|
|
||||||
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
|
|
||||||
}
|
}
|
||||||
List<Map<String, Object>> audits = jdbc.queryForList(
|
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
|
||||||
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
|
|
||||||
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
|
||||||
summary.customerId(), settlementId);
|
|
||||||
List<SettlementRevision> revisions = new ArrayList<>();
|
List<SettlementRevision> revisions = new ArrayList<>();
|
||||||
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
||||||
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
||||||
@@ -479,8 +453,9 @@ public class FinanceSettlementService {
|
|||||||
text(row, "status"), true);
|
text(row, "status"), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
|
||||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
|
||||||
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
||||||
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
||||||
text(row, "created_by_name"),
|
text(row, "created_by_name"),
|
||||||
@@ -521,15 +496,18 @@ public class FinanceSettlementService {
|
|||||||
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */
|
||||||
private String transactionNo() {
|
private String transactionNo() {
|
||||||
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
return documentNumbers.next("PAY");
|
||||||
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try {
|
try {
|
||||||
|
String canonical = objectMapper.writer()
|
||||||
|
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
||||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
throw error("FIN_001", "幂等请求哈希失败");
|
throw error("FIN_001", "幂等请求哈希失败");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
|||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -694,8 +695,9 @@ public class FinanceWalletService {
|
|||||||
timestamp(rs.getTimestamp("reviewed_at")));
|
timestamp(rs.getTimestamp("reviewed_at")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||||
}
|
}
|
||||||
private String fileHash(MultipartFile file) {
|
private String fileHash(MultipartFile file) {
|
||||||
|
|||||||
+9
-6
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
|||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -46,10 +47,7 @@ public class ReconciliationStatementService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||||
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
FinanceDocumentNumberGenerator documentNumbers) {
|
FinanceDocumentNumberGenerator documentNumbers) {
|
||||||
@@ -58,6 +56,7 @@ public class ReconciliationStatementService {
|
|||||||
this.documentNumbers = documentNumbers;
|
this.documentNumbers = documentNumbers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
||||||
validateCreate(request);
|
validateCreate(request);
|
||||||
@@ -523,9 +522,13 @@ public class ReconciliationStatementService {
|
|||||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
try {
|
||||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(value);
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LocalDateTime localTime(Object value) {
|
private static LocalDateTime localTime(Object value) {
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS fin_document_sequence (
|
|||||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
) ENGINE=InnoDB COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
@@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
|||||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||||
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
||||||
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
|
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
|
||||||
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
|
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
|
||||||
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
|
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||||
@@ -549,7 +549,7 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
|||||||
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
||||||
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||||
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||||
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
|
|||||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
||||||
"operatorId", "operatorName");
|
"operatorId", "operatorName");
|
||||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
||||||
"createdBy", "createdByName");
|
"createdBy", "createdByName", "transactionNo");
|
||||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
||||||
"operatorId", "operatorName");
|
"operatorId", "operatorName");
|
||||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
|
|||||||
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
|
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
|
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
|
||||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||||
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
|
Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
|
||||||
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
|
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
|
||||||
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
|
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
|
||||||
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
||||||
@@ -258,6 +258,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
||||||
.isEqualTo("PAYMENT");
|
.isEqualTo("PAYMENT");
|
||||||
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
||||||
|
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
|
||||||
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
|
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
|
||||||
any(), any(), any(), any(), any(), any(), any(), any());
|
any(), any(), any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
@@ -522,6 +523,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
.isEqualTo("RECEIPT");
|
.isEqualTo("RECEIPT");
|
||||||
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
||||||
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
||||||
|
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -620,10 +622,18 @@ class FinanceSettlementBehaviorTest {
|
|||||||
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
|
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
|
||||||
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
|
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
|
||||||
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
|
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
|
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
|
||||||
|
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
|
||||||
|
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
|
||||||
|
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
|
||||||
|
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
|
||||||
|
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
|
||||||
|
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
|
||||||
|
return jdbc.queryForList(sql, invocation.getArguments());
|
||||||
|
});
|
||||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FinanceSupplierValidator supplierValidator() {
|
private static FinanceSupplierValidator supplierValidator() {
|
||||||
@@ -656,7 +666,7 @@ class FinanceSettlementBehaviorTest {
|
|||||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
||||||
long customerId = ((Number) settlement.get("tenant_id")).longValue();
|
long customerId = ((Number) settlement.get("tenant_id")).longValue();
|
||||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||||
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||||
|
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
|
||||||
|
import org.apache.ibatis.mapping.Environment;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||||
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FinanceSettlementDetailMapperTest {
|
||||||
|
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
|
||||||
|
@Test
|
||||||
|
void paymentNumbersRemainScopedToCustomerAndSettlement() {
|
||||||
|
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||||
|
var jdbc = new JdbcTemplate(source);
|
||||||
|
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
|
||||||
|
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
|
||||||
|
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
|
||||||
|
configuration.addMapper(FinanceSettlementDetailMapper.class);
|
||||||
|
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||||
|
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
|
||||||
|
var rows = repository.payments(10, 20);
|
||||||
|
assertThat(rows).hasSize(1);
|
||||||
|
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
|
||||||
|
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
|
||||||
|
assertThat(repository.payments(11, 21)).isEmpty();
|
||||||
|
assertThat(repository.payments(10, 22)).hasSize(1);
|
||||||
|
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.baiyue.finance;
|
||||||
|
|
||||||
|
import com.baiyue.finance.service.FinanceSettlementService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import com.baiyue.finance.service.FinanceWalletService;
|
||||||
|
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||||
|
import com.baiyue.finance.service.FinanceService;
|
||||||
|
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FinanceSettlementRequestHashTest {
|
||||||
|
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
|
||||||
|
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
|
||||||
|
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
|
||||||
|
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
|
||||||
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("amount", 190); body.put("expectedVersion", 0);
|
||||||
|
Map<String, Object> first = new LinkedHashMap<>();
|
||||||
|
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
|
||||||
|
first.put("customerId", 10L); first.put("body", body);
|
||||||
|
Map<String, Object> reversedBody = new LinkedHashMap<>();
|
||||||
|
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
|
||||||
|
Map<String, Object> second = new LinkedHashMap<>();
|
||||||
|
second.put("body", reversedBody); second.put("customerId", 10L);
|
||||||
|
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
|
||||||
|
|
||||||
|
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
|
||||||
|
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||||
|
assertThat(reordered).isEqualTo(original);
|
||||||
|
reversedBody.put("amount", 191);
|
||||||
|
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||||
|
assertThat(changed).isNotEqualTo(original);
|
||||||
|
second.put("customerId", 11L); reversedBody.put("amount", 190);
|
||||||
|
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||||
|
assertThat(otherCustomer).isNotEqualTo(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
||||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
var result = target.create(71L, "create-key", request);
|
var result = target.create(71L, "create-key", request);
|
||||||
|
|
||||||
assertThat(result.statementId()).isEqualTo(501L);
|
assertThat(result.statementId()).isEqualTo(501L);
|
||||||
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
|
assertThat(result.statementNo()).isEqualTo("REC202608180001");
|
||||||
assertThat(result.currency()).isEqualTo("CNY");
|
assertThat(result.currency()).isEqualTo("CNY");
|
||||||
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
||||||
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
||||||
@@ -132,7 +132,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
||||||
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
||||||
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
||||||
@@ -144,7 +144,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "submit-key",
|
var result = target.action(71L, 501L, "submit-key",
|
||||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
||||||
@@ -164,7 +164,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "approve-key",
|
var result = target.action(71L, 501L, "approve-key",
|
||||||
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
||||||
@@ -177,7 +177,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "issue-key",
|
var result = target.action(71L, 501L, "issue-key",
|
||||||
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
||||||
@@ -202,7 +202,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
||||||
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
|
assertThatThrownBy(() -> serviceWithNumber(crossCustomer).confirmForCustomer(
|
||||||
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
||||||
@@ -251,7 +251,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
||||||
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
|
assertThatThrownBy(() -> serviceWithNumber(draft).confirmForCustomer(
|
||||||
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -271,7 +271,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
});
|
});
|
||||||
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
||||||
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
|
assertThatThrownBy(() -> serviceWithNumber(stale).confirmForCustomer(
|
||||||
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -288,7 +288,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of(
|
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of(
|
||||||
"request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L,
|
"request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L,
|
||||||
"updated_at", LocalDateTime.now())));
|
"updated_at", LocalDateTime.now())));
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
||||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
||||||
@@ -300,7 +300,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
||||||
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
||||||
@@ -318,7 +318,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
||||||
.thenReturn(new BigDecimal("280.00"));
|
.thenReturn(new BigDecimal("280.00"));
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
||||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
||||||
@@ -342,7 +342,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
return List.of(mapper.mapRow(rs, 0));
|
return List.of(mapper.mapRow(rs, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
||||||
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
||||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
@@ -355,7 +355,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "void-key",
|
var result = target.action(71L, 501L, "void-key",
|
||||||
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
||||||
@@ -372,7 +372,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
||||||
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
||||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
||||||
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -384,7 +384,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
||||||
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
||||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
||||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||||
|
|
||||||
var result = target.action(71L, 501L, "reopen-key",
|
var result = target.action(71L, 501L, "reopen-key",
|
||||||
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
||||||
@@ -399,7 +399,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
||||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||||
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
||||||
ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService firstService = serviceWithNumber(firstJdbc);
|
||||||
|
|
||||||
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
||||||
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
||||||
@@ -412,7 +412,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
||||||
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
||||||
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
||||||
new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()),
|
serviceWithNumber(replayJdbc),
|
||||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
||||||
.isEqualTo(501L);
|
.isEqualTo(501L);
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
||||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
||||||
new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()),
|
serviceWithNumber(processingJdbc),
|
||||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
||||||
.isInstanceOf(FinanceEventException.class)
|
.isInstanceOf(FinanceEventException.class)
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||||
@@ -431,7 +431,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
||||||
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
||||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
||||||
ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules());
|
ReconciliationStatementService recoveryService = serviceWithNumber(recoveryJdbc);
|
||||||
|
|
||||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
||||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
||||||
@@ -556,7 +556,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
ResultSet rs = mock(ResultSet.class);
|
ResultSet rs = mock(ResultSet.class);
|
||||||
when(rs.getLong("id")).thenReturn(501L);
|
when(rs.getLong("id")).thenReturn(501L);
|
||||||
when(rs.getLong("tenant_id")).thenReturn(9L);
|
when(rs.getLong("tenant_id")).thenReturn(9L);
|
||||||
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
|
when(rs.getString("bill_no")).thenReturn("REC202608180001");
|
||||||
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
||||||
when(rs.getString("status")).thenReturn(status);
|
when(rs.getString("status")).thenReturn(status);
|
||||||
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
||||||
@@ -591,7 +591,7 @@ class ReconciliationStatementBehaviorTest {
|
|||||||
|
|
||||||
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||||
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
|
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
|
||||||
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.baiyue.finance.repository;
|
||||||
|
|
||||||
|
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mybatis.spring.SqlSessionTemplate;
|
||||||
|
import org.springframework.aop.framework.ProxyFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.*;
|
||||||
|
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||||
|
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
||||||
|
class DocumentSequenceRepositoryTest {
|
||||||
|
private static class Fixture {
|
||||||
|
final JdbcTemplate jdbc;
|
||||||
|
final DataSourceTransactionManager transactions;
|
||||||
|
final DocumentSequenceMapper mapper;
|
||||||
|
final DocumentSequenceRepository repository;
|
||||||
|
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
||||||
|
Fixture() throws Exception {
|
||||||
|
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||||
|
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||||
|
jdbc.execute("CREATE TABLE fin_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
||||||
|
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
||||||
|
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||||
|
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
||||||
|
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
||||||
|
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||||
|
repository=(DocumentSequenceRepository)proxy.getProxy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 日期和前缀各自独立,重复分配持续递增。 */
|
||||||
|
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
||||||
|
var f=new Fixture();
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||||
|
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
||||||
|
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
||||||
|
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
||||||
|
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
var work=new ArrayList<Callable<Long>>();
|
||||||
|
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
||||||
|
var values=new TreeSet<Long>();
|
||||||
|
for(var future:pool.invokeAll(work))values.add(future.get());
|
||||||
|
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
||||||
|
} finally {pool.shutdownNow();}
|
||||||
|
}
|
||||||
|
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
||||||
|
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
||||||
|
var f=new Fixture();
|
||||||
|
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||||
|
throw new IllegalStateException("业务回滚");
|
||||||
|
})).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
||||||
|
@Test void connectionResultIsNeverCached() throws Exception {
|
||||||
|
var f=new Fixture();
|
||||||
|
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||||
|
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||||
|
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||||
|
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||||
|
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-57
@@ -1,70 +1,41 @@
|
|||||||
package com.baiyue.finance.service;
|
package com.baiyue.finance.service;
|
||||||
|
|
||||||
|
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import java.time.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import java.time.Clock;
|
import static org.mockito.Mockito.*;
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
class FinanceDocumentNumberGeneratorTest {
|
class FinanceDocumentNumberGeneratorTest {
|
||||||
private static final Clock CLOCK = Clock.fixed(
|
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
||||||
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
|
||||||
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
|
||||||
context.register(FinanceDocumentNumberGenerator.class);
|
|
||||||
context.refresh();
|
|
||||||
|
|
||||||
|
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
||||||
|
@Test void springCreatesGeneratorWithRepository() {
|
||||||
|
try(var context=new AnnotationConfigApplicationContext()) {
|
||||||
|
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
||||||
|
context.register(FinanceDocumentNumberGenerator.class);context.refresh();
|
||||||
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
|
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
||||||
@Test
|
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
||||||
void formatsShortDailySequenceForAllThreeDocuments() {
|
var sequences=mock(DocumentSequenceRepository.class);
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
when(sequences.next("REC:20261020")).thenReturn(1L,2L,9999L);
|
||||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
|
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
||||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
assertThat(numbers.next("REC")).isEqualTo("REC202610200001");
|
||||||
|
assertThat(numbers.next("REC")).isEqualTo("REC202610200002");
|
||||||
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
|
assertThat(numbers.next("REC")).isEqualTo("REC202610209999");
|
||||||
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
|
verify(sequences,times(3)).next("REC:20261020");
|
||||||
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
|
|
||||||
|
|
||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
|
|
||||||
eq("REC:20260818"));
|
|
||||||
}
|
}
|
||||||
|
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
||||||
@Test
|
@Test void rejectsUnknownTypeAndOverflow() {
|
||||||
void rejectsUnsupportedTypeAndOverflow() {
|
var sequences=mock(DocumentSequenceRepository.class);
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
||||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
||||||
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
|
verifyNoInteractions(sequences);
|
||||||
|
when(sequences.next("REC:20261020")).thenReturn(10000L);
|
||||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
assertThatThrownBy(()->numbers.next("REC")).isInstanceOf(BizException.class);
|
||||||
assertThatThrownBy(() -> generator.next("REC"))
|
|
||||||
.isInstanceOf(FinanceEventException.class)
|
|
||||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void vehicleCostsAndPaymentsUseIndependentSequenceKeys() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L);
|
|
||||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
|
||||||
assertThat(generator.next("VC")).isEqualTo("VC-260818-0001");
|
|
||||||
assertThat(generator.next("VP")).isEqualTo("VP-260818-0001");
|
|
||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818"));
|
|
||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
|
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
|
||||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||||
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
|
||||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 |
|
||||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||||
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||||
|
|||||||
-1
@@ -56,7 +56,6 @@ public final class ContainerCustomsDtos {
|
|||||||
String remark, List<ClearanceResultView> results) {}
|
String remark, List<ClearanceResultView> results) {}
|
||||||
|
|
||||||
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
||||||
@Size(max = 64) String batchNo,
|
|
||||||
@Size(max = 64) String customsEntryNo,
|
@Size(max = 64) String customsEntryNo,
|
||||||
@Size(max = 500) String remark) {}
|
@Size(max = 500) String remark) {}
|
||||||
|
|
||||||
|
|||||||
+11
-15
@@ -29,6 +29,8 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ContainerCustomsService {
|
public class ContainerCustomsService {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
|
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
|
||||||
public static final String READY = "READY_TO_START";
|
public static final String READY = "READY_TO_START";
|
||||||
public static final String DECLARING = "DECLARING";
|
public static final String DECLARING = "DECLARING";
|
||||||
@@ -179,6 +181,7 @@ public class ContainerCustomsService {
|
|||||||
return platformView(planId);
|
return platformView(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
|
public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key,
|
||||||
ActionRequest request) {
|
ActionRequest request) {
|
||||||
@@ -197,7 +200,7 @@ public class ContainerCustomsService {
|
|||||||
throw conflict("报关资料尚未全部冻结,不能发送给报关行");
|
throw conflict("报关资料尚未全部冻结,不能发送给报关行");
|
||||||
}
|
}
|
||||||
long submissionId = IdWorker.getId();
|
long submissionId = IdWorker.getId();
|
||||||
String batchNo = brokerBatchNo(planId);
|
String batchNo = documentNumbers.next("BR");
|
||||||
String snapshot = brokerSubmissionSnapshot(container.id());
|
String snapshot = brokerSubmissionSnapshot(container.id());
|
||||||
if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot,
|
if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||||
operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败");
|
operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败");
|
||||||
@@ -356,11 +359,12 @@ public class ContainerCustomsService {
|
|||||||
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
||||||
String key, DeclarationBatchRequest request) {
|
String key, DeclarationBatchRequest request) {
|
||||||
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
||||||
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark());
|
String requestHash = hash(action, request.expectedVersion(), request.remark());
|
||||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
||||||
ContainerRow container = mapper.lockContainer(planId);
|
ContainerRow container = mapper.lockContainer(planId);
|
||||||
@@ -371,8 +375,8 @@ public class ContainerCustomsService {
|
|||||||
}
|
}
|
||||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||||
long batchId = IdWorker.getId();
|
long batchId = IdWorker.getId();
|
||||||
String batchNo = trim(request.batchNo());
|
// 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。
|
||||||
if (batchNo == null) batchNo = "DECL-" + batchId;
|
String batchNo = documentNumbers.next("DECL");
|
||||||
String snapshot = declarationSnapshot(container.id());
|
String snapshot = declarationSnapshot(container.id());
|
||||||
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||||
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
||||||
@@ -381,20 +385,20 @@ public class ContainerCustomsService {
|
|||||||
return declarationBatchView(mapper.declarationBatch(batchId));
|
return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提交只回填外部报关单号,内部批次号保持创建时的值。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
||||||
String key, DeclarationBatchRequest request) {
|
String key, DeclarationBatchRequest request) {
|
||||||
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
||||||
String entryNo = trim(request.customsEntryNo());
|
String entryNo = trim(request.customsEntryNo());
|
||||||
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
||||||
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark());
|
String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark());
|
||||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
||||||
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
||||||
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||||
String batchNo = trim(request.batchNo());
|
String batchNo = batch.batchNo();
|
||||||
if (batchNo == null) batchNo = batch.batchNo();
|
|
||||||
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
||||||
trim(request.remark())) != 1) throw concurrent();
|
trim(request.remark())) != 1) throw concurrent();
|
||||||
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||||
@@ -501,14 +505,6 @@ public class ContainerCustomsService {
|
|||||||
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
|
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private String brokerBatchNo(long planId) {
|
|
||||||
PlanRow plan = requiredPlan(planId);
|
|
||||||
String planNo = trim(plan.planNo());
|
|
||||||
if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01";
|
|
||||||
String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT);
|
|
||||||
return "BR-" + LocalDate.now() + "-" + suffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String brokerSubmissionSnapshot(long containerId) {
|
private String brokerSubmissionSnapshot(long containerId) {
|
||||||
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
|
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
|
||||||
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
|
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
|
||||||
|
|||||||
+2
-1
@@ -67,10 +67,11 @@ public class PlatformColoadGroupController {
|
|||||||
idempotencyKey, user.userId(), user.username()));
|
idempotencyKey, user.userId(), user.username()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
|
||||||
@GetMapping("/by-inbound-code/{inboundCode}")
|
@GetMapping("/by-inbound-code/{inboundCode}")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||||
@Operation(summary = "按入仓码查询同柜组业务摘要",
|
@Operation(summary = "按入仓码查询同柜组业务摘要",
|
||||||
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
|
description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
|
||||||
public ApiResponse<BusinessSummary> byInboundCode(
|
public ApiResponse<BusinessSummary> byInboundCode(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
|
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public final class ColoadGroupModels {
|
|||||||
|
|
||||||
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
|
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
|
||||||
public record BusinessSummary(
|
public record BusinessSummary(
|
||||||
|
String groupNo,
|
||||||
String status,
|
String status,
|
||||||
int memberCount,
|
int memberCount,
|
||||||
List<BusinessMember> members
|
List<BusinessMember> members
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
|
|||||||
@Param("inboundId") long inboundId);
|
@Param("inboundId") long inboundId);
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
|
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
|
||||||
FROM fl_inbound_order requested
|
FROM fl_inbound_order requested
|
||||||
JOIN fl_coload_group_member member
|
JOIN fl_coload_group_member member
|
||||||
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
|
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.firstleg.loading.coload.persistence;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */
|
||||||
|
@Repository
|
||||||
|
public class ColoadGroupQueryRepository {
|
||||||
|
private final ColoadGroupMapper mapper;
|
||||||
|
/** 注入同服务持久化入口。 */
|
||||||
|
public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; }
|
||||||
|
/** 按业务入仓码查询当前组及其真实业务组号。 */
|
||||||
|
public List<ColoadPersistenceRows.InboundGroupLookupRow> groupsByInboundCode(String code) {
|
||||||
|
return mapper.groupsByInboundCode(code);
|
||||||
|
}
|
||||||
|
/** 读取被冻结的成员,交由服务核对缺失和重复片段。 */
|
||||||
|
public List<ColoadPersistenceRows.MemberCodeRow> effectiveMemberCodes(long groupId) {
|
||||||
|
return mapper.effectiveMemberCodes(groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -59,6 +59,7 @@ public final class ColoadPersistenceRows {
|
|||||||
@Setter
|
@Setter
|
||||||
public static class InboundGroupLookupRow {
|
public static class InboundGroupLookupRow {
|
||||||
private Long groupId;
|
private Long groupId;
|
||||||
|
private String groupNo;
|
||||||
private String groupStatus;
|
private String groupStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -38,6 +38,8 @@ import java.util.Set;
|
|||||||
/** 同柜不可拆组的唯一状态机与平台编排服务。 */
|
/** 同柜不可拆组的唯一状态机与平台编排服务。 */
|
||||||
@Service
|
@Service
|
||||||
public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
|
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
|
||||||
private static final Set<String> PRE_LOADING_GROUP_STATUSES = Set.of(
|
private static final Set<String> PRE_LOADING_GROUP_STATUSES = Set.of(
|
||||||
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||||
@@ -45,6 +47,8 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ColoadGroupMapper mapper;
|
private ColoadGroupMapper mapper;
|
||||||
|
@Resource
|
||||||
|
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
|
||||||
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
||||||
@Resource private OrderCargoClient orderCargoClient;
|
@Resource private OrderCargoClient orderCargoClient;
|
||||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||||
@@ -64,7 +68,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
validateSameRoute(candidates);
|
validateSameRoute(candidates);
|
||||||
Long lockedPlanId = validateAllocationState(candidates);
|
Long lockedPlanId = validateAllocationState(candidates);
|
||||||
long groupId = IdWorker.getId();
|
long groupId = IdWorker.getId();
|
||||||
String groupNo = "CLG-" + groupId;
|
String groupNo = documentNumbers.next("CLG");
|
||||||
InboundCandidateRow first = candidates.get(0);
|
InboundCandidateRow first = candidates.get(0);
|
||||||
mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(),
|
mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(),
|
||||||
operatorId, trim(operatorName));
|
operatorId, trim(operatorName));
|
||||||
@@ -116,10 +120,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
return requiredSnapshot(groupId);
|
return requiredSnapshot(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public BusinessSummary queryByInboundCode(String inboundCode) {
|
public BusinessSummary queryByInboundCode(String inboundCode) {
|
||||||
String code = normalizedInboundCode(inboundCode);
|
String code = normalizedInboundCode(inboundCode);
|
||||||
var groups = mapper.groupsByInboundCode(code).stream()
|
var groups = queryRepository.groupsByInboundCode(code).stream()
|
||||||
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
|
||||||
.toList();
|
.toList();
|
||||||
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
|
||||||
@@ -129,7 +134,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
|
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
|
||||||
long groupId = groups.get(0).getGroupId();
|
long groupId = groups.get(0).getGroupId();
|
||||||
String status = groups.get(0).getGroupStatus();
|
String status = groups.get(0).getGroupStatus();
|
||||||
var memberRows = mapper.effectiveMemberCodes(groupId);
|
var memberRows = queryRepository.effectiveMemberCodes(groupId);
|
||||||
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
|
||||||
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|
||||||
|| trim(row.getInboundCode()) == null)) {
|
|| trim(row.getInboundCode()) == null)) {
|
||||||
@@ -140,7 +145,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
|
|||||||
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
throw conflict("同柜不可拆组存在重复入仓片段业务号");
|
||||||
}
|
}
|
||||||
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
|
||||||
return new BusinessSummary(status, members.size(), members);
|
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
|
||||||
}
|
}
|
||||||
|
|
||||||
private long requiredCurrentGroupId(String inboundCode) {
|
private long requiredCurrentGroupId(String inboundCode) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.firstleg.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
||||||
|
@Mapper
|
||||||
|
public interface DocumentSequenceMapper {
|
||||||
|
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
||||||
|
@Insert("INSERT INTO fl_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
||||||
|
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
||||||
|
int allocate(@Param("key") String key);
|
||||||
|
|
||||||
|
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
||||||
|
@Select("SELECT LAST_INSERT_ID()")
|
||||||
|
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||||
|
Long allocatedValue();
|
||||||
|
}
|
||||||
+6
-1
@@ -63,6 +63,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
|||||||
""")
|
""")
|
||||||
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
||||||
|
|
||||||
|
/** 到港与日历统一取最新观测的实际记录,后续修正为更早时间时不能被历史最大值覆盖。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||||
@@ -70,7 +71,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
|||||||
current.actual_at AS latestActualAt,
|
current.actual_at AS latestActualAt,
|
||||||
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
||||||
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
MAX(arrival.actual_at) AS arrivedDestinationPortAt,
|
||||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||||
@@ -81,6 +82,10 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
|||||||
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
|
||||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
|
||||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
|
||||||
|
LEFT JOIN fl_carrier_schedule_node_fact arrival ON arrival.id=(
|
||||||
|
SELECT a.id FROM fl_carrier_schedule_node_fact a
|
||||||
|
WHERE a.container_booking_id=b.id AND a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL
|
||||||
|
ORDER BY a.observed_at DESC,a.id DESC LIMIT 1)
|
||||||
WHERE b.id IN
|
WHERE b.id IN
|
||||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
|
||||||
|
|||||||
+3
-1
@@ -32,6 +32,8 @@ import java.util.Set;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PortScheduleService {
|
public class PortScheduleService {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
|
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
|
||||||
|
|
||||||
@Resource private PortScheduleRepository repository;
|
@Resource private PortScheduleRepository repository;
|
||||||
@@ -72,7 +74,7 @@ public class PortScheduleService {
|
|||||||
validateLinks(request.data());
|
validateLinks(request.data());
|
||||||
PortScheduleEntity value = new PortScheduleEntity();
|
PortScheduleEntity value = new PortScheduleEntity();
|
||||||
value.setId(IdWorker.getId());
|
value.setId(IdWorker.getId());
|
||||||
value.setScheduleNo("PS" + value.getId());
|
value.setScheduleNo(documentNumbers.next("PS"));
|
||||||
copy(value, request.data());
|
copy(value, request.data());
|
||||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
|
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.firstleg.repository;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.mapper.DocumentSequenceMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
||||||
|
@Repository
|
||||||
|
public class DocumentSequenceRepository {
|
||||||
|
private final DocumentSequenceMapper mapper;
|
||||||
|
|
||||||
|
/** 只访问所属服务的编号表。 */
|
||||||
|
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
||||||
|
|
||||||
|
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public long next(String key) {
|
||||||
|
mapper.allocate(key);
|
||||||
|
Long value = mapper.allocatedValue();
|
||||||
|
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -40,6 +40,8 @@ import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SailingCalendarService {
|
public class SailingCalendarService {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
private static final String OPEN = "OPEN";
|
private static final String OPEN = "OPEN";
|
||||||
@Resource private SailingCalendarRepository repository;
|
@Resource private SailingCalendarRepository repository;
|
||||||
@@ -77,7 +79,7 @@ public class SailingCalendarService {
|
|||||||
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
|
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
|
||||||
SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
|
SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
|
||||||
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
|
||||||
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId());
|
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo(documentNumbers.next("ST"));
|
||||||
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
repository.insert(value); return value;
|
repository.insert(value); return value;
|
||||||
@@ -310,7 +312,7 @@ public class SailingCalendarService {
|
|||||||
if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId())
|
if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId())
|
||||||
: throwCopyConflict(request.etdAt(), source.getVoyageNo());
|
: throwCopyConflict(request.etdAt(), source.getVoyageNo());
|
||||||
|
|
||||||
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
|
||||||
try { repository.insert(value); } catch (DuplicateKeyException ex) {
|
try { repository.insert(value); } catch (DuplicateKeyException ex) {
|
||||||
SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
|
SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
|
||||||
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
|
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
|
||||||
@@ -362,7 +364,7 @@ public class SailingCalendarService {
|
|||||||
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
|
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
|
||||||
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
|
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
|
||||||
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
|
||||||
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId());
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL"));
|
||||||
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
|
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
|
||||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
|
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
|
||||||
@@ -398,7 +400,7 @@ public class SailingCalendarService {
|
|||||||
validateTemplateSnapshot(template);
|
validateTemplateSnapshot(template);
|
||||||
List<SailingEntity> result = new java.util.ArrayList<>();
|
List<SailingEntity> result = new java.util.ArrayList<>();
|
||||||
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
|
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
|
||||||
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId);
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(templateId);
|
||||||
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
|
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
|
||||||
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
|
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-47
@@ -1,71 +1,47 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.number.DailyDocumentNumber;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||||
@Component
|
@Component
|
||||||
public class FirstlegDocumentNumberGenerator {
|
public class FirstlegDocumentNumberGenerator {
|
||||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
private static final Set<String> PREFIXES = Set.of("OB", "TA", "LP", "DECL", "BR", "PS", "SL", "ST", "CLG");
|
||||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
private final DocumentSequenceRepository sequences;
|
||||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP");
|
|
||||||
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
|
/** 正式运行使用统一业务时区。 */
|
||||||
@Autowired
|
@Autowired
|
||||||
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
|
public FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
||||||
this(jdbc, Clock.system(BUSINESS_ZONE));
|
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
/** 固定时钟用于跨日及前缀独立性验证。 */
|
||||||
this.jdbc = jdbc;
|
FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
||||||
|
this.sequences = sequences;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(propagation = Propagation.MANDATORY)
|
/** 只为本服务已登记的业务类型分配号码。 */
|
||||||
public String next(String prefix) {
|
public String next(String prefix) {
|
||||||
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
// 实物标签未纳入本次改号,DP 保留原六位日期及横杠。
|
||||||
throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix);
|
if ("DP".equals(prefix)) {
|
||||||
|
java.time.LocalDate date = java.time.LocalDate.now(clock.withZone(DailyDocumentNumber.BUSINESS_ZONE));
|
||||||
|
long value = sequences.next("DP:" + date.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE));
|
||||||
|
DailyDocumentNumber.format("DP", date, value);
|
||||||
|
return "DP-" + date.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd"))
|
||||||
|
+ "-" + String.format(java.util.Locale.ROOT, "%04d", value);
|
||||||
}
|
}
|
||||||
LocalDate sequenceDate = LocalDate.now(clock);
|
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
||||||
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
||||||
long sequence = nextSequence(sequenceKey);
|
|
||||||
if (sequence > MAX_DAILY_SEQUENCE) {
|
|
||||||
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
|
|
||||||
}
|
|
||||||
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
|
||||||
String.format(Locale.ROOT, "%04d", sequence);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 平台柜次号是长期业务身份,使用不按日期重置的 D 序号。 */
|
/** 柜次号保持现有全局连续规则,不按日期重置,也不限制为四位。 */
|
||||||
@Transactional(propagation = Propagation.MANDATORY)
|
|
||||||
public String nextCabinetNo() {
|
public String nextCabinetNo() {
|
||||||
return "D" + String.format(Locale.ROOT, "%04d", nextSequence("CABINET"));
|
return "D" + String.format(java.util.Locale.ROOT, "%04d", sequences.next("CABINET"));
|
||||||
}
|
|
||||||
|
|
||||||
private long nextSequence(String sequenceKey) {
|
|
||||||
jdbc.update(
|
|
||||||
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
|
|
||||||
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
|
||||||
"current_value=LAST_INSERT_ID(current_value+1)",
|
|
||||||
sequenceKey);
|
|
||||||
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
|
||||||
if (sequence == null || sequence <= 0) {
|
|
||||||
throw new BizException(ResultCode.CONFLICT, "业务编号生成失败");
|
|
||||||
}
|
|
||||||
return sequence;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,12 +91,13 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
return repository.candidates(warehouseId);
|
return repository.candidates(warehouseId);
|
||||||
}
|
}
|
||||||
|
/** 按柜次渠道查询候选,关联订舱客户快照不限制其他客户的货物。 */
|
||||||
public List<Candidate> candidates(long bookingId, long warehouseId) {
|
public List<Candidate> candidates(long bookingId, long warehouseId) {
|
||||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||||
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
|
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
|
||||||
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
|
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
|
||||||
List<Candidate> values = candidates(warehouseId).stream()
|
List<Candidate> values = candidates(warehouseId).stream()
|
||||||
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
.filter(candidate -> sameChannel(booking, candidate)).toList();
|
||||||
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
|
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +109,11 @@ public class SmartLoadingService {
|
|||||||
return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList());
|
return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 推荐保留渠道和显式筛选范围,允许同柜包含多个客户。 */
|
||||||
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking,
|
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking,
|
||||||
Long customerId, String inboundCode) {
|
Long customerId, String inboundCode) {
|
||||||
List<Candidate> values = repository.candidates(null).stream()
|
List<Candidate> values = repository.candidates(null).stream()
|
||||||
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate))
|
.filter(candidate -> sameChannel(booking, candidate))
|
||||||
.filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId()))
|
.filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId()))
|
||||||
.filter(candidate -> !StringUtils.hasText(inboundCode)
|
.filter(candidate -> !StringUtils.hasText(inboundCode)
|
||||||
|| inboundCode.trim().equals(candidate.inboundCode()))
|
|| inboundCode.trim().equals(candidate.inboundCode()))
|
||||||
@@ -513,6 +515,7 @@ public class SmartLoadingService {
|
|||||||
return view(assigned, requireBooking(assigned.getContainerBookingId()));
|
return view(assigned, requireBooking(assigned.getContainerBookingId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 原子新增明细或整托组成,使用货物自身客户归属而非订舱客户快照。 */
|
||||||
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
|
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
|
||||||
boolean wholeGroupAssignment, Long palletId, String palletNo,
|
boolean wholeGroupAssignment, Long palletId, String palletNo,
|
||||||
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
|
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
|
||||||
@@ -535,7 +538,6 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
|
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
|
||||||
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
|
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
|
||||||
requireSameCustomerScope(booking, candidate);
|
|
||||||
var task = loadingPickService.mutationSnapshot(planId);
|
var task = loadingPickService.mutationSnapshot(planId);
|
||||||
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
|
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
|
||||||
splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(),
|
splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(),
|
||||||
@@ -604,10 +606,7 @@ public class SmartLoadingService {
|
|||||||
return assignInbound(planId, request, operatorId, operatorName, false, null, true);
|
return assignInbound(planId, request, operatorId, operatorName, false, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 待审核订单可跨客户占位;权威订单归属和后续审核测量门禁仍必须满足。 */
|
||||||
* Reserves declared cartons in a cabinet before order review. Review status is not an allocation
|
|
||||||
* gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT.
|
|
||||||
*/
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN",
|
@BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN",
|
||||||
resourceId = "#planId", description = "待审核订单加入装柜清单")
|
resourceId = "#planId", description = "待审核订单加入装柜清单")
|
||||||
@@ -652,7 +651,6 @@ public class SmartLoadingService {
|
|||||||
for (InboundAllocation allocation : allocations) {
|
for (InboundAllocation allocation : allocations) {
|
||||||
if (allocation.packageCount() == 0) continue;
|
if (allocation.packageCount() == 0) continue;
|
||||||
Candidate candidate = allocation.candidate();
|
Candidate candidate = allocation.candidate();
|
||||||
requireSameCustomerScope(booking, candidate);
|
|
||||||
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
||||||
OrderCargoClient.CargoBinding cargo = declared.stream()
|
OrderCargoClient.CargoBinding cargo = declared.stream()
|
||||||
.map(DeclaredCargo::cargo)
|
.map(DeclaredCargo::cargo)
|
||||||
@@ -722,7 +720,7 @@ public class SmartLoadingService {
|
|||||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
||||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||||
List<Candidate> details = frozenDetails == null
|
List<Candidate> details = frozenDetails == null
|
||||||
? manualCandidates(booking).stream()
|
? manualCandidates().stream()
|
||||||
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
|
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
|
||||||
: List.copyOf(frozenDetails);
|
: List.copyOf(frozenDetails);
|
||||||
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
|
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
|
||||||
@@ -756,6 +754,7 @@ public class SmartLoadingService {
|
|||||||
return view(assigned, booking);
|
return view(assigned, booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按入仓单自己的客户和申报事实建立占位,不以订舱客户限制拼柜。 */
|
||||||
private Plan assignDeclaredInbound(long planId, AssignInboundRequest request,
|
private Plan assignDeclaredInbound(long planId, AssignInboundRequest request,
|
||||||
InboundOrderEntity projection, String operation,
|
InboundOrderEntity projection, String operation,
|
||||||
String requestHash, long operatorId, String operatorName) {
|
String requestHash, long operatorId, String operatorName) {
|
||||||
@@ -786,7 +785,6 @@ public class SmartLoadingService {
|
|||||||
for (InboundAllocation allocation : allocations) {
|
for (InboundAllocation allocation : allocations) {
|
||||||
if (allocation.packageCount() == 0) continue;
|
if (allocation.packageCount() == 0) continue;
|
||||||
Candidate candidate = allocation.candidate();
|
Candidate candidate = allocation.candidate();
|
||||||
requireSameCustomerScope(booking, candidate);
|
|
||||||
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
||||||
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
|
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
|
||||||
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
|
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
|
||||||
@@ -1007,6 +1005,7 @@ public class SmartLoadingService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
|
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
|
||||||
resourceId = "#itemId", description = "调整智能装柜明细")
|
resourceId = "#itemId", description = "调整智能装柜明细")
|
||||||
|
/** 调整本计划明细箱数,保留原客户归属、可用量、拣货和容量校验。 */
|
||||||
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
|
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
|
||||||
String operation = operation(request.operationId());
|
String operation = operation(request.operationId());
|
||||||
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
|
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
|
||||||
@@ -1053,7 +1052,6 @@ public class SmartLoadingService {
|
|||||||
if (request.packageCount() > maxPackageCount) {
|
if (request.packageCount() > maxPackageCount) {
|
||||||
throw validation("调整箱数超过可用箱数");
|
throw validation("调整箱数超过可用箱数");
|
||||||
}
|
}
|
||||||
requireSameCustomerScope(booking, candidate);
|
|
||||||
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||||
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
||||||
checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true);
|
checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true);
|
||||||
@@ -1360,7 +1358,7 @@ public class SmartLoadingService {
|
|||||||
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
|
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
|
||||||
throw conflict("同柜不可拆组已锁定或已消费");
|
throw conflict("同柜不可拆组已锁定或已消费");
|
||||||
}
|
}
|
||||||
List<Candidate> available = manualCandidates(booking);
|
List<Candidate> available = manualCandidates();
|
||||||
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
|
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
|
||||||
for (Member member : group.members()) {
|
for (Member member : group.members()) {
|
||||||
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
|
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
|
||||||
@@ -1382,14 +1380,15 @@ public class SmartLoadingService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 整组推荐可跨客户,但仍按柜次渠道选择候选。 */
|
||||||
private List<Candidate> availableCandidates(ContainerBookingEntity booking) {
|
private List<Candidate> availableCandidates(ContainerBookingEntity booking) {
|
||||||
return repository.candidates(null).stream()
|
return repository.candidates(null).stream()
|
||||||
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
.filter(candidate -> sameChannel(booking, candidate)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
|
/** 平台人工分配可拼装多个客户,明细始终保留各自的真实客户归属。 */
|
||||||
return repository.candidates(null).stream()
|
private List<Candidate> manualCandidates() {
|
||||||
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
|
return repository.candidates(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
|
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
|
||||||
@@ -1807,12 +1806,6 @@ public class SmartLoadingService {
|
|||||||
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
|
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
|
||||||
&& booking.getChannelGroupCode().equals(candidateGroup.code());
|
&& booking.getChannelGroupCode().equals(candidateGroup.code());
|
||||||
}
|
}
|
||||||
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
|
||||||
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
|
|
||||||
}
|
|
||||||
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
|
||||||
if (!sameCustomerScope(booking, candidate)) throw forbidden();
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot.
|
* A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot.
|
||||||
* The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and
|
* The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and
|
||||||
@@ -1911,5 +1904,5 @@ public class SmartLoadingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
|
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
|
||||||
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
|
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class WorkflowTabService {
|
|||||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||||
// 确认页签复用已推送费用版本。
|
// 确认页签复用已推送费用版本。
|
||||||
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
||||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
|
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
|
||||||
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
||||||
}
|
}
|
||||||
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||||
@@ -32,19 +32,24 @@ public class WorkflowTabService {
|
|||||||
if(!sources.isEmpty()) {
|
if(!sources.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
||||||
if(response==null || !response.successful() || response.data()==null) throw unavailable();
|
if(response==null || !response.successful() || response.data()==null) throw unavailable(
|
||||||
|
"尾程事实响应无效 code="+(response==null?null:response.code())
|
||||||
|
+" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId()));
|
||||||
var allowed=new HashSet<>(sources);
|
var allowed=new HashSet<>(sources);
|
||||||
for(var fact:response.data()) {
|
for(var fact:response.data()) {
|
||||||
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
|
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
|
||||||
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
||||||
}
|
}
|
||||||
if(overseas.size()!=sources.size()) throw unavailable();
|
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
||||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
|
||||||
|
} catch(BizException e) { throw e; } catch(Exception e) {
|
||||||
|
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||||
for(var fact:facts) {
|
for(var fact:facts) {
|
||||||
var inbound=expected.get(fact.inboundId());
|
var inbound=expected.get(fact.inboundId());
|
||||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
|
||||||
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
||||||
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
||||||
}
|
}
|
||||||
@@ -77,5 +82,9 @@ public class WorkflowTabService {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||||
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
private static BizException unavailable(String reason) {
|
||||||
|
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
|
||||||
|
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
|
||||||
|
.initCause(new IllegalStateException(reason));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ CALL phase3_add_allocation_picked_count();
|
|||||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||||
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
||||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||||
PRIMARY KEY (sequence_key),
|
PRIMARY KEY (sequence_key),
|
||||||
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
|
||||||
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
) ENGINE=InnoDB COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ paths:
|
|||||||
summary: 按入仓码查询同柜组业务摘要
|
summary: 按入仓码查询同柜组业务摘要
|
||||||
description: >-
|
description: >-
|
||||||
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
|
||||||
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
|
||||||
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
|
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
|
||||||
parameters:
|
parameters:
|
||||||
- name: inboundCode
|
- name: inboundCode
|
||||||
in: path
|
in: path
|
||||||
@@ -147,8 +147,9 @@ components:
|
|||||||
ColoadGroupBusinessSummary:
|
ColoadGroupBusinessSummary:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required: [status, memberCount, members]
|
required: [groupNo, status, memberCount, members]
|
||||||
properties:
|
properties:
|
||||||
|
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
|
||||||
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
|
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
|
||||||
memberCount: {type: integer, minimum: 1}
|
memberCount: {type: integer, minimum: 1}
|
||||||
members:
|
members:
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ components:
|
|||||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
||||||
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
||||||
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||||
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||||
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
||||||
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
||||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||||
|
|||||||
@@ -700,7 +700,8 @@ paths:
|
|||||||
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
|
需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中
|
||||||
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
|
对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。
|
||||||
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
|
零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道,
|
||||||
仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。
|
仍由服务端重新校验国内仓、货物原客户归属、占用、同柜组、箱数和容量资格。
|
||||||
|
平台柜次支持跨客户拼柜,关联订舱的客户快照不限制柜内成员客户。
|
||||||
parameters:
|
parameters:
|
||||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
responses:
|
responses:
|
||||||
@@ -835,6 +836,7 @@ paths:
|
|||||||
description: >-
|
description: >-
|
||||||
需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围;
|
需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围;
|
||||||
传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。
|
传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。
|
||||||
|
关联订舱的客户快照不裁剪候选,未指定客户筛选时可推荐多个客户的货物。
|
||||||
一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。
|
一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。
|
||||||
parameters:
|
parameters:
|
||||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
@@ -848,7 +850,8 @@ paths:
|
|||||||
summary: 向预装计划新增入库单明细
|
summary: 向预装计划新增入库单明细
|
||||||
description: >-
|
description: >-
|
||||||
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
||||||
校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同,
|
校验货物原客户归属、可用箱数、容积和限重后原子新增/累加明细;平台允许跨客户拼柜,
|
||||||
|
不以关联订舱客户快照限定成员客户。人工加入允许订单国内仓及渠道与柜次不同,
|
||||||
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
|
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
|
||||||
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
||||||
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
||||||
@@ -870,6 +873,7 @@ paths:
|
|||||||
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
|
||||||
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
|
||||||
占用、同柜组和容量门禁不变。
|
占用、同柜组和容量门禁不变。
|
||||||
|
支持将不同客户的入仓码加入同一柜次,明细及费用仍保留各自原客户归属。
|
||||||
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
|
||||||
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
||||||
parameters:
|
parameters:
|
||||||
@@ -886,6 +890,7 @@ paths:
|
|||||||
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
||||||
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
||||||
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
|
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同;
|
||||||
|
不同客户可加入同一柜次,关联订舱客户快照不作为客户准入限制。
|
||||||
已被其他有效柜次占用、国内仓不一致、
|
已被其他有效柜次占用、国内仓不一致、
|
||||||
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
||||||
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
||||||
@@ -2376,7 +2381,7 @@ components:
|
|||||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
|
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
|
||||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||||
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
planNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||||
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
||||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||||
@@ -2543,7 +2548,7 @@ components:
|
|||||||
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
|
inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示}
|
||||||
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
|
orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示}
|
||||||
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
|
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID}
|
||||||
loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示}
|
loadingPlanNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', description: 装柜计划业务号,UI主展示}
|
||||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
|
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID}
|
||||||
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
|
cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示}
|
||||||
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
|
containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示}
|
||||||
@@ -2676,7 +2681,7 @@ components:
|
|||||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
|
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
|
||||||
properties:
|
properties:
|
||||||
id: {type: string}
|
id: {type: string}
|
||||||
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
|
planNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号}
|
||||||
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||||
bookingId: {type: string}
|
bookingId: {type: string}
|
||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
preloadNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||||
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
||||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||||
@@ -356,7 +356,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
planId: {$ref: '#/components/schemas/LongId'}
|
planId: {$ref: '#/components/schemas/LongId'}
|
||||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||||
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
loadingListNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 装柜清单号;沿用预装阶段生成的同一编号}
|
||||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ components:
|
|||||||
oceanBooking:
|
oceanBooking:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
|
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||||
parties: {type: array}
|
parties: {type: array}
|
||||||
containerRequirements: {type: array}
|
containerRequirements: {type: array}
|
||||||
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
containers: {type: array, description: Existing containers allocated after port pickup linking}
|
||||||
|
|||||||
@@ -235,9 +235,9 @@ components:
|
|||||||
required: [appointmentNo, carrierId, carrierName, attachmentCount]
|
required: [appointmentNo, carrierId, carrierName, attachmentCount]
|
||||||
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
|
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
|
||||||
properties:
|
properties:
|
||||||
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
|
appointmentNo: {type: string, pattern: '^TA[0-9]{12}$', example: TA202608180001, description: 派车单号;按北京时间创建日逐日编号}
|
||||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||||
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
|
oceanBookingNo: {type: string, nullable: true, pattern: '^OB[0-9]{12}$', example: OB202608180001}
|
||||||
warehouseId: {type: integer, format: int64, nullable: true}
|
warehouseId: {type: integer, format: int64, nullable: true}
|
||||||
warehouseName: {type: string, nullable: true}
|
warehouseName: {type: string, nullable: true}
|
||||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||||
|
|||||||
+6
-3
@@ -32,7 +32,7 @@ class InboundMeasurementReviewContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID")
|
@DisplayName("测量审核不进入待客户确认,并声明幂等和字符串长ID")
|
||||||
void shouldDocumentReviewContract() throws Exception {
|
void shouldDocumentReviewContract() throws Exception {
|
||||||
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
@@ -46,8 +46,11 @@ class InboundMeasurementReviewContractTest {
|
|||||||
.contains("name: inboundId")
|
.contains("name: inboundId")
|
||||||
.contains("schema: {type: string, pattern:")
|
.contains("schema: {type: string, pattern:")
|
||||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
|
||||||
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:")
|
.doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:");
|
||||||
.doesNotContain("待客户确认");
|
// 约束仅针对测量审核;客户费用确认属于另一业务,允许独立存在。
|
||||||
|
int start = openapi.indexOf(" /api/platform/v1/inbounds/{inboundId}/measurements/approve:");
|
||||||
|
int end = openapi.indexOf("\n /", start + 1);
|
||||||
|
assertThat(openapi.substring(start, end < 0 ? openapi.length() : end)).doesNotContain("待客户确认");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+59
-5
@@ -28,6 +28,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class ContainerCustomsServiceTest {
|
class ContainerCustomsServiceTest {
|
||||||
|
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||||
|
|
||||||
@Mock ContainerCustomsMapper mapper;
|
@Mock ContainerCustomsMapper mapper;
|
||||||
@Mock CustomsFileClient files;
|
@Mock CustomsFileClient files;
|
||||||
@Mock ObjectMapper objectMapper;
|
@Mock ObjectMapper objectMapper;
|
||||||
@@ -35,6 +37,58 @@ class ContainerCustomsServiceTest {
|
|||||||
@InjectMocks ContainerCustomsService service;
|
@InjectMocks ContainerCustomsService service;
|
||||||
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);}
|
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);}
|
||||||
|
|
||||||
|
/** 服务端分配批次号后,命令重放必须返回原号且不再消耗流水。 */
|
||||||
|
@Test void declarationCreationReplayKeepsAllocatedNumber() throws Exception {
|
||||||
|
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_9";
|
||||||
|
var command = new AtomicReference<ContainerCustomsMapper.CommandRow>();
|
||||||
|
var batch = new AtomicReference<ContainerCustomsMapper.DeclarationBatchRow>();
|
||||||
|
when(mapper.claimCommand(anyLong(), eq(5L), eq(action), eq("batch-key"), anyString())).thenAnswer(call -> {
|
||||||
|
if (command.get() != null) return 0;
|
||||||
|
command.set(new ContainerCustomsMapper.CommandRow(1L, 5L, action, "batch-key", call.getArgument(4), "PROCESSING", null, null, null));
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
when(mapper.command(5L, action, "batch-key")).thenAnswer(call -> command.get());
|
||||||
|
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
|
||||||
|
when(mapper.taxRefundCount(9L)).thenReturn(1);
|
||||||
|
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.of(2026, 10, 20, 9, 0));
|
||||||
|
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||||
|
when(mapper.insertDeclarationBatch(anyLong(), eq(2L), eq(9L), anyString(), eq(3), eq("[]"), isNull())).thenAnswer(call -> {
|
||||||
|
batch.set(new ContainerCustomsMapper.DeclarationBatchRow(call.getArgument(0), 2L, 9L, call.getArgument(3),
|
||||||
|
"READY", 3, null, null, null, null, null, null, 0, null, null));
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
when(mapper.declarationBatch(anyLong())).thenAnswer(call -> batch.get());
|
||||||
|
when(mapper.insertHistory(anyLong(), eq(2L), eq("CREATE_CUSTOMS_DECLARATION_BATCH"), anyString(), anyString(), anyString(), eq(5L), eq("admin"))).thenReturn(1);
|
||||||
|
when(mapper.completeCommand(eq(5L), eq(action), eq("batch-key"), anyLong())).thenAnswer(call -> {
|
||||||
|
var old = command.get();
|
||||||
|
command.set(new ContainerCustomsMapper.CommandRow(old.id(), 5L, action, "batch-key", old.requestHash(), "SUCCESS", call.getArgument(3), null, null));
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
var request = new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(3, null, null);
|
||||||
|
var created = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request);
|
||||||
|
var replay = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request);
|
||||||
|
assertThat(created.batchNo()).isEqualTo("DECL202610200001");
|
||||||
|
assertThat(replay.id()).isEqualTo(created.id());
|
||||||
|
assertThat(replay.batchNo()).isEqualTo(created.batchNo());
|
||||||
|
verify(documentNumbers, times(1)).next("DECL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 外部报关单号与内部批次号独立,正式提交不能修改内部号。 */
|
||||||
|
@Test void declarationSubmissionPreservesBatchNumber() {
|
||||||
|
var batch = new ContainerCustomsMapper.DeclarationBatchRow(41L, 2L, 9L, "DECL202610200001",
|
||||||
|
"READY", 3, null, null, null, null, null, null, 0, null, null);
|
||||||
|
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_DECLARATION_BATCH_41"), eq("submit-key"), anyString())).thenReturn(1);
|
||||||
|
when(mapper.lockDeclarationBatch(41L)).thenReturn(batch);
|
||||||
|
when(mapper.submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null)).thenReturn(1);
|
||||||
|
when(mapper.completeCommand(5L, "SUBMIT_CUSTOMS_DECLARATION_BATCH_41", "submit-key", 41L)).thenReturn(1);
|
||||||
|
when(mapper.declarationBatch(41L)).thenReturn(batch);
|
||||||
|
var result = service.submitDeclarationBatch(41L, 5L, "admin", "submit-key",
|
||||||
|
new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(0, "CUSTOMS-EXTERNAL-1", null));
|
||||||
|
assertThat(result.batchNo()).isEqualTo(batch.batchNo());
|
||||||
|
verify(mapper).submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null);
|
||||||
|
verifyNoInteractions(documentNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
@Test void merchantOwnBuyOrderIsNotFound(){
|
@Test void merchantOwnBuyOrderIsNotFound(){
|
||||||
assertMerchantOrderNotFound(7L,31L);
|
assertMerchantOrderNotFound(7L,31L);
|
||||||
}
|
}
|
||||||
@@ -211,7 +265,7 @@ class ContainerCustomsServiceTest {
|
|||||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||||
var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64),
|
var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64),
|
||||||
"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);
|
"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);
|
||||||
var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",
|
var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",
|
||||||
java.time.LocalDateTime.now(),1);
|
java.time.LocalDateTime.now(),1);
|
||||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1);
|
when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1);
|
||||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||||
@@ -220,7 +274,7 @@ class ContainerCustomsServiceTest {
|
|||||||
when(mapper.documents(2L,null)).thenReturn(List.of(source));
|
when(mapper.documents(2L,null)).thenReturn(List.of(source));
|
||||||
when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I")));
|
when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I")));
|
||||||
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||||
when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1);
|
when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1);
|
||||||
when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1);
|
when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1);
|
||||||
when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1);
|
when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1);
|
||||||
when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1);
|
when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1);
|
||||||
@@ -235,9 +289,9 @@ class ContainerCustomsServiceTest {
|
|||||||
var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null));
|
var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null));
|
||||||
|
|
||||||
assertThat(result.brokerSubmission()).isNotNull();
|
assertThat(result.brokerSubmission()).isNotNull();
|
||||||
assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01");
|
assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR202610200001");
|
||||||
assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3);
|
assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3);
|
||||||
verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"));
|
verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void initialBrokerSendRejectsStaleAggregateVersion(){
|
@Test void initialBrokerSendRejectsStaleAggregateVersion(){
|
||||||
@@ -535,7 +589,7 @@ class ContainerCustomsServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
|
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
|
||||||
private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);}
|
private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",java.time.LocalDateTime.now(),1);}
|
||||||
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
|
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
|
||||||
private void assertMerchantOrderNotFound(long tenantId,long orderId){
|
private void assertMerchantOrderNotFound(long tenantId,long orderId){
|
||||||
assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId))
|
assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId))
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
|
|||||||
.doesNotContain("destinationCode")
|
.doesNotContain("destinationCode")
|
||||||
.doesNotContain("expectedVersion")
|
.doesNotContain("expectedVersion")
|
||||||
.doesNotContain("platform:firstleg:coload");
|
.doesNotContain("platform:firstleg:coload");
|
||||||
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
|
assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
|
||||||
assertThat(memberProperties).containsOnlyKeys("inboundCode");
|
assertThat(memberProperties).containsOnlyKeys("inboundCode");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -37,8 +37,17 @@ import static org.mockito.Mockito.never;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@SpringJUnitConfig(ColoadGroupService.class)
|
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
|
||||||
class ColoadGroupServiceTest {
|
class ColoadGroupServiceTest {
|
||||||
|
@org.springframework.context.annotation.Configuration
|
||||||
|
static class Numbers {
|
||||||
|
/** 同柜组业务测试使用真实格式,序列由隔离测试替身提供。 */
|
||||||
|
@org.springframework.context.annotation.Bean
|
||||||
|
com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers() {
|
||||||
|
return com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private ColoadGroupMapper mapper;
|
private ColoadGroupMapper mapper;
|
||||||
@MockBean private PendingReviewOrderClient pendingReviewOrders;
|
@MockBean private PendingReviewOrderClient pendingReviewOrders;
|
||||||
@@ -62,6 +71,7 @@ class ColoadGroupServiceTest {
|
|||||||
|
|
||||||
var result = service.queryByInboundCode(" IN-A ");
|
var result = service.queryByInboundCode(" IN-A ");
|
||||||
|
|
||||||
|
assertThat(result.groupNo()).isEqualTo("CLG202610210001");
|
||||||
assertThat(result.status()).isEqualTo("ACTIVE");
|
assertThat(result.status()).isEqualTo("ACTIVE");
|
||||||
assertThat(result.memberCount()).isEqualTo(2);
|
assertThat(result.memberCount()).isEqualTo(2);
|
||||||
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
|
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
|
||||||
@@ -565,6 +575,7 @@ class ColoadGroupServiceTest {
|
|||||||
private static InboundGroupLookupRow lookup(long groupId, String status) {
|
private static InboundGroupLookupRow lookup(long groupId, String status) {
|
||||||
InboundGroupLookupRow value = new InboundGroupLookupRow();
|
InboundGroupLookupRow value = new InboundGroupLookupRow();
|
||||||
value.setGroupId(groupId);
|
value.setGroupId(groupId);
|
||||||
|
value.setGroupNo("CLG202610210001");
|
||||||
value.setGroupStatus(status);
|
value.setGroupStatus(status);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -86,7 +86,8 @@ class ColoadGroupSqlContractTest {
|
|||||||
.contains("requested.inbound_code=#{inboundCode}")
|
.contains("requested.inbound_code=#{inboundCode}")
|
||||||
.contains("requested.deleted=0")
|
.contains("requested.deleted=0")
|
||||||
.contains("'ACTIVE','LOCKED','CONSUMED'")
|
.contains("'ACTIVE','LOCKED','CONSUMED'")
|
||||||
.doesNotContain("group_no", "order_id=#{");
|
.contains("group_row.group_no groupNo")
|
||||||
|
.doesNotContain("order_id=#{");
|
||||||
assertThat(memberSql)
|
assertThat(memberSql)
|
||||||
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
|
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
|
||||||
.contains("LEFT JOIN fl_inbound_order inbound")
|
.contains("LEFT JOIN fl_inbound_order inbound")
|
||||||
|
|||||||
+7
-3
@@ -44,8 +44,9 @@ class CarrierScheduleQuerySqlContractTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */
|
||||||
@Test
|
@Test
|
||||||
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
|
void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception {
|
||||||
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
||||||
.getMethod("selectContainerActualFacts", List.class)
|
.getMethod("selectContainerActualFacts", List.class)
|
||||||
.getAnnotation(Select.class).value());
|
.getAnnotation(Select.class).value());
|
||||||
@@ -55,13 +56,16 @@ class CarrierScheduleQuerySqlContractTest {
|
|||||||
.contains("f.actual_at IS NOT NULL")
|
.contains("f.actual_at IS NOT NULL")
|
||||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
||||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
||||||
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
.contains("MAX(arrival.actual_at) AS arrivedDestinationPortAt")
|
||||||
|
.contains("a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL")
|
||||||
|
.contains("ORDER BY a.observed_at DESC,a.id DESC LIMIT 1")
|
||||||
|
.doesNotContain("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||||
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
||||||
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
||||||
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
||||||
.contains("ORDER BY b.id")
|
.contains("ORDER BY b.id")
|
||||||
.doesNotContain("estimated_at");
|
.doesNotContain("estimated_at");
|
||||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
|
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(5);
|
||||||
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
|
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -14,6 +14,7 @@ class ArrivalCalendarTest {
|
|||||||
void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() {
|
void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() {
|
||||||
var repository = mock(PortScheduleRepository.class);
|
var repository = mock(PortScheduleRepository.class);
|
||||||
var service = new PortScheduleService();
|
var service = new PortScheduleService();
|
||||||
|
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.firstleg.service.TestDocumentNumbers.documents());
|
||||||
ReflectionTestUtils.setField(service, "repository", repository);
|
ReflectionTestUtils.setField(service, "repository", repository);
|
||||||
var from = LocalDate.of(2026, 9, 28);
|
var from = LocalDate.of(2026, 9, 28);
|
||||||
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from));
|
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from));
|
||||||
|
|||||||
+2
@@ -35,6 +35,8 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class PortScheduleServiceTest {
|
class PortScheduleServiceTest {
|
||||||
|
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() {
|
void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() {
|
||||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.baiyue.firstleg.repository;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.mapper.DocumentSequenceMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mybatis.spring.SqlSessionTemplate;
|
||||||
|
import org.springframework.aop.framework.ProxyFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.*;
|
||||||
|
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||||
|
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
||||||
|
class DocumentSequenceRepositoryTest {
|
||||||
|
private static class Fixture {
|
||||||
|
final JdbcTemplate jdbc;
|
||||||
|
final DataSourceTransactionManager transactions;
|
||||||
|
final DocumentSequenceMapper mapper;
|
||||||
|
final DocumentSequenceRepository repository;
|
||||||
|
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
||||||
|
Fixture() throws Exception {
|
||||||
|
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||||
|
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||||
|
jdbc.execute("CREATE TABLE fl_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
||||||
|
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
||||||
|
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||||
|
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
||||||
|
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
||||||
|
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||||
|
repository=(DocumentSequenceRepository)proxy.getProxy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 日期和前缀各自独立,重复分配持续递增。 */
|
||||||
|
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
||||||
|
var f=new Fixture();
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||||
|
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
||||||
|
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
||||||
|
}
|
||||||
|
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
||||||
|
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
||||||
|
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
var work=new ArrayList<Callable<Long>>();
|
||||||
|
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
||||||
|
var values=new TreeSet<Long>();
|
||||||
|
for(var future:pool.invokeAll(work))values.add(future.get());
|
||||||
|
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
||||||
|
} finally {pool.shutdownNow();}
|
||||||
|
}
|
||||||
|
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
||||||
|
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
||||||
|
var f=new Fixture();
|
||||||
|
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||||
|
throw new IllegalStateException("业务回滚");
|
||||||
|
})).isInstanceOf(IllegalStateException.class);
|
||||||
|
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
||||||
|
@Test void connectionResultIsNeverCached() throws Exception {
|
||||||
|
var f=new Fixture();
|
||||||
|
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||||
|
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||||
|
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||||
|
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||||
|
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
|||||||
@Test
|
@Test
|
||||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||||
String sql = sql();
|
String sql = sql();
|
||||||
int expectedTables = 228;
|
int expectedTables = 229;
|
||||||
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
||||||
.matcher(sql).results().count();
|
.matcher(sql).results().count();
|
||||||
assertThat(actualTables).isEqualTo(expectedTables);
|
assertThat(actualTables).isEqualTo(expectedTables);
|
||||||
|
|||||||
+2
@@ -24,6 +24,8 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class SailingCalendarServiceTest {
|
class SailingCalendarServiceTest {
|
||||||
|
@org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents();
|
||||||
|
|
||||||
@Mock private SailingCalendarRepository repository;
|
@Mock private SailingCalendarRepository repository;
|
||||||
@InjectMocks private SailingCalendarService service;
|
@InjectMocks private SailingCalendarService service;
|
||||||
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
|
private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14);
|
||||||
|
|||||||
+37
-58
@@ -1,73 +1,52 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import java.time.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import java.time.Clock;
|
import static org.mockito.Mockito.*;
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
class FirstlegDocumentNumberGeneratorTest {
|
class FirstlegDocumentNumberGeneratorTest {
|
||||||
private static final Clock CLOCK = Clock.fixed(
|
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
||||||
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
|
||||||
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
|
||||||
context.register(FirstlegDocumentNumberGenerator.class);
|
|
||||||
context.refresh();
|
|
||||||
|
|
||||||
|
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
||||||
|
@Test void springCreatesGeneratorWithRepository() {
|
||||||
|
try(var context=new AnnotationConfigApplicationContext()) {
|
||||||
|
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
||||||
|
context.register(FirstlegDocumentNumberGenerator.class);context.refresh();
|
||||||
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
|
assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
||||||
@Test
|
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
||||||
void formatsAllOperationalDocumentPrefixes() {
|
var sequences=mock(DocumentSequenceRepository.class);
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
when(sequences.next("LP:20261020")).thenReturn(1L,2L,9999L);
|
||||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
|
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||||
.thenReturn(1L, 1L, 1L, 1L, 1L);
|
assertThat(numbers.next("LP")).isEqualTo("LP202610200001");
|
||||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
assertThat(numbers.next("LP")).isEqualTo("LP202610200002");
|
||||||
|
assertThat(numbers.next("LP")).isEqualTo("LP202610209999");
|
||||||
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
|
verify(sequences,times(3)).next("LP:20261020");
|
||||||
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
|
}
|
||||||
assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class);
|
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
||||||
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
|
@Test void rejectsUnknownTypeAndOverflow() {
|
||||||
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
|
var sequences=mock(DocumentSequenceRepository.class);
|
||||||
|
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
||||||
eq("OB:20260818"));
|
verifyNoInteractions(sequences);
|
||||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
when(sequences.next("LP:20261020")).thenReturn(10000L);
|
||||||
eq("CABINET"));
|
assertThatThrownBy(()->numbers.next("LP")).isInstanceOf(BizException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
/** 柜次和实物标签明确不改格式,柜次允许超过四位。 */
|
||||||
void cabinetSequenceDoesNotResetOrStopAtFourDigits() {
|
@Test void preservesCabinetAndPalletRules() {
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
var sequences=mock(DocumentSequenceRepository.class);
|
||||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L);
|
when(sequences.next("CABINET")).thenReturn(9999L,10000L);
|
||||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
when(sequences.next("DP:20261020")).thenReturn(1L);
|
||||||
|
var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK);
|
||||||
assertThat(generator.nextCabinetNo()).isEqualTo("D9999");
|
assertThat(numbers.nextCabinetNo()).isEqualTo("D9999");
|
||||||
assertThat(generator.nextCabinetNo()).isEqualTo("D10000");
|
assertThat(numbers.nextCabinetNo()).isEqualTo("D10000");
|
||||||
}
|
assertThat(numbers.next("DP")).isEqualTo("DP-261020-0001");
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectsUnsupportedTypeAndDailyOverflow() {
|
|
||||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
|
||||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
|
||||||
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
|
|
||||||
|
|
||||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
|
||||||
assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,8 +462,9 @@ class SmartLoadingServiceTest {
|
|||||||
verify(repository).occupiedOrderIds(eq(query), anySet());
|
verify(repository).occupiedOrderIds(eq(query), anySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 待审核订单来自其他客户时仍可拼柜,客户归属和费用重算维持原值。 */
|
||||||
@Test
|
@Test
|
||||||
void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() {
|
void manuallyAssignsCrossCustomerAndChannelPendingReviewOrderAsAProvisionalContainerAllocation() {
|
||||||
long customerId = 2092797167699816450L;
|
long customerId = 2092797167699816450L;
|
||||||
long orderId = 2095052002489335810L;
|
long orderId = 2095052002489335810L;
|
||||||
String inboundCode = "EXXHK2609020361";
|
String inboundCode = "EXXHK2609020361";
|
||||||
@@ -503,6 +504,7 @@ class SmartLoadingServiceTest {
|
|||||||
assertThat(result.version()).isEqualTo(1);
|
assertThat(result.version()).isEqualTo(1);
|
||||||
ArgumentCaptor<LoadingPlanItemEntity> item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
ArgumentCaptor<LoadingPlanItemEntity> item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||||
verify(repository).insert(item.capture());
|
verify(repository).insert(item.capture());
|
||||||
|
assertThat(item.getValue().getAccountId()).isEqualTo(customerId);
|
||||||
assertThat(item.getValue().getInboundId()).isEqualTo(10L);
|
assertThat(item.getValue().getInboundId()).isEqualTo(10L);
|
||||||
assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L);
|
assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L);
|
||||||
assertThat(item.getValue().getPackageCount()).isEqualTo(5);
|
assertThat(item.getValue().getPackageCount()).isEqualTo(5);
|
||||||
@@ -1378,20 +1380,31 @@ class SmartLoadingServiceTest {
|
|||||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 订舱客户仅为关联资料;平台可将其他客户的货物加入同一柜次。 */
|
||||||
@Test
|
@Test
|
||||||
void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() {
|
void allowsManualAssignmentFromAnotherCustomerWithoutRewritingCargoOwnership() {
|
||||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
stubAssignment();
|
||||||
ContainerBookingEntity linked = booking();
|
|
||||||
linked.setCustomerId(88L);
|
|
||||||
when(repository.plan(1L)).thenReturn(plan);
|
|
||||||
when(repository.booking(99L)).thenReturn(linked);
|
|
||||||
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(5));
|
|
||||||
|
|
||||||
BizException error = catchThrowableOfType(() -> service.assign(1L,
|
Plan result = service.assign(1L,
|
||||||
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
|
new AssignRequest(10L, 20L, 1, 0, "assign-cross-customer"), 7L, "u");
|
||||||
|
|
||||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
|
assertThat(result.version()).isEqualTo(1);
|
||||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
ArgumentCaptor<LoadingPlanItemEntity> inserted = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||||
|
verify(repository).insert(inserted.capture());
|
||||||
|
assertThat(inserted.getValue().getAccountId()).isEqualTo(9L);
|
||||||
|
assertThat(inserted.getValue().getOrderId()).isEqualTo(30L);
|
||||||
|
verify(cargoClient).detail(anyString(), eq(9L), eq(30L), eq(20L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同渠道候选包含不同客户,不能被订舱客户快照裁剪。 */
|
||||||
|
@Test
|
||||||
|
void listsCandidatesFromBothCustomersInALinkedBooking() {
|
||||||
|
when(repository.booking(99L)).thenReturn(booking());
|
||||||
|
when(repository.candidates(100L)).thenReturn(List.of(
|
||||||
|
candidate(9L, 10L, 20L, 30L, 5), candidate(88L, 11L, 21L, 31L, 4)));
|
||||||
|
|
||||||
|
assertThat(service.candidates(99L, 100L)).extracting(Candidate::accountId)
|
||||||
|
.containsExactly(9L, 88L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -2075,9 +2088,11 @@ class SmartLoadingServiceTest {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 所有流程默认带历史订舱客户,覆盖候选、占位、整托、调整和推荐的跨客户拼柜。 */
|
||||||
private static ContainerBookingEntity booking() {
|
private static ContainerBookingEntity booking() {
|
||||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||||
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
|
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
|
||||||
|
value.setCustomerId(88L);
|
||||||
value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null);
|
value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null);
|
||||||
value.setContainerNo("MSCU1234567");
|
value.setContainerNo("MSCU1234567");
|
||||||
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
|
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.repository.DocumentSequenceRepository;
|
||||||
|
import java.time.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */
|
||||||
|
public final class TestDocumentNumbers {
|
||||||
|
/** 测试工具不实例化。 */
|
||||||
|
private TestDocumentNumbers() {}
|
||||||
|
/** 每个测试生成器独立计数,类型前缀之间互不占号。 */
|
||||||
|
public static FirstlegDocumentNumberGenerator documents() {
|
||||||
|
var sequences=mock(DocumentSequenceRepository.class);
|
||||||
|
var counters=new ConcurrentHashMap<String,AtomicLong>();
|
||||||
|
lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet());
|
||||||
|
return new FirstlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||||
|
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
|
||||||
import org.apache.ibatis.mapping.Environment;
|
import org.apache.ibatis.mapping.Environment;
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||||
import org.h2.jdbcx.JdbcDataSource;
|
import org.h2.jdbcx.JdbcDataSource;
|
||||||
@@ -10,6 +11,51 @@ import java.time.LocalDateTime;
|
|||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class ContainerCalendarSourceMapperTest {
|
class ContainerCalendarSourceMapperTest {
|
||||||
|
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
|
||||||
|
@Test
|
||||||
|
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
|
||||||
|
var ds = new JdbcDataSource();
|
||||||
|
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||||
|
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
|
||||||
|
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
|
||||||
|
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
|
||||||
|
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
|
||||||
|
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||||
|
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
|
||||||
|
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,query_id BIGINT,container_booking_id BIGINT," +
|
||||||
|
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
|
||||||
|
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
|
||||||
|
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
|
||||||
|
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
|
||||||
|
"(1,'D1','2026-10-29 08:00:00','ACTIVE'),(2,'D2','2026-10-29 08:00:00','ACTIVE')");
|
||||||
|
sql.execute("INSERT INTO fl_carrier_schedule_query VALUES(1,0,'READY'),(2,0,'READY')");
|
||||||
|
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
|
||||||
|
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES " +
|
||||||
|
"(1,1,1,'ARRIVED_DESTINATION_PORT','2026-10-23 14:00:00','2026-10-24 14:00:00')," +
|
||||||
|
"(2,1,1,'ARRIVED_DESTINATION_PORT','2026-10-21 14:00:00','2026-10-25 14:00:00')," +
|
||||||
|
"(3,1,1,'ARRIVED_DESTINATION_PORT',NULL,'2026-10-26 14:00:00')," +
|
||||||
|
"(4,2,2,'ARRIVED_DESTINATION_PORT','2026-10-24 14:00:00','2026-10-25 14:00:00')," +
|
||||||
|
"(5,2,2,'ARRIVED_DESTINATION_PORT','2026-10-22 14:00:00','2026-10-25 14:00:00')");
|
||||||
|
}
|
||||||
|
var config = new MybatisConfiguration();
|
||||||
|
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||||
|
config.addMapper(ContainerCalendarSourceMapper.class);
|
||||||
|
config.addMapper(CarrierScheduleQueryMapper.class);
|
||||||
|
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||||
|
var from = LocalDateTime.parse("2026-10-01T00:00:00");
|
||||||
|
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
|
||||||
|
var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
|
||||||
|
assertEquals(2, calendar.size());
|
||||||
|
assertEquals(2, workbench.size());
|
||||||
|
for (int index = 0; index < calendar.size(); index++) {
|
||||||
|
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
|
||||||
|
assertEquals(expected, calendar.get(index).arrivalAt());
|
||||||
|
assertEquals("ACTUAL", calendar.get(index).arrivalType());
|
||||||
|
assertEquals(expected, workbench.get(index).arrivedDestinationPortAt());
|
||||||
|
assertEquals(calendar.get(index).arrivalAt().plusDays(2).toLocalDate(), workbench.get(index).arrivedDestinationPortAt().plusDays(2).toLocalDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
|
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
|
||||||
@Test
|
@Test
|
||||||
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
|
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ class WorkflowBoundaryTest {
|
|||||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||||
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
||||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
|
||||||
|
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
|
||||||
|
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
|
||||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||||
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
var failure=new IllegalStateException("unavailable");
|
||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
when(downstream.read(any(),any())).thenThrow(failure);
|
||||||
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ VALUES
|
|||||||
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
|
(5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0),
|
||||||
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
|
(5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0),
|
||||||
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
|
(5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0),
|
||||||
|
(5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0),
|
||||||
|
(5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0),
|
||||||
|
(5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0),
|
||||||
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
|
(5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0),
|
||||||
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
|
(5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0),
|
||||||
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
|
(5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0),
|
||||||
@@ -164,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
|
|||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM iam_role role
|
FROM iam_role role
|
||||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit')
|
||||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.iam.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */
|
||||||
|
class FleetBusinessPermissionSeedSqlContractTest {
|
||||||
|
/** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */
|
||||||
|
@Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception {
|
||||||
|
String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
|
||||||
|
String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||||
|
for(String sql:List.of(local,unified)) {
|
||||||
|
for(var scope:List.of("owned-business","outsourced-business")) {
|
||||||
|
long base=scope.equals("owned-business")?5107390L:5107400L;
|
||||||
|
assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'");
|
||||||
|
assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'");
|
||||||
|
assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'");
|
||||||
|
assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */
|
||||||
|
@Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception {
|
||||||
|
String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql"));
|
||||||
|
var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
|
||||||
|
.map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList();
|
||||||
|
assertThat(grants).hasSize(1);
|
||||||
|
assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'",
|
||||||
|
"role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0");
|
||||||
|
}
|
||||||
|
/** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */
|
||||||
|
@Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception {
|
||||||
|
String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||||
|
for(long id:List.of(5107400L,5107401L,5107402L)) {
|
||||||
|
var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results()
|
||||||
|
.map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList();
|
||||||
|
assertThat(grants).hasSize(1);
|
||||||
|
assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
|
||||||
|
|
||||||
|
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
|
||||||
|
|
||||||
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
|
||||||
|
|
||||||
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
|
沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
|
||||||
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
|
||||||
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
|
||||||
|
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
|
||||||
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
|
||||||
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
### 预约、车队与导出依赖收口(2026-10-12)
|
### 预约、车队与导出依赖收口(2026-10-12)
|
||||||
|
|
||||||
- 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。
|
- 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。
|
||||||
- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。
|
- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号沿用 LB/TR 前缀,通过共享编号组件按工作区 `docs/业务单据编号契约.md` 分配,各入口共用本库日流水;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。
|
||||||
- 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。
|
- 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。
|
||||||
- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。
|
- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。
|
||||||
- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。
|
- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
|
|
||||||
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。
|
||||||
|
|
||||||
|
### 外配业务原排车详情读取边界(2026-10-21)
|
||||||
|
|
||||||
|
`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。
|
||||||
|
|
||||||
### 平台自营统计边界
|
### 平台自营统计边界
|
||||||
|
|
||||||
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
|
`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。
|
||||||
@@ -36,7 +40,7 @@
|
|||||||
|
|
||||||
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
|
车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。
|
||||||
|
|
||||||
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。
|
当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上述自有运营的五条公开只读引用及外配详情的一条公开模型引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。
|
||||||
|
|
||||||
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
|
2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ownedBusinessReadEdges = [
|
|||||||
['service.OwnedBusinessQueryService', 'DispatchTypes'],
|
['service.OwnedBusinessQueryService', 'DispatchTypes'],
|
||||||
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
|
['service.OwnedBusinessQueryService', 'DispatchDetailReader'],
|
||||||
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
|
].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort();
|
||||||
|
const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews'];
|
||||||
|
|
||||||
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
|
test('selfoperated analytics is independent of customer outbound and inventory slices', () => {
|
||||||
const report = analyze();
|
const report = analyze();
|
||||||
@@ -58,7 +59,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen
|
|||||||
}
|
}
|
||||||
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
|
assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false);
|
||||||
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.')
|
||||||
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges);
|
&& /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort());
|
||||||
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
|
const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.')
|
||||||
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
&& edge.target.startsWith('com.baiyue.lastleg.fleet.'));
|
||||||
assert.ok(dispatchEdges.length > 0);
|
assert.ok(dispatchEdges.length > 0);
|
||||||
@@ -101,6 +102,21 @@ test('owned business reads only reviewed dispatch ports and cannot inherit persi
|
|||||||
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
|
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => {
|
||||||
|
const report = analyze();
|
||||||
|
const baseline = JSON.parse(readFileSync(contract, 'utf8'));
|
||||||
|
const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.';
|
||||||
|
const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix)
|
||||||
|
&& edge.target.startsWith('com.baiyue.lastleg.dispatch.'));
|
||||||
|
assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges);
|
||||||
|
assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false);
|
||||||
|
for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) {
|
||||||
|
const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target };
|
||||||
|
report.classDependencies.push(edge);
|
||||||
|
assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function analyze(sourceSet = 'main') {
|
function analyze(sourceSet = 'main') {
|
||||||
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
|
||||||
cwd: moduleRoot,
|
cwd: moduleRoot,
|
||||||
|
|||||||
+8
-5
@@ -132,6 +132,7 @@ public class DeliveryTransferService {
|
|||||||
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations;
|
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations;
|
||||||
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
|
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
|
||||||
|
|
||||||
|
/** 创建一件代发出库任务,使用独立的 DFCK 日流水标识。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
|
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) {
|
||||||
String requestHash = oneClickRequestHash(request);
|
String requestHash = oneClickRequestHash(request);
|
||||||
@@ -145,7 +146,7 @@ public class DeliveryTransferService {
|
|||||||
String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓");
|
String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓");
|
||||||
OutboundOrder outbound = new OutboundOrder();
|
OutboundOrder outbound = new OutboundOrder();
|
||||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||||
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId()));
|
outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP"));
|
||||||
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
|
outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim());
|
||||||
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
|
outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size());
|
||||||
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||||
@@ -227,6 +228,7 @@ public class DeliveryTransferService {
|
|||||||
value(c.getTotalQuantity()), c.getStatus())).toList();
|
value(c.getTotalQuantity()), c.getStatus())).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验退货库存后建立出库和派送单,各自分配业务日流水。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId,
|
public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId,
|
||||||
DeliveryRequests.ReturnOutbound request) {
|
DeliveryRequests.ReturnOutbound request) {
|
||||||
@@ -279,7 +281,7 @@ public class DeliveryTransferService {
|
|||||||
}
|
}
|
||||||
OutboundOrder outbound = new OutboundOrder();
|
OutboundOrder outbound = new OutboundOrder();
|
||||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||||
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
|
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND"));
|
||||||
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||||
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
|
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
|
||||||
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||||
@@ -296,7 +298,7 @@ public class DeliveryTransferService {
|
|||||||
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
|
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
|
||||||
}
|
}
|
||||||
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||||
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId()));
|
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY"));
|
||||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||||
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
|
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
|
||||||
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
|
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
|
||||||
@@ -358,6 +360,7 @@ public class DeliveryTransferService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 为仓储转运建立普通出库和派送单,保留原库存关联。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId,
|
public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId,
|
||||||
DeliveryRequests.WarehouseTransferOutbound request) {
|
DeliveryRequests.WarehouseTransferOutbound request) {
|
||||||
@@ -397,7 +400,7 @@ public class DeliveryTransferService {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
OutboundOrder outbound = new OutboundOrder();
|
OutboundOrder outbound = new OutboundOrder();
|
||||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId()));
|
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
|
||||||
outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
||||||
outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||||
outbound.setRecipientInfo("PRIVATE".equals(destinationType)
|
outbound.setRecipientInfo("PRIVATE".equals(destinationType)
|
||||||
@@ -434,7 +437,7 @@ public class DeliveryTransferService {
|
|||||||
LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId());
|
LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||||
DeliveryOrder delivery = new DeliveryOrder();
|
DeliveryOrder delivery = new DeliveryOrder();
|
||||||
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||||
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId()));
|
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY"));
|
||||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||||
delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
||||||
delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName());
|
delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName());
|
||||||
|
|||||||
+2
-2
@@ -130,7 +130,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
|
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
|
||||||
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
|
||||||
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
|
var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer);
|
||||||
order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId()));
|
order.setOutboundNo(numbers.next("OUTBOUND"));
|
||||||
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
|
order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId());
|
||||||
order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes("");
|
order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes("");
|
||||||
order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
|
order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum()));
|
||||||
@@ -276,7 +276,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
|
|||||||
/** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */
|
/** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */
|
||||||
private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) {
|
private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) {
|
||||||
var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName());
|
var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName());
|
||||||
d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo());
|
d.setDeliveryNo(numbers.next("DELIVERY"));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo());
|
||||||
d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName());
|
d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName());
|
||||||
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
|
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
|
||||||
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
|
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ public class CargoDiscardOutboundService {
|
|||||||
LocalDateTime at = LocalDateTime.now();
|
LocalDateTime at = LocalDateTime.now();
|
||||||
OutboundOrder outbound = new OutboundOrder();
|
OutboundOrder outbound = new OutboundOrder();
|
||||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
|
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
|
||||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND"));
|
||||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
||||||
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
||||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
|
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
|
||||||
|
|||||||
+1
-1
@@ -222,7 +222,7 @@ public class CargoDiscardService {
|
|||||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||||
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
||||||
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
|
application.setDiscardNo(orderNoGenerator.next("DISCARD"));
|
||||||
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
|
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
|
||||||
application.setInboundCodes(java.util.stream.Stream.concat(
|
application.setInboundCodes(java.util.stream.Stream.concat(
|
||||||
java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode),
|
java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode),
|
||||||
|
|||||||
+3
-2
@@ -28,8 +28,9 @@ public class SmartDispatchController {
|
|||||||
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
|
||||||
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
|
||||||
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
|
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
|
||||||
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) {
|
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
|
||||||
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken)));
|
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
|
||||||
|
return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken,page,size,warehouseTab)));
|
||||||
}
|
}
|
||||||
/** 只列出当前拥有者未失效草稿。 */
|
/** 只列出当前拥有者未失效草稿。 */
|
||||||
@GetMapping("/drafts")
|
@GetMapping("/drafts")
|
||||||
|
|||||||
+37
-18
@@ -9,6 +9,21 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface DispatchCandidateMapper {
|
public interface DispatchCandidateMapper {
|
||||||
|
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||||
|
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||||
|
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||||
|
String DEPARTED_PLAN =
|
||||||
|
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||||
|
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
|
||||||
|
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||||
|
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
|
||||||
|
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
|
||||||
|
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
|
||||||
|
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
|
||||||
|
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
|
||||||
|
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
|
||||||
|
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
|
||||||
|
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
|
||||||
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
||||||
String CONFIRMED_APPOINTMENT_AT =
|
String CONFIRMED_APPOINTMENT_AT =
|
||||||
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
||||||
@@ -41,7 +56,7 @@ public interface DispatchCandidateMapper {
|
|||||||
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
|
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
|
||||||
|
|
||||||
String AVAILABLE_PLAN_BODY =
|
String AVAILABLE_PLAN_BODY =
|
||||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
|
||||||
"MIN(l.order_id) AS orderId," +
|
"MIN(l.order_id) AS orderId," +
|
||||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||||
@@ -53,16 +68,16 @@ public interface DispatchCandidateMapper {
|
|||||||
"MIN(ip.container_no) AS containerNo," +
|
"MIN(ip.container_no) AS containerNo," +
|
||||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||||
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
"CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
||||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||||
@@ -70,11 +85,11 @@ public interface DispatchCandidateMapper {
|
|||||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||||
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
||||||
@@ -108,12 +123,13 @@ public interface DispatchCandidateMapper {
|
|||||||
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
||||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||||
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
||||||
@@ -150,7 +166,7 @@ public interface DispatchCandidateMapper {
|
|||||||
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
|
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
|
||||||
|
|
||||||
String SELECTED_PLAN_SQL = "<script>" +
|
String SELECTED_PLAN_SQL = "<script>" +
|
||||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
|
||||||
"MIN(l.order_id) AS orderId," +
|
"MIN(l.order_id) AS orderId," +
|
||||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||||
@@ -161,16 +177,16 @@ public interface DispatchCandidateMapper {
|
|||||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||||
"MIN(ip.container_no) AS containerNo," +
|
"MIN(ip.container_no) AS containerNo," +
|
||||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||||
@@ -178,11 +194,11 @@ public interface DispatchCandidateMapper {
|
|||||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||||
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
||||||
@@ -216,7 +232,8 @@ public interface DispatchCandidateMapper {
|
|||||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||||
|
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||||
@@ -261,7 +278,8 @@ public interface DispatchCandidateMapper {
|
|||||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||||
"AND pos.hold_packages > 0 " +
|
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
|
||||||
|
"AND pos.hold_packages > 0 AND pos.pallet_id > 0 " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||||
"ON existing_dispatch.id=di.dispatch_order_id " +
|
"ON existing_dispatch.id=di.dispatch_order_id " +
|
||||||
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
|
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
|
||||||
@@ -283,8 +301,9 @@ public interface DispatchCandidateMapper {
|
|||||||
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
|
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
|
||||||
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
|
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
|
||||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
|
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
|
||||||
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.weight) THEN SUM(x.weight) END FROM (SELECT DISTINCT c2.id,c2.weight FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
|
// 从唯一实箱汇总并用EXISTS核对申请,避免一箱多明细重复计量和跨层相关派生表。
|
||||||
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.volume) THEN SUM(x.volume) END FROM (SELECT DISTINCT c2.id,(c2.length*c2.width*c2.height)/1000000 AS volume FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
|
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.weight) THEN SUM(c2.weight) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," +
|
||||||
|
"COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.length*c2.width*c2.height) THEN SUM(c2.length*c2.width*c2.height/1000000) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," +
|
||||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
|
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
|
||||||
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
|
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
|
||||||
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
|
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
|
||||||
|
import org.apache.ibatis.annotations.*;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/** 四种来源共用数据库分页;页外来源不进入测量、完整组和预约的逐组装配。 */
|
||||||
|
@Mapper
|
||||||
|
public interface DispatchCandidatePageMapper {
|
||||||
|
/** 统计相同筛选下的来源行,不把当前页条数当全仓总数。 */
|
||||||
|
@SelectProvider(type=Sql.class,method="count")
|
||||||
|
long count(@Param("query")CandidateQuery query);
|
||||||
|
/** 稳定排序后在数据库限页,完整组可包含页外成员。 */
|
||||||
|
@SelectProvider(type=Sql.class,method="page")
|
||||||
|
List<DispatchPalletCandidate> page(@Param("query")CandidateQuery query,@Param("offset")long offset);
|
||||||
|
|
||||||
|
/** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */
|
||||||
|
final class Sql {
|
||||||
|
private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource";
|
||||||
|
private static final Set<String> TEXT_FIELDS=Set.of(("customerId,customerName,planNo,orderType,containerNo,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptNo").split(","));
|
||||||
|
private static final Set<String> PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource");
|
||||||
|
private static final Set<String> RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(","));
|
||||||
|
private static final Set<String> NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(","));
|
||||||
|
private static final String BINDINGS="<bind name='customerId' value='null'/><bind name='relabelNo' value='null'/>"
|
||||||
|
+"<bind name='planIds' value='query.planIds'/><bind name='orderNo' value='query.orderNo'/>"
|
||||||
|
+"<bind name='fbaWarehouseCode' value='query.destinationWarehouseCode'/><bind name='fba' value='query.destinationWarehouseCode'/>"
|
||||||
|
+"<bind name='sourceStage' value='query.sourceStage'/><bind name='draftToken' value='query.draftToken'/>";
|
||||||
|
/** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */
|
||||||
|
private static String project(String body,Set<String> fields,boolean count) {
|
||||||
|
return "SELECT "+Arrays.stream((count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS).split(",")).map(field->column(field,fields.contains(field)?"s."+field:
|
||||||
|
("returnCartonSource".equals(field)?"0":"NULL"))).collect(Collectors.joining(","))
|
||||||
|
+" FROM ("+body+") s";
|
||||||
|
}
|
||||||
|
/** 在UNION前统一文本列,避免表字段、CAST及聚合结果采用不同collation而报1271;数值与时间保持原类型。 */
|
||||||
|
private static String column(String field,String expression) {
|
||||||
|
return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_unicode_ci":expression)+" AS "+field;
|
||||||
|
}
|
||||||
|
/** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */
|
||||||
|
private static String rows(boolean count) {
|
||||||
|
var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING);
|
||||||
|
var fields=count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS;
|
||||||
|
return "<choose><when test=\"sourceStage != 'RELABEL'\">"+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields,count)
|
||||||
|
+"</when><otherwise>SELECT "+Arrays.stream(fields.split(",")).map(f->column(f,"NULL")).collect(Collectors.joining(","))+" WHERE 1=0</otherwise></choose>"
|
||||||
|
+"<if test='planIds == null or planIds.isEmpty()'>"
|
||||||
|
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED'\"> UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS,count)+"</if>"
|
||||||
|
+"<if test=\"sourceStage == null or sourceStage == 'RECEIVED' or sourceStage == 'RELABEL'\"> UNION ALL "
|
||||||
|
+project(DispatchCandidateMapper.RELABEL_BODY,planFields,count)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS,count)+"</if></if>";
|
||||||
|
}
|
||||||
|
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
|
||||||
|
private static String filtered(boolean count) {
|
||||||
|
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
|
||||||
|
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
|
||||||
|
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
|
||||||
|
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
|
||||||
|
}
|
||||||
|
/** 同一事务内统计分页全集。 */
|
||||||
|
public static String count(){return "<script>"+BINDINGS+"SELECT COUNT(*)"+filtered(true)+"</script>";}
|
||||||
|
/** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */
|
||||||
|
public static String page(){return "<script>"+BINDINGS+"SELECT *"+filtered(false)
|
||||||
|
+" ORDER BY customerId,orderNo,palletNo,sourceStage,planPalletId,returnReceiptId,orderRefs,returnCartonSource"
|
||||||
|
+" LIMIT #{query.size} OFFSET #{offset}</script>";}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Options;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
|
||||||
|
@Mapper
|
||||||
|
public interface DispatchNumberSequenceMapper {
|
||||||
|
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
|
||||||
|
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
|
||||||
|
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
|
||||||
|
int allocate(@Param("date") LocalDate date);
|
||||||
|
|
||||||
|
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
|
||||||
|
@Select("SELECT LAST_INSERT_ID()")
|
||||||
|
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||||
|
Long allocatedValue();
|
||||||
|
}
|
||||||
+23
@@ -11,6 +11,8 @@ public interface DispatchSourceMapper {
|
|||||||
long allocatedPackages, long pickedPackages, long version) { }
|
long allocatedPackages, long pickedPackages, long version) { }
|
||||||
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
|
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
|
||||||
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
|
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
|
||||||
|
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
|
||||||
|
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
|
||||||
record Claim(Long id, Long dispatchId, String draftToken) { }
|
record Claim(Long id, Long dispatchId, String draftToken) { }
|
||||||
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
||||||
record NativeApplication(String customerId,String relabelNo) { }
|
record NativeApplication(String customerId,String relabelNo) { }
|
||||||
@@ -112,6 +114,27 @@ public interface DispatchSourceMapper {
|
|||||||
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
|
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
|
||||||
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
|
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
|
||||||
|
|
||||||
|
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
|
||||||
|
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
|
||||||
|
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
|
||||||
|
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
|
||||||
|
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
|
||||||
|
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||||
|
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||||
|
|
||||||
|
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
|
||||||
|
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
|
||||||
|
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
|
||||||
|
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
|
||||||
|
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||||
|
|
||||||
|
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
|
||||||
|
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
|
||||||
|
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
|
||||||
|
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
|
||||||
|
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
|
||||||
|
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||||
|
|
||||||
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
|
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
|
||||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
|
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
|
||||||
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
|
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
|
||||||
|
|||||||
+5
-2
@@ -12,7 +12,8 @@ public final class DispatchPlanningModels {
|
|||||||
private DispatchPlanningModels() { }
|
private DispatchPlanningModels() { }
|
||||||
|
|
||||||
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
|
public record CandidateQuery(String warehouseId, List<Long> planIds, String orderNo,
|
||||||
String destinationWarehouseCode, String sourceStage, String draftToken) { }
|
String destinationWarehouseCode, String sourceStage, String draftToken,
|
||||||
|
int page, int size, String warehouseTab) { }
|
||||||
|
|
||||||
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
|
/** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */
|
||||||
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
|
public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion,
|
||||||
@@ -36,7 +37,9 @@ public final class DispatchPlanningModels {
|
|||||||
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
|
public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List<String> sourceKeys,
|
||||||
long packageCount) { }
|
long packageCount) { }
|
||||||
|
|
||||||
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete) { }
|
/** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */
|
||||||
|
public record CandidateList(List<Candidate> candidates, Instant snapshotAt, boolean complete,
|
||||||
|
long total, int page, int size, long pages) { }
|
||||||
|
|
||||||
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
|
public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity,
|
||||||
List<String> sourceKeys) { }
|
List<String> sourceKeys) { }
|
||||||
|
|||||||
+6
-6
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository;
|
|||||||
|
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -11,17 +11,17 @@ import java.time.LocalDate;
|
|||||||
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
|
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
|
||||||
@Repository
|
@Repository
|
||||||
public class DispatchNumberSequence {
|
public class DispatchNumberSequence {
|
||||||
private final JdbcTemplate jdbc;
|
private final DispatchNumberSequenceMapper mapper;
|
||||||
|
|
||||||
/** 流水与排车主单使用相同服务数据库。 */
|
/** 流水与排车主单使用相同服务数据库。 */
|
||||||
public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
|
||||||
|
|
||||||
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
|
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public long next(LocalDate date) {
|
public long next(LocalDate date) {
|
||||||
jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
|
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
|
||||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
|
mapper.allocate(date);
|
||||||
Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
Long value = mapper.allocatedValue();
|
||||||
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
|
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
|
||||||
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
|
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
+25
@@ -11,6 +11,13 @@ import java.util.List;
|
|||||||
/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */
|
/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */
|
||||||
@Repository
|
@Repository
|
||||||
public class DispatchSourceRepository {
|
public class DispatchSourceRepository {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper pages;
|
||||||
|
/** 筛选全集的数据库行数,与分页使用同一只读事务。 */
|
||||||
|
public long candidateCount(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){return pages.count(query);}
|
||||||
|
/** 只返回当前页来源,禁止先全量装配再截取。 */
|
||||||
|
public List<DispatchPalletCandidate> candidatePage(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){
|
||||||
|
return pages.page(query,Math.multiplyExact((long)query.page()-1,query.size()));
|
||||||
|
}
|
||||||
private final DispatchCandidateMapper plans;
|
private final DispatchCandidateMapper plans;
|
||||||
private final ReturnDispatchCandidateMapper returns;
|
private final ReturnDispatchCandidateMapper returns;
|
||||||
private final DispatchSourceMapper source;
|
private final DispatchSourceMapper source;
|
||||||
@@ -75,6 +82,24 @@ public class DispatchSourceRepository {
|
|||||||
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
|
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
|
||||||
/** 同一实托的退回实收不得从来源证明丢失。 */
|
/** 同一实托的退回实收不得从来源证明丢失。 */
|
||||||
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
|
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
|
||||||
|
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
|
||||||
|
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
|
||||||
|
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
|
||||||
|
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
|
||||||
|
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
|
||||||
|
if(pallets.isEmpty())return java.util.Map.of();
|
||||||
|
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
|
||||||
|
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
|
||||||
|
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
|
||||||
|
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
|
||||||
|
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
|
||||||
|
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
|
||||||
|
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
|
||||||
|
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
|
||||||
|
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
|
||||||
|
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
|
||||||
|
return java.util.Collections.unmodifiableMap(result);
|
||||||
|
}
|
||||||
/** 释放已到期草稿键。 */
|
/** 释放已到期草稿键。 */
|
||||||
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
|
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
|
||||||
/** 锁定当前活动资源占用。 */
|
/** 锁定当前活动资源占用。 */
|
||||||
|
|||||||
+22
-4
@@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
|||||||
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
||||||
@Service
|
@Service
|
||||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||||
|
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||||
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
||||||
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
||||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||||
@@ -147,11 +148,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
requirePlatform(actor);
|
requirePlatform(actor);
|
||||||
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
|
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
|
||||||
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
|
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
|
||||||
|
if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法");
|
||||||
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()),query.page(),query.size(),query.warehouseTab());
|
||||||
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
long started=System.nanoTime();
|
||||||
|
var page=sourceRules.candidatePage(actor.operatorId(),normalized);
|
||||||
|
var rows=page.rows();
|
||||||
|
long candidatesRead=System.nanoTime();
|
||||||
var groups=sourceRules.selectionGroups(rows);
|
var groups=sourceRules.selectionGroups(rows);
|
||||||
|
long groupsRead=System.nanoTime();
|
||||||
var batchFacts=appointments.candidates(rows);
|
var batchFacts=appointments.candidates(rows);
|
||||||
|
long batchesRead=System.nanoTime();
|
||||||
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
||||||
for(var row:rows) {
|
for(var row:rows) {
|
||||||
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
||||||
@@ -181,7 +188,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
|
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
|
||||||
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
|
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
|
||||||
}
|
}
|
||||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
|
long finished=System.nanoTime();
|
||||||
|
// 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。
|
||||||
|
if(finished-started>=1_000_000_000L)LOG.warn(
|
||||||
|
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
|
||||||
|
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
|
||||||
|
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
|
||||||
|
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),
|
||||||
|
normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(),
|
||||||
|
(page.total()+normalized.size()-1)/normalized.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
|
/** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */
|
||||||
@@ -509,7 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
|
var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request);
|
||||||
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
|
||||||
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
|
if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车");
|
||||||
lockExecutionSources(row,repository.cargo(row.getId()));
|
var items=repository.cargo(row.getId());
|
||||||
|
// 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。
|
||||||
|
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
|
||||||
|
lockExecutionSources(row,items);
|
||||||
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
|
||||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||||
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
|
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
|
||||||
|
|||||||
+38
-1
@@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
|||||||
@Service
|
@Service
|
||||||
public class DispatchPdaQueryService {
|
public class DispatchPdaQueryService {
|
||||||
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
|
||||||
|
@jakarta.annotation.Resource private DispatchService sourceRules;
|
||||||
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
|
||||||
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
|
||||||
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
|
||||||
@@ -28,7 +30,7 @@ public class DispatchPdaQueryService {
|
|||||||
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
||||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
||||||
}
|
}
|
||||||
/** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */
|
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
|
||||||
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
||||||
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
|
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
|
||||||
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
|
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
|
||||||
@@ -59,6 +61,8 @@ public class DispatchPdaQueryService {
|
|||||||
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
|
||||||
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
|
||||||
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
|
||||||
|
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
|
||||||
|
sourcePositions=receivedPlanPositions(actor,item,items);
|
||||||
if(normalRelabel(item)) {
|
if(normalRelabel(item)) {
|
||||||
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
|
||||||
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
|
||||||
@@ -89,6 +93,39 @@ public class DispatchPdaQueryService {
|
|||||||
}
|
}
|
||||||
return List.copyOf(result);
|
return List.copyOf(result);
|
||||||
}
|
}
|
||||||
|
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
|
||||||
|
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
|
||||||
|
if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of();
|
||||||
|
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
|
||||||
|
if(rows.size()!=1)return List.of();
|
||||||
|
var actual=rows.get(0);
|
||||||
|
if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null
|
||||||
|
|| !Objects.equals(actual.planPalletId(),item.getPlanPalletId())
|
||||||
|
|| !Objects.equals(actual.customerId(),item.getCustomerId())
|
||||||
|
|| !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId())
|
||||||
|
|| !Objects.equals(actual.packageCount(),item.getPackageCount())
|
||||||
|
|| !Objects.equals(actual.destinationType(),item.getDestinationType())
|
||||||
|
|| !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode())
|
||||||
|
|| !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of();
|
||||||
|
var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
|
||||||
|
if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll(
|
||||||
|
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of();
|
||||||
|
// 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。
|
||||||
|
var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection(
|
||||||
|
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
|
||||||
|
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of();
|
||||||
|
for(var member:group.sources()) {
|
||||||
|
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
|
||||||
|
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId())
|
||||||
|
|| n(selected.getPackageCount())!=member.packages())return List.of();
|
||||||
|
}
|
||||||
|
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
|
||||||
|
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId())
|
||||||
|
|| !Objects.equals(p.palletId(),actual.warehousePalletId()))
|
||||||
|
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of();
|
||||||
|
return stock.stream().filter(p->p.availablePackages()>0)
|
||||||
|
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList();
|
||||||
|
}
|
||||||
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
/** 仓和操作者均来自受信身份,不接收请求自报。 */
|
||||||
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
|
||||||
/** 缺失计数不作为业务标识使用。 */
|
/** 缺失计数不作为业务标识使用。 */
|
||||||
|
|||||||
+80
-29
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
|||||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||||
|
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
|
||||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -34,16 +35,64 @@ public class DispatchService {
|
|||||||
|
|
||||||
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
||||||
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
||||||
|
var reads=new CandidateReadScope(rows);
|
||||||
var result=new LinkedHashMap<String,SelectionGroup>();
|
var result=new LinkedHashMap<String,SelectionGroup>();
|
||||||
for(var row:rows) {
|
for(var row:rows) {
|
||||||
String key=wholeSourceKey(selection(row,null));
|
String key=wholeSourceKey(selection(row,null));
|
||||||
if(result.containsKey(key))continue;
|
if(result.containsKey(key))continue;
|
||||||
var group=sourceGroup(row,false);
|
var group=sourceGroup(row,false,reads);
|
||||||
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
|
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
|
||||||
}
|
}
|
||||||
return Collections.unmodifiableMap(result);
|
return Collections.unmodifiableMap(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
|
||||||
|
private final class CandidateReadScope {
|
||||||
|
private final Map<String,Set<Long>> loaded=new HashMap<>();
|
||||||
|
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
|
||||||
|
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
|
||||||
|
|
||||||
|
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
|
||||||
|
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
|
||||||
|
var pallets=new TreeMap<String,Set<Long>>();
|
||||||
|
for(var row:rows)if(row.warehousePalletId()!=null)
|
||||||
|
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
|
||||||
|
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
|
||||||
|
loadFacts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
|
||||||
|
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
|
||||||
|
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
|
||||||
|
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
|
||||||
|
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
|
||||||
|
if(!missing.isEmpty()) {
|
||||||
|
var rows=wholeSources.physicalAvailable(warehouse,missing);
|
||||||
|
for(var row:rows)
|
||||||
|
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
|
||||||
|
loadFacts(rows);
|
||||||
|
known.addAll(missing);
|
||||||
|
}
|
||||||
|
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
|
||||||
|
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
|
||||||
|
var missing=new TreeMap<String,Set<Long>>();
|
||||||
|
for(var row:rows)if(row.warehousePalletId()!=null
|
||||||
|
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
|
||||||
|
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
|
||||||
|
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
|
||||||
|
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
|
||||||
|
private SourceFacts facts(DispatchPalletCandidate row) {
|
||||||
|
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
|
||||||
|
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
|
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
|
||||||
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
|
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
|
||||||
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
||||||
@@ -78,6 +127,11 @@ public class DispatchService {
|
|||||||
|
|
||||||
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
||||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
|
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
|
||||||
|
return sourceGroup(seed,locked,null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
|
||||||
|
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
|
||||||
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
|
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
|
||||||
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
|
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
|
||||||
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
|
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
|
||||||
@@ -90,7 +144,9 @@ public class DispatchService {
|
|||||||
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
||||||
reasons.addAll(facts.blockReasons());
|
reasons.addAll(facts.blockReasons());
|
||||||
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
||||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
|
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
|
||||||
|
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
|
||||||
|
.filter(id->id!=null && id>0).forEach(pallets::add);
|
||||||
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
||||||
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
||||||
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
|
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
|
||||||
@@ -109,7 +165,8 @@ public class DispatchService {
|
|||||||
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
|
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
|
||||||
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
||||||
if(!pallets.isEmpty()) {
|
if(!pallets.isEmpty()) {
|
||||||
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
|
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
|
||||||
|
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||||
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
|
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
|
||||||
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
|
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
|
||||||
for(var entry:buckets.entrySet()) {
|
for(var entry:buckets.entrySet()) {
|
||||||
@@ -130,7 +187,8 @@ public class DispatchService {
|
|||||||
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
|
if(reads!=null)reads.loadFacts(rows.values());
|
||||||
|
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
|
||||||
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
|
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
|
||||||
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
|
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
|
||||||
var sources=new ArrayList<SelectionGroupSource>();
|
var sources=new ArrayList<SelectionGroupSource>();
|
||||||
@@ -154,32 +212,22 @@ public class DispatchService {
|
|||||||
public record WholeSource(DispatchPalletCandidate candidate,
|
public record WholeSource(DispatchPalletCandidate candidate,
|
||||||
com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { }
|
com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { }
|
||||||
|
|
||||||
/** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */
|
/** 总数与当前页同事务读取;页外候选不参与逐组装配。 */
|
||||||
public List<DispatchPalletCandidate> wholeCandidates(String operator,
|
public record CandidatePage(List<DispatchPalletCandidate> rows,long total) { }
|
||||||
com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) {
|
|
||||||
|
/** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */
|
||||||
|
public CandidatePage candidatePage(String operator,CandidateQuery query) {
|
||||||
validateSourceStage(query.sourceStage());
|
validateSourceStage(query.sourceStage());
|
||||||
if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream()
|
if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream()
|
||||||
.noneMatch(d -> query.draftToken().equals(d.getDraftToken())))
|
.noneMatch(d -> query.draftToken().equals(d.getDraftToken())))
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源");
|
||||||
var result=new java.util.ArrayList<DispatchPalletCandidate>();
|
long total=wholeSources.candidateCount(query);
|
||||||
if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query));
|
var rows=wholeSources.candidatePage(query);
|
||||||
if(query.planIds()==null || query.planIds().isEmpty()) {
|
measuredRelabelCandidates(rows.stream().filter(row->"RELABEL".equals(row.sourceStage()) && !row.returnCartonSource()).toList());
|
||||||
if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query));
|
var nativeRows=rows.stream().filter(DispatchPalletCandidate::returnCartonSource).toList();
|
||||||
if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) {
|
if(!nativeRows.isEmpty())returnRelabelStock.resolvePage(nativeRows);
|
||||||
var rows=wholeSources.availableRelabel(query);
|
return new CandidatePage(List.copyOf(rows),total);
|
||||||
if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
|
|
||||||
result.addAll(measuredRelabelCandidates(rows));
|
|
||||||
result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围");
|
|
||||||
return result.stream().filter(row -> query.destinationWarehouseCode()==null
|
|
||||||
|| query.destinationWarehouseCode().equals(row.fbaWarehouseCode()))
|
|
||||||
.sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),""))
|
|
||||||
.thenComparing(row -> Objects.toString(row.orderNo(),""))
|
|
||||||
.thenComparing(row -> Objects.toString(row.palletNo(),""))).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
|
/** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */
|
||||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||||
public List<WholeSource> lockWholeSources(String warehouseId,
|
public List<WholeSource> lockWholeSources(String warehouseId,
|
||||||
@@ -397,7 +445,7 @@ public class DispatchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
|
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
|
||||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
|
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
|
||||||
var facts = new java.util.TreeMap<String,Object>();
|
var facts = new java.util.TreeMap<String,Object>();
|
||||||
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
||||||
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
|
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
|
||||||
@@ -409,12 +457,15 @@ public class DispatchService {
|
|||||||
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
|
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
|
||||||
facts.put("cartonMarks",row.relabelCartonMarks());
|
facts.put("cartonMarks",row.relabelCartonMarks());
|
||||||
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
|
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
|
||||||
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
|
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
|
||||||
|
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
|
||||||
|
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
||||||
|
var stock=basis.stock();
|
||||||
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
|
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
||||||
facts.put("stock",stock);
|
facts.put("stock",stock);
|
||||||
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
|
facts.put("receipts",basis.receipts());
|
||||||
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
facts.put("returnedReceipts",basis.returnedReceipts());
|
||||||
return wholeHashes.source(facts);
|
return wholeHashes.source(facts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock {
|
|||||||
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
|
return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整车候选只解析已经按仓限定且有界的原生退货记录。 */
|
/** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */
|
||||||
public List<DispatchPalletCandidate> wholeCandidates(String warehouse,String orderNo) {
|
public void resolvePage(List<DispatchPalletCandidate> rows) {
|
||||||
var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null);
|
if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查");
|
||||||
if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围");
|
|
||||||
return resolve(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
|
/** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */
|
||||||
|
|||||||
+2
-1
@@ -418,6 +418,7 @@ public class ExceptionOrderService {
|
|||||||
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of());
|
tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存异常单及来源关系,异常编号使用 EX 日流水。 */
|
||||||
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
|
private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery,
|
||||||
String type, String reason, Integer packages, String sku,
|
String type, String reason, Integer packages, String sku,
|
||||||
String direction, String logisticsNo, String sourceType,
|
String direction, String logisticsNo, String sourceType,
|
||||||
@@ -428,7 +429,7 @@ public class ExceptionOrderService {
|
|||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
ExceptionOrder value = new ExceptionOrder();
|
ExceptionOrder value = new ExceptionOrder();
|
||||||
value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId());
|
value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId());
|
||||||
value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId()));
|
value.setExceptionNo(orderNoGenerator.next("EXCEPTION"));
|
||||||
value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason));
|
value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason));
|
||||||
value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo());
|
value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo());
|
||||||
value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode());
|
value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode());
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ public class ReturnCartonReturnService {
|
|||||||
var pallet=pallets.resolve(input.targetPalletNo());
|
var pallet=pallets.resolve(input.targetPalletNo());
|
||||||
var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode());
|
var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode());
|
||||||
if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓");
|
if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓");
|
||||||
LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true;
|
LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT");boolean first=true;
|
||||||
for(var movement:shipped) {
|
for(var movement:shipped) {
|
||||||
repository.restore(movement);
|
repository.restore(movement);
|
||||||
var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId());
|
var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId());
|
||||||
|
|||||||
+3
-3
@@ -22,13 +22,13 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
|
|||||||
public class FleetPoolOrderBridge {
|
public class FleetPoolOrderBridge {
|
||||||
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
|
@jakarta.annotation.Resource private FleetLooseboardDemandRepository demands;
|
||||||
private final FleetPoolOrderRepository repository;
|
private final FleetPoolOrderRepository repository;
|
||||||
private final FleetPoolOrderNumberGenerator numbers;
|
private final com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers;
|
||||||
private final ObjectMapper json;
|
private final ObjectMapper json;
|
||||||
private final FleetPoolDeliveryEvidence deliveryEvidence;
|
private final FleetPoolDeliveryEvidence deliveryEvidence;
|
||||||
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
|
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
|
||||||
/** 注入同库商业委托仓储及履约协作服务。 */
|
/** 注入同库商业委托仓储及履约协作服务。 */
|
||||||
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
|
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
|
||||||
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
|
com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
|
||||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
|
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
|
||||||
this.repository=repository;
|
this.repository=repository;
|
||||||
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
|
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
|
||||||
@@ -53,7 +53,7 @@ public class FleetPoolOrderBridge {
|
|||||||
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
|
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
|
||||||
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
|
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
|
||||||
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
|
row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId());
|
||||||
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo());
|
row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB"):source.getOrderNo());
|
||||||
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
|
row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey());
|
||||||
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
|
row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions());
|
||||||
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
|
row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L);
|
||||||
|
|||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
package com.baiyue.lastleg.fleet.business;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */
|
|
||||||
@Component
|
|
||||||
public class FleetPoolOrderNumberGenerator {
|
|
||||||
private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD");
|
|
||||||
public String next(String businessType,String destination,long uniqueId){
|
|
||||||
String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");};
|
|
||||||
return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
|
|
||||||
+"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000));
|
|
||||||
}
|
|
||||||
private static String location(String value){
|
|
||||||
String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code);
|
|
||||||
if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group();
|
|
||||||
return code.matches("[A-Z0-9]{2,8}")?code:"WH";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-1
@@ -22,6 +22,8 @@ import java.util.*;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class VehicleCareService {
|
public class VehicleCareService {
|
||||||
|
@jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers;
|
||||||
|
|
||||||
private final VehicleCareMapper records;
|
private final VehicleCareMapper records;
|
||||||
private final VehicleCareHistoryMapper histories;
|
private final VehicleCareHistoryMapper histories;
|
||||||
private final VehicleRepository vehicles;
|
private final VehicleRepository vehicles;
|
||||||
@@ -41,6 +43,7 @@ public class VehicleCareService {
|
|||||||
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||||
}
|
}
|
||||||
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
|
public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); }
|
||||||
|
/** 建立车辆运维记录,首次建单分配 VR 编号。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
|
public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) {
|
||||||
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
|
// Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response.
|
||||||
@@ -53,7 +56,7 @@ public class VehicleCareService {
|
|||||||
}
|
}
|
||||||
validate(request.details());
|
validate(request.details());
|
||||||
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
|
var row = new VehicleCareEntity(); row.setId(IdWorker.getId());
|
||||||
row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
|
row.setRecordNo(documentNumbers.next("VR"));
|
||||||
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
|
row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType());
|
||||||
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
|
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]");
|
||||||
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
|
row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L);
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
|
||||||
|
public class OutsourcedBusinessController {
|
||||||
|
private final OutsourcedBusinessService service;
|
||||||
|
private final OwnedBusinessSupport access;
|
||||||
|
/** 会话只转真实平台操作者及其原权限集合。 */
|
||||||
|
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
|
||||||
|
/** 同筛选分页和完整阶段统计一次返回。 */
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||||
|
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
|
||||||
|
/** 列表权限包含详情,不借原排车或自有写权限。 */
|
||||||
|
@GetMapping("/{dispatchNo}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||||
|
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
|
||||||
|
/** 原主单和本页附加历史保持各自版本字段。 */
|
||||||
|
@GetMapping("/{dispatchNo}/history")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||||
|
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||||
|
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
|
||||||
|
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
|
||||||
|
}
|
||||||
|
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
|
||||||
|
@PutMapping("/{dispatchNo}/operations")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
|
||||||
|
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
|
||||||
|
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
|
||||||
|
}
|
||||||
|
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
|
||||||
|
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||||
|
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||||
|
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||||
|
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
|
||||||
|
}
|
||||||
|
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
|
||||||
|
@PutMapping("/{dispatchNo}/photos/{slot}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||||
|
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||||
|
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
|
||||||
|
}
|
||||||
|
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
|
||||||
|
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||||
|
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||||
|
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
|
||||||
|
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
|
||||||
|
.header("X-Content-Type-Options","nosniff").body(result.content());
|
||||||
|
}
|
||||||
|
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
|
||||||
|
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||||
|
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||||
|
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
|
||||||
|
}
|
||||||
|
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
|
||||||
|
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
|
||||||
|
public final class OutsourcedBusinessModels {
|
||||||
|
/** DTO容器不允许实例化。 */
|
||||||
|
private OutsourcedBusinessModels() {}
|
||||||
|
public static final String LIST="platform:fleet:outsourced-business:list";
|
||||||
|
public static final String EDIT="platform:fleet:outsourced-business:edit";
|
||||||
|
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
|
||||||
|
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
|
||||||
|
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
|
||||||
|
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
|
||||||
|
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||||
|
}
|
||||||
|
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||||
|
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
|
||||||
|
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
||||||
|
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||||
|
}
|
||||||
|
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
|
||||||
|
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
||||||
|
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||||
|
}
|
||||||
|
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
|
||||||
|
public record PhotoSummary(int loadingCount) {}
|
||||||
|
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||||
|
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
|
||||||
|
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
|
||||||
|
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
|
||||||
|
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
|
||||||
|
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
|
||||||
|
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
|
||||||
|
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
|
||||||
|
public record HistoryPage(List<History> records,int page,int size,int total) {}
|
||||||
|
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
|
||||||
|
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
|
||||||
|
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
|
||||||
|
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import java.util.List;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
|
||||||
|
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
|
||||||
|
|
||||||
|
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
|
||||||
|
@Service
|
||||||
|
public class OutsourcedBusinessService {
|
||||||
|
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
|
||||||
|
private final OwnedBusinessQueryService queries;
|
||||||
|
private final OwnedBusinessRecordService records;
|
||||||
|
private final OwnedBusinessPhotoService photos;
|
||||||
|
private final OwnedBusinessProjection projection;
|
||||||
|
private final OwnedBusinessSupport codec;
|
||||||
|
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
|
||||||
|
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
|
||||||
|
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
|
||||||
|
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
|
||||||
|
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
|
||||||
|
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
|
||||||
|
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
|
||||||
|
}
|
||||||
|
/** 三个页签读取同一原排车详情和外配附加记录。 */
|
||||||
|
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
|
||||||
|
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
|
||||||
|
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
|
||||||
|
var result=queries.history(actor,number,page,size,SCOPE);
|
||||||
|
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
|
||||||
|
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
|
||||||
|
}
|
||||||
|
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
|
||||||
|
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
|
||||||
|
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||||
|
var ops=request.operations();
|
||||||
|
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
|
||||||
|
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
|
||||||
|
}
|
||||||
|
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
|
||||||
|
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
|
||||||
|
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
|
||||||
|
}
|
||||||
|
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
||||||
|
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||||
|
if(request==null)throw bad("照片绑定请求缺失");
|
||||||
|
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
|
||||||
|
}
|
||||||
|
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
||||||
|
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||||
|
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
|
||||||
|
}
|
||||||
|
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
|
||||||
|
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
|
||||||
|
var original=photos.command(actor,number,key,SCOPE);Object result=null;
|
||||||
|
if(original.result()!=null)result=switch(original.action()) {
|
||||||
|
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
|
||||||
|
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
|
||||||
|
default->throw conflict("原命令类型不属于外配业务");
|
||||||
|
};
|
||||||
|
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
|
||||||
|
}
|
||||||
|
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
|
||||||
|
private Detail detail(OwnedBusinessModels.Detail detail) {
|
||||||
|
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
|
||||||
|
}
|
||||||
|
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
|
||||||
|
private Row row(OwnedBusinessModels.Row r,String carrierName) {
|
||||||
|
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
|
||||||
|
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
|
||||||
|
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
|
||||||
|
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
|
||||||
|
}
|
||||||
|
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
|
||||||
|
private Operations operations(OwnedBusinessModels.Operations ops) {
|
||||||
|
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
|
||||||
|
return new Operations(ops.money(),ops.arrivals());
|
||||||
|
}
|
||||||
|
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
|
||||||
|
private Photo photo(OwnedBusinessModels.Photo p) {
|
||||||
|
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
|
||||||
|
}
|
||||||
|
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
|
||||||
|
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
|
||||||
|
}
|
||||||
+9
-3
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
|
|||||||
#{reason},#{snapshotJson},#{occurredAt})
|
#{reason},#{snapshotJson},#{occurredAt})
|
||||||
""")
|
""")
|
||||||
int insertHistory(History row);
|
int insertHistory(History row);
|
||||||
/** 历史按本页版本顺序分页。 */
|
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
|
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
|
||||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||||
|
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||||
|
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||||
|
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
|
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
|
||||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||||
|
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||||
|
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||||
|
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||||
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
|
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
|
||||||
""")
|
""")
|
||||||
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
|
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
|
||||||
|
|||||||
+5
-1
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
|
|||||||
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
|
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
|
||||||
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
|
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
|
||||||
""";
|
""";
|
||||||
|
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
|
||||||
String FILTER="""
|
String FILTER="""
|
||||||
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
||||||
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
||||||
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
|
AND o.vehicle_ownership=#{q.scope.ownership}
|
||||||
|
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
|
||||||
|
AND o.loading_completed_at=d.loading_completed_at
|
||||||
|
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||||
<if test='q.from != null'> AND d.planned_departure_at >= #{q.from}</if>
|
<if test='q.from != null'> AND d.planned_departure_at >= #{q.from}</if>
|
||||||
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
||||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||||
|
|||||||
+1
-1
@@ -52,6 +52,6 @@ public final class OwnedBusinessRows {
|
|||||||
private Long allocatedPackages;
|
private Long allocatedPackages;
|
||||||
}
|
}
|
||||||
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
||||||
LocalDateTime from,LocalDateTime to) {}
|
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
|
||||||
@Data public static class StageCount { private String stage;private Long quantity; }
|
@Data public static class StageCount { private String stage;private Long quantity; }
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user