Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310d86da85 | ||
|
|
c5ac966ce9 | ||
|
|
de96e47d74 | ||
|
|
5d71af1ac9 | ||
|
|
98939d9314 | ||
|
|
e60890e63e | ||
|
|
0c05c15e5a | ||
|
|
74f510a49b | ||
|
|
5783610154 | ||
|
|
8adaea75fa | ||
|
|
738b778edf | ||
|
|
2c3435adaf | ||
|
|
bbf8f2f792 | ||
|
|
5356a8c898 | ||
|
|
234216b3eb | ||
|
|
e2ad7857b4 | ||
|
|
632d35faaf | ||
|
|
538a63c635 |
@@ -52,6 +52,10 @@
|
||||
|
||||
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
||||
|
||||
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
|
||||
|
||||
订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。
|
||||
|
||||
## 唯一入口与区域边界
|
||||
|
||||
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
||||
|
||||
@@ -3442,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
|
||||
CREATE TABLE fl_ocean_booking (
|
||||
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
||||
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
||||
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
|
||||
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
|
||||
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||
@@ -3465,6 +3465,17 @@ CREATE TABLE fl_ocean_booking (
|
||||
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
|
||||
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
|
||||
freight_term VARCHAR(64) NULL COMMENT '运费条款',
|
||||
service_term VARCHAR(64) NULL COMMENT '运输条款',
|
||||
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
|
||||
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
|
||||
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
|
||||
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
|
||||
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
|
||||
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
|
||||
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
|
||||
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
|
||||
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
|
||||
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
||||
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||
@@ -3479,6 +3490,14 @@ CREATE TABLE fl_ocean_booking (
|
||||
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
|
||||
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
|
||||
CONSTRAINT chk_ocean_booking_cargo CHECK (
|
||||
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
|
||||
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
|
||||
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
|
||||
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
|
||||
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
|
||||
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
|
||||
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
|
||||
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
|
||||
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||
@@ -3565,6 +3584,8 @@ CREATE TABLE fl_ocean_booking_party (
|
||||
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
||||
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
|
||||
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
|
||||
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
||||
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
|
||||
@@ -6026,9 +6047,49 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN
|
||||
IF OLD.status = 'RELEASED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
BEGIN
|
||||
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
|
||||
IF OLD.status = 'RELEASED' AND NOT (
|
||||
(NEW.id <=> OLD.id)
|
||||
AND (NEW.booking_no <=> OLD.booking_no)
|
||||
AND (NEW.status <=> OLD.status)
|
||||
AND (NEW.customer_id <=> OLD.customer_id)
|
||||
AND (NEW.customer_name <=> OLD.customer_name)
|
||||
AND (NEW.channel_code <=> OLD.channel_code)
|
||||
AND (NEW.channel_name <=> OLD.channel_name)
|
||||
AND (NEW.warehouse_id <=> OLD.warehouse_id)
|
||||
AND (NEW.warehouse_name <=> OLD.warehouse_name)
|
||||
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
|
||||
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
|
||||
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
|
||||
AND (NEW.carrier_code <=> OLD.carrier_code)
|
||||
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
|
||||
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
|
||||
AND (NEW.vessel_name <=> OLD.vessel_name)
|
||||
AND (NEW.voyage_no <=> OLD.voyage_no)
|
||||
AND (NEW.departure_port_code <=> OLD.departure_port_code)
|
||||
AND (NEW.departure_port_name <=> OLD.departure_port_name)
|
||||
AND (NEW.destination_port_code <=> OLD.destination_port_code)
|
||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
||||
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
|
||||
AND (NEW.remark <=> OLD.remark)
|
||||
AND (NEW.freight_term <=> OLD.freight_term)
|
||||
AND (NEW.service_term <=> OLD.service_term)
|
||||
AND (NEW.package_unit <=> OLD.package_unit)
|
||||
AND (NEW.cargo_description <=> OLD.cargo_description)
|
||||
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
|
||||
AND (NEW.declared_package_count <=> OLD.declared_package_count)
|
||||
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
|
||||
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
|
||||
AND (NEW.created_by <=> OLD.created_by)
|
||||
AND (NEW.created_by_name <=> OLD.created_by_name)
|
||||
AND (NEW.created_at <=> OLD.created_at)
|
||||
AND NEW.version = OLD.version + 1
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
|
||||
@@ -417,6 +417,7 @@ CREATE TABLE ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
|
||||
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
||||
@Mapper
|
||||
public interface FinanceFeeSourceReadMapper {
|
||||
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))";
|
||||
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
||||
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
|
||||
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
||||
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
|
||||
" AND f.status<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" +
|
||||
" WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" +
|
||||
" AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" +
|
||||
"</if>" +
|
||||
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
|
||||
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
|
||||
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
|
||||
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
|
||||
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
|
||||
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + ">=#{dateFrom}</if>" +
|
||||
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "<=#{dateTo}</if>" +
|
||||
"<if test='keyword != null'> AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))</if>";
|
||||
|
||||
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
|
||||
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
|
||||
long count(Map<String, Object> filters);
|
||||
|
||||
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
|
||||
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
|
||||
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<Map<String, Object>> list(Map<String, Object> filters);
|
||||
|
||||
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
|
||||
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
|
||||
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
|
||||
" AND f.source_fee_version<=anchor.source_fee_version";
|
||||
|
||||
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
|
||||
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
|
||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
|
||||
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
|
||||
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
|
||||
|
||||
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
|
||||
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
|
||||
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
|
||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
|
||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */
|
||||
@Repository
|
||||
public class FinanceFeeSourceReadRepository {
|
||||
private final FinanceFeeSourceReadMapper mapper;
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 注入正式持久化入口与费用快照解析器。 */
|
||||
public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) {
|
||||
this.mapper = mapper;
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
/** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */
|
||||
public Map<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
|
||||
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("customerId", customerId); values.put("direction", direction); values.put("status", status);
|
||||
values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated);
|
||||
values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword);
|
||||
values.put("limit", size); values.put("offset", (page - 1) * size);
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */
|
||||
public long count(Map<String, Object> filters) { return mapper.count(filters); }
|
||||
|
||||
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
|
||||
public List<FeeSourceSummary> list(Map<String, Object> filters) {
|
||||
return mapper.list(filters).stream().map(this::summary).toList();
|
||||
}
|
||||
|
||||
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
|
||||
public List<OperationLog> logs(long customerId, long id) {
|
||||
List<OperationLog> result = new ArrayList<>();
|
||||
Map<String, Object> previous = null;
|
||||
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
|
||||
Map<String, Object> row = row(raw);
|
||||
String version = "来源版本 " + row.get("source_fee_version");
|
||||
String amount = amount(row) + " " + row.get("currency_code");
|
||||
// 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。
|
||||
result.add(new OperationLog(time(row.get("created_at")),
|
||||
previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST",
|
||||
"系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount
|
||||
: "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version")
|
||||
+ ";金额 " + amount(previous) + " → " + amount));
|
||||
previous = row;
|
||||
}
|
||||
// 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。
|
||||
java.util.Collections.reverse(result);
|
||||
for (Map<String, Object> raw : mapper.audits(customerId, id)) {
|
||||
Map<String, Object> row = row(raw);
|
||||
String remark = text(row.get("reason"));
|
||||
JsonNode before = parse(row.get("before_data"));
|
||||
JsonNode after = parse(row.get("after_data"));
|
||||
if (before.hasNonNull("amount") && after.hasNonNull("amount")
|
||||
&& before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) {
|
||||
remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText()
|
||||
+ " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY");
|
||||
}
|
||||
result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")),
|
||||
text(row.get("operator_name_snapshot")), "SUCCESS", remark));
|
||||
}
|
||||
// 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。
|
||||
result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */
|
||||
private FeeSourceSummary summary(Map<String, Object> raw) {
|
||||
Map<String, Object> row = row(raw);
|
||||
JsonNode snapshot = parse(row.get("source_snapshot"));
|
||||
BigDecimal amount = decimal(row.get("amount"));
|
||||
BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount);
|
||||
BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity"));
|
||||
BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0
|
||||
? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null);
|
||||
return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")),
|
||||
first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")),
|
||||
((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")),
|
||||
text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"),
|
||||
nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"),
|
||||
nullableLong(row.get("container_id")), text(snapshot, "containerNo"),
|
||||
first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")),
|
||||
quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")),
|
||||
amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))),
|
||||
originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")),
|
||||
Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0);
|
||||
}
|
||||
|
||||
/** 保留不同数据库驱动的列名大小写读取语义。 */
|
||||
private static Map<String, Object> row(Map<String, Object> source) {
|
||||
Map<String, Object> result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result;
|
||||
}
|
||||
/** 解析不可变快照,损坏数据失败关闭。 */
|
||||
private JsonNode parse(Object value) {
|
||||
try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); }
|
||||
catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); }
|
||||
}
|
||||
/** 格式化记录金额,保留定点精度。 */
|
||||
private static String amount(Map<String, Object> row) { return decimal(row.get("amount")).toPlainString(); }
|
||||
/** 读取驱动返回的标准时间。 */
|
||||
private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; }
|
||||
/** 读取数字主键,仅用于数据引用。 */
|
||||
private static long number(Object value) { return ((Number) value).longValue(); }
|
||||
/** 保留未关联的可空引用。 */
|
||||
private static Long nullableLong(Object value) { return value == null ? null : number(value); }
|
||||
/** 只接受合法的正整型供应商引用。 */
|
||||
private static Long positiveLong(String value) {
|
||||
try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; }
|
||||
catch (NumberFormatException failure) { return null; }
|
||||
}
|
||||
/** 读取可空金额,不经过浮点转换。 */
|
||||
private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); }
|
||||
/** 读取可空业务日期。 */
|
||||
private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); }
|
||||
/** 保持空字段的缺失语义。 */
|
||||
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); }
|
||||
/** 读取快照字段。 */
|
||||
private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); }
|
||||
/** 优先使用来源字段,其次使用已有正式默认值。 */
|
||||
private static <T> T first(T value, T fallback) { return value == null ? fallback : value; }
|
||||
}
|
||||
+12
-61
@@ -1,10 +1,10 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||
@@ -44,19 +44,19 @@ public class FinancePhase1OperationService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FirstlegFeeSourceClient firstleg;
|
||||
private final String internalToken;
|
||||
private final FinanceFeeSourceReadRepository feeSources;
|
||||
|
||||
/** 注入费用来源查询及现有业务依赖。 */
|
||||
@Autowired
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FirstlegFeeSourceClient firstleg,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||
FinanceFeeSourceReadRepository feeSources) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.firstleg = firstleg;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this(jdbc, objectMapper, null, "");
|
||||
this.feeSources = feeSources;
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
||||
@@ -163,6 +163,7 @@ public class FinancePhase1OperationService {
|
||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||
}
|
||||
|
||||
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
|
||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||
String direction, String sourceStatus,
|
||||
String reviewStatus,
|
||||
@@ -178,34 +179,9 @@ public class FinancePhase1OperationService {
|
||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedSourceStatus = normalized(sourceStatus,
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
||||
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
|
||||
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
|
||||
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
|
||||
List<Object> args = new ArrayList<>();
|
||||
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
|
||||
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
||||
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
||||
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
||||
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
||||
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
||||
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
||||
String normalizedKeyword = blankToNull(keyword);
|
||||
if (normalizedKeyword != null) {
|
||||
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
||||
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
|
||||
}
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
||||
List<Object> queryArgs = new ArrayList<>(args);
|
||||
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
|
||||
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
|
||||
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
|
||||
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
||||
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
||||
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
||||
}
|
||||
|
||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||
@@ -339,6 +315,7 @@ public class FinancePhase1OperationService {
|
||||
return after;
|
||||
}
|
||||
|
||||
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
|
||||
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
long id = rs.getLong("id");
|
||||
@@ -356,33 +333,7 @@ public class FinancePhase1OperationService {
|
||||
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
|
||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
|
||||
}
|
||||
|
||||
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
boolean generated = rs.getBoolean("statement_generated");
|
||||
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
||||
BigDecimal amount = money(rs, "amount");
|
||||
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
|
||||
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
|
||||
? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
|
||||
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
|
||||
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
|
||||
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
|
||||
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
|
||||
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
|
||||
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
|
||||
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
defaultString(rs.getString("review_status"), "DRAFT"),
|
||||
rs.getString("effective_status"), generated);
|
||||
}
|
||||
|
||||
private List<OperationLog> logs(long customerId, long id) {
|
||||
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
|
||||
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
|
||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
||||
|
||||
@@ -17,6 +17,7 @@ class FinanceContractTest {
|
||||
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||
}
|
||||
|
||||
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
|
||||
@Test
|
||||
void controllersUseFrozenFinancePaths() throws Exception {
|
||||
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
|
||||
@@ -40,7 +41,8 @@ class FinanceContractTest {
|
||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
||||
assertTrue(operations.contains("bl.source_id=f.id"));
|
||||
String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java"));
|
||||
assertTrue(feeReads.contains("bl.source_id=f.id"));
|
||||
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
|
||||
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */
|
||||
public class FinanceFeeSourceReadMapperTest {
|
||||
/** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
||||
void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
|
||||
var jdbc = new JdbcTemplate(data);
|
||||
// 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。
|
||||
try (var connection = data.getConnection()) {
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
|
||||
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
|
||||
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
|
||||
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
|
||||
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
|
||||
insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00");
|
||||
insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00");
|
||||
insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00");
|
||||
insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00");
|
||||
insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00");
|
||||
insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')");
|
||||
|
||||
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data));
|
||||
configuration.addMapper(FinanceFeeSourceReadMapper.class);
|
||||
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||
var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper());
|
||||
var filters = repository.filters(9L, direction, null, "APPROVED", false,
|
||||
LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20);
|
||||
assertThat(repository.count(filters)).isEqualTo(5);
|
||||
var rows = repository.list(filters);
|
||||
assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L);
|
||||
assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
.isEqualByComparingTo("347.00");
|
||||
assertThat(rows).allSatisfy(row -> {
|
||||
assertThat(row.direction()).isEqualTo(direction);
|
||||
assertThat(row.customerId()).isEqualTo(9);
|
||||
assertThat(row.supplierId()).isEqualTo(79);
|
||||
assertThat(row.inboundCode()).isEqualTo("IN-9");
|
||||
assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21));
|
||||
assertThat(row.originalAmount()).isEqualByComparingTo("200");
|
||||
assertThat(row.unitPrice()).isEqualByComparingTo("20");
|
||||
});
|
||||
filters.put("limit", 2);
|
||||
filters.put("offset", 2);
|
||||
assertThat(repository.count(filters)).isEqualTo(5);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L);
|
||||
filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE");
|
||||
assertThat(repository.count(filters)).isEqualTo(4);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L);
|
||||
filters.put("status", "SUPERSEDED");
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L);
|
||||
|
||||
var logs = repository.logs(9, 2);
|
||||
assertThat(logs).hasSize(4);
|
||||
assertThat(logs).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED");
|
||||
assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY");
|
||||
assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
||||
assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他"));
|
||||
assertThat(repository.logs(9, 2)).isEqualTo(logs);
|
||||
assertThat(repository.logs(9, 1)).hasSize(2);
|
||||
assertThat(repository.logs(10, 2)).isEmpty();
|
||||
// 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。
|
||||
jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
||||
jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')");
|
||||
session.clearCache();
|
||||
var sameTimeLogs = repository.logs(9, 2);
|
||||
assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE",
|
||||
"FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE");
|
||||
assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
||||
assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY");
|
||||
assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly(
|
||||
"同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核");
|
||||
assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs);
|
||||
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
||||
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
|
||||
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
||||
assertThat(repository.count(filters)).isEqualTo(1);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
||||
|
||||
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
|
||||
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')");
|
||||
assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> {
|
||||
assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST");
|
||||
assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY");
|
||||
});
|
||||
}
|
||||
} catch (java.sql.SQLException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
|
||||
/** 准备具有相同名称、不同稳定来源的隔离费用快照。 */
|
||||
private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version,
|
||||
long customer, String direction, String status, String amount) {
|
||||
jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)",
|
||||
id, system, type, fee, version, customer, direction, new BigDecimal(amount), status,
|
||||
"{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}",
|
||||
java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00"));
|
||||
}
|
||||
|
||||
/** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */
|
||||
public static String jsonExtract(String value, String path) throws Exception {
|
||||
var node = new ObjectMapper().readTree(value).path(path.substring(2));
|
||||
return node.isMissingNode() || node.isNull() ? null : node.toString();
|
||||
}
|
||||
|
||||
/** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */
|
||||
public static String jsonUnquote(String value) throws Exception {
|
||||
return value == null ? null : new ObjectMapper().readTree(value).asText();
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,13 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -40,6 +43,9 @@ import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FinancePhase1OperationBehaviorTest {
|
||||
@@ -81,18 +87,20 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(response.page().total()).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 无效客户在进入持久化查询前拒绝。 */
|
||||
@Test
|
||||
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
}
|
||||
|
||||
/** 保留客户账户汇总的固定分页与金额口径。 */
|
||||
@Test
|
||||
void accountListReturnsFixedPagedDto() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
|
||||
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
|
||||
@@ -112,10 +120,11 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
||||
}
|
||||
|
||||
/** 所有派生流水查询保留目标客户范围。 */
|
||||
@Test
|
||||
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
@@ -128,58 +137,24 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
||||
}
|
||||
|
||||
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
|
||||
@Test
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
|
||||
.thenReturn(1L);
|
||||
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong("id")).thenReturn(88L);
|
||||
when(rs.getLong("source_fee_id")).thenReturn(9001L);
|
||||
when(rs.getInt("source_fee_version")).thenReturn(3);
|
||||
when(rs.getLong("customer_id")).thenReturn(9L);
|
||||
when(rs.getLong("order_id")).thenReturn(19L);
|
||||
when(rs.getLong("inbound_id")).thenReturn(29L);
|
||||
when(rs.getLong("container_id")).thenReturn(39L);
|
||||
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
|
||||
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
|
||||
when(rs.getString("source_no")).thenReturn("IN-9");
|
||||
when(rs.getString("direction")).thenReturn("RECEIVABLE");
|
||||
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
|
||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||
when(rs.getString("review_status")).thenReturn("APPROVED");
|
||||
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
|
||||
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
||||
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
|
||||
var reads = mock(FinanceFeeSourceReadRepository.class);
|
||||
var filters = java.util.Map.<String, Object>of("query", "current");
|
||||
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
|
||||
when(reads.list(filters)).thenReturn(List.of());
|
||||
when(reads.count(filters)).thenReturn(3L);
|
||||
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
|
||||
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
||||
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||
|
||||
assertThat(result.page()).satisfies(meta -> {
|
||||
assertThat(meta.page()).isEqualTo(2);
|
||||
assertThat(meta.pageSize()).isEqualTo(50);
|
||||
assertThat(meta.total()).isEqualTo(1);
|
||||
});
|
||||
assertThat(result.data()).singleElement().satisfies(source -> {
|
||||
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
|
||||
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
|
||||
assertThat(source.inboundCode()).isEqualTo("IN-9");
|
||||
assertThat(source.containerNo()).isEqualTo("CONT-9");
|
||||
assertThat(source.supplierId()).isEqualTo(79L);
|
||||
assertThat(source.supplierName()).isEqualTo("供应商A");
|
||||
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
||||
assertThat(source.statementGenerated()).isFalse();
|
||||
});
|
||||
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(result.page().total()).isEqualTo(3);
|
||||
assertThat(result.page().page()).isEqualTo(2);
|
||||
verify(reads).list(filters);
|
||||
}
|
||||
|
||||
/** 流水返回业务单号和操作者快照。 */
|
||||
@Test
|
||||
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -197,7 +172,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
@@ -211,18 +186,20 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
}
|
||||
|
||||
/** 保留费用审核状态读取。 */
|
||||
@Test
|
||||
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||
}
|
||||
|
||||
/** 费用详情保持原客户与来源版本。 */
|
||||
@Test
|
||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper());
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||
|
||||
@@ -232,10 +209,11 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
||||
}
|
||||
|
||||
/** 业务来源费用必须回到所属系统调整。 */
|
||||
@Test
|
||||
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
||||
new BigDecimal("120.00"), "更正金额", 1);
|
||||
@@ -247,6 +225,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
(Object[]) any());
|
||||
}
|
||||
|
||||
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
||||
@Test
|
||||
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
|
||||
@@ -257,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
||||
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
jdbc, objectMapper(), firstleg, "internal-token");
|
||||
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
||||
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
||||
@@ -278,6 +257,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
(Object[]) any());
|
||||
}
|
||||
|
||||
/** 审核以当前操作版本更新并追加同一记录的审计。 */
|
||||
@Test
|
||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -289,7 +269,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||
@@ -301,7 +280,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
||||
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
|
||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||
@@ -311,6 +290,51 @@ class FinancePhase1OperationBehaviorTest {
|
||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
|
||||
}
|
||||
|
||||
/** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
||||
void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception {
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)));
|
||||
var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE")
|
||||
.put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9)
|
||||
.put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2)
|
||||
.put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false);
|
||||
var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class);
|
||||
var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120)
|
||||
.put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class);
|
||||
doReturn(before, after).when(service).getFinanceFeeSource(88L);
|
||||
lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")))
|
||||
.thenReturn(1, 0);
|
||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2);
|
||||
var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true);
|
||||
assertThat(result.feeSourceId()).isEqualTo(88);
|
||||
assertThat(result.sourceFeeId()).isEqualTo(700);
|
||||
assertThat(result.direction()).isEqualTo(direction);
|
||||
assertThat(result.amount()).isEqualByComparingTo("120");
|
||||
assertThat(result.operationVersion()).isEqualTo(3);
|
||||
var hash = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture());
|
||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once")))
|
||||
.thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L)));
|
||||
assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after);
|
||||
verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"));
|
||||
verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L),
|
||||
eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any());
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once",
|
||||
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求");
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本");
|
||||
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class))
|
||||
.when(service).getFinanceFeeSource(88L);
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
|
||||
}
|
||||
|
||||
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
|
||||
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
@@ -318,7 +342,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
||||
return jdbc;
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
||||
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港为可选计划,不作为放舱必填项;填写时不得早于预计开船时间。
|
||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `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`,提单文件继续归订舱。
|
||||
|
||||
+29
-3
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
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 org.apache.ibatis.annotations.Update;
|
||||
@@ -55,28 +56,34 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
||||
int claim(@Param("id") long id, @Param("worker") String worker,
|
||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
/** 只有尚未过期的当前所有者可以续租,过期执行者必须重新领取。 */
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int renew(@Param("id") long id, @Param("worker") String worker,
|
||||
@Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
/** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
|
||||
failure_request_id=NULL,failure_trace_id=NULL
|
||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int complete(@Param("id") long id, @Param("worker") String worker);
|
||||
|
||||
/** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */
|
||||
@Update("""
|
||||
UPDATE fl_loading_completion_command
|
||||
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
|
||||
failure_request_id=#{requestId},failure_trace_id=#{traceId}
|
||||
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
|
||||
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
|
||||
@@ -97,13 +104,32 @@ public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingComple
|
||||
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId);
|
||||
|
||||
/** 检查点事务锁住当前有效租约,与并发重新领取互斥。 */
|
||||
@Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
|
||||
@Select("""
|
||||
SELECT id FROM fl_loading_completion_command
|
||||
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
||||
FOR UPDATE
|
||||
""")
|
||||
Long lockCheckpointLease(@Param("commandId") long commandId, @Param("worker") String worker);
|
||||
|
||||
/** 在已锁定的命令行上重验有效租约;检查点只能前进,不能被迟到写入降级。 */
|
||||
@Insert("""
|
||||
INSERT INTO fl_loading_completion_checkpoint
|
||||
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
|
||||
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
|
||||
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
|
||||
SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage}
|
||||
FROM fl_loading_completion_command
|
||||
WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker}
|
||||
AND lease_until>CURRENT_TIMESTAMP(3)
|
||||
ON DUPLICATE KEY UPDATE checkpoint_stage=CASE
|
||||
WHEN checkpoint_stage='INBOUND_COMPLETED' OR #{stage}='INBOUND_COMPLETED'
|
||||
THEN 'INBOUND_COMPLETED'
|
||||
WHEN checkpoint_stage='SETTLED' OR #{stage}='SETTLED' THEN 'SETTLED'
|
||||
ELSE 'READY' END,updated_at=CURRENT_TIMESTAMP(3)
|
||||
""")
|
||||
int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
|
||||
int checkpointCompleted(@Param("commandId") long commandId, @Param("worker") String worker,
|
||||
@Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
|
||||
@Param("stage") String stage);
|
||||
}
|
||||
|
||||
+11
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
|
||||
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
|
||||
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
|
||||
@PutMapping("/{oceanBookingId}/actual-cargo")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量")
|
||||
public ApiResponse<Detail> updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualCargoRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
|
||||
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
|
||||
|
||||
@@ -45,10 +45,12 @@ public final class OceanBookingDtos {
|
||||
String customerName, String warehouseName, String channelName) {}
|
||||
|
||||
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
|
||||
@NotBlank @Size(max = 255) String partyName,
|
||||
@NotNull @Size(max = 255) String partyName,
|
||||
@Size(max = 1000) String address,
|
||||
@Size(max = 128) String contactName,
|
||||
@Size(max = 64) String contactPhone) {}
|
||||
@Size(max = 64) String contactPhone,
|
||||
@jakarta.validation.constraints.Email @Size(max = 255) String contactEmail,
|
||||
@Size(max = 128) String taxNo) {}
|
||||
|
||||
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
|
||||
@NotBlank @Size(max = 32) String containerKind,
|
||||
@@ -76,7 +78,31 @@ public final class OceanBookingDtos {
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@Size(max = 1000) String remark,
|
||||
@Valid @Size(max = 3) List<PartyRequest> parties,
|
||||
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
|
||||
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements,
|
||||
@Size(max = 64) String freightTerm,
|
||||
@Size(max = 64) String serviceTerm,
|
||||
@Pattern(regexp = "CARTONS|PCS|PALLETS") String packageUnit,
|
||||
@Size(max = 1000) String cargoDescription,
|
||||
@Size(max = 1000) String marksAndNos,
|
||||
@Min(1) @Max(999999999) Integer declaredPackageCount,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredGrossWeightKg,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredVolumeCbm,
|
||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
|
||||
|
||||
/** 放舱后的受控实货量补录,不接收运输资料。 */
|
||||
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg,
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
|
||||
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Valid @NotNull DraftData data) {}
|
||||
|
||||
+11
@@ -40,6 +40,17 @@ public class OceanBookingEntity {
|
||||
private LocalDateTime estimatedSailingAt;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private String remark;
|
||||
private String freightTerm;
|
||||
private String serviceTerm;
|
||||
private String packageUnit;
|
||||
private String cargoDescription;
|
||||
private String marksAndNos;
|
||||
private Integer declaredPackageCount;
|
||||
private java.math.BigDecimal declaredGrossWeightKg;
|
||||
private java.math.BigDecimal declaredVolumeCbm;
|
||||
private Integer actualPackageCount;
|
||||
private java.math.BigDecimal actualGrossWeightKg;
|
||||
private java.math.BigDecimal actualVolumeCbm;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||
|
||||
+2
@@ -17,4 +17,6 @@ public class OceanBookingPartyEntity {
|
||||
private String address;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String contactEmail;
|
||||
private String taxNo;
|
||||
}
|
||||
|
||||
+10
-1
@@ -25,8 +25,17 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
""")
|
||||
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
|
||||
actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},
|
||||
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{value.id} AND status='RELEASED' AND version=#{version}
|
||||
""")
|
||||
int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
||||
@Select("""
|
||||
|
||||
+4
@@ -54,6 +54,10 @@ public class OceanBookingRepository {
|
||||
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.touchDraft(id, version, operatorId, operatorName);
|
||||
}
|
||||
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
|
||||
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateActualCargo(value, version, operatorId, operatorName);
|
||||
}
|
||||
public int deleteDraft(long id, int version) {
|
||||
return bookings.deleteDraft(id, version);
|
||||
}
|
||||
|
||||
+51
-8
@@ -46,7 +46,7 @@ public class OceanBookingPrintService {
|
||||
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
|
||||
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
|
||||
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
|
||||
fields(template, detail), List.of(emptyCargo()))));
|
||||
fields(template, detail), List.of(cargo(template, booking)))));
|
||||
try {
|
||||
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
@@ -75,14 +75,21 @@ public class OceanBookingPrintService {
|
||||
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
|
||||
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
|
||||
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
||||
fields.put("serviceTerm", "");
|
||||
fields.put("freightTerm", "");
|
||||
fields.put("totalPackagesInWords", "");
|
||||
fields.put("serviceTerm", text(booking.getServiceTerm()));
|
||||
fields.put("freightTerm", text(booking.getFreightTerm()));
|
||||
Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
|
||||
fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit()));
|
||||
if (SHIPPING_INSTRUCTION.equals(template)) {
|
||||
fields.put("toCompany", text(booking.getBookingAgentName()));
|
||||
fields.put("jobNumber", text(booking.getBookingNo()));
|
||||
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
|
||||
fields.put("cargoQuantity", "");
|
||||
fields.put("cargoQuantity", detail.containerRequirements().stream()
|
||||
.map(value -> value.getQuantity() + "*" + value.getContainerType()).collect(Collectors.joining(" + ")));
|
||||
OceanBookingPartyEntity shipper = parties.get("SHIPPER");
|
||||
fields.put("picName", shipper == null ? "" : text(shipper.getContactName()));
|
||||
fields.put("picTel", shipper == null ? "" : text(shipper.getContactPhone()));
|
||||
fields.put("apName", text(booking.getBookingAgentName()));
|
||||
fields.put("printDate", java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString());
|
||||
fields.put("carrier", text(booking.getShippingCompanyName()));
|
||||
fields.put("bookingAgent", text(booking.getBookingAgentName()));
|
||||
fields.put("remark", text(booking.getRemark()));
|
||||
@@ -98,8 +105,43 @@ public class OceanBookingPrintService {
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static PrintPdfClient.CargoRow emptyCargo() {
|
||||
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
|
||||
/** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */
|
||||
private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) {
|
||||
boolean declared = SHIPPING_INSTRUCTION.equals(template);
|
||||
Integer count = declared ? booking.getDeclaredPackageCount() : booking.getActualPackageCount();
|
||||
BigDecimal weight = declared ? booking.getDeclaredGrossWeightKg() : booking.getActualGrossWeightKg();
|
||||
BigDecimal volume = declared ? booking.getDeclaredVolumeCbm() : booking.getActualVolumeCbm();
|
||||
return new PrintPdfClient.CargoRow(display(booking.getMarksAndNos()), count == null ? "—" : count.toString(),
|
||||
display(booking.getPackageUnit()), display(booking.getCargoDescription()),
|
||||
weight == null ? "—" : weight.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " KGS",
|
||||
volume == null ? "—" : volume.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " CBM");
|
||||
}
|
||||
|
||||
/** 打印空字段使用统一占位符。 */
|
||||
private static String display(String value) { return text(value).isEmpty() ? "—" : text(value); }
|
||||
|
||||
/** 件数大写跟随所选数据和包装单位生成,未录入时明确留空。 */
|
||||
private static String packageWords(Integer count, String unit) {
|
||||
if (count == null || !org.springframework.util.StringUtils.hasText(unit)) return "";
|
||||
String packaging = count == 1 ? switch (unit) {
|
||||
case "CARTONS" -> "CARTON";
|
||||
case "PALLETS" -> "PALLET";
|
||||
case "PCS" -> "PIECE";
|
||||
default -> unit;
|
||||
} : ("PCS".equals(unit) ? "PIECES" : unit);
|
||||
return "SAY " + words(count) + " " + packaging + " ONLY";
|
||||
}
|
||||
|
||||
/** 将受件数范围约束的正整数转换为英文数量。 */
|
||||
private static String words(int value) {
|
||||
String[] small = {"ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN",
|
||||
"ELEVEN", "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN", "NINETEEN"};
|
||||
String[] tens = {"", "", "TWENTY", "THIRTY", "FORTY", "FIFTY", "SIXTY", "SEVENTY", "EIGHTY", "NINETY"};
|
||||
if (value < 20) return small[value];
|
||||
if (value < 100) return tens[value / 10] + (value % 10 == 0 ? "" : " " + words(value % 10));
|
||||
int divisor = value >= 1000000 ? 1000000 : value >= 1000 ? 1000 : 100;
|
||||
String scale = divisor == 1000000 ? "MILLION" : divisor == 1000 ? "THOUSAND" : "HUNDRED";
|
||||
return words(value / divisor) + " " + scale + (value % divisor == 0 ? "" : " " + words(value % divisor));
|
||||
}
|
||||
|
||||
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
|
||||
@@ -110,7 +152,8 @@ public class OceanBookingPrintService {
|
||||
private static String party(OceanBookingPartyEntity value) {
|
||||
if (value == null) return "";
|
||||
return List.of(text(value.getPartyName()), text(value.getAddress()),
|
||||
join(value.getContactName(), value.getContactPhone())).stream()
|
||||
join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()),
|
||||
text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream()
|
||||
.filter(part -> !part.isEmpty()).collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
|
||||
+45
-1
@@ -145,6 +145,26 @@ public class OceanBookingService {
|
||||
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
|
||||
@Transactional
|
||||
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
|
||||
String action = "UPDATE_ACTUAL_CARGO";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
||||
if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因");
|
||||
validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm());
|
||||
OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class);
|
||||
after.setActualPackageCount(request.actualPackageCount());
|
||||
after.setActualGrossWeightKg(request.actualGrossWeightKg());
|
||||
after.setActualVolumeCbm(request.actualVolumeCbm());
|
||||
if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity saved = required(id);
|
||||
addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(saved));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "DELETE_DRAFT";
|
||||
@@ -209,7 +229,18 @@ public class OceanBookingService {
|
||||
private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
||||
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
|
||||
|
||||
/** 未采集使用空值;已填写的件数、重量和体积必须为正数且不超出存储精度。 */
|
||||
private static void validateCargo(Integer count, java.math.BigDecimal weight, java.math.BigDecimal volume) {
|
||||
if (count != null && (count < 1 || count > 999999999)) throw badRequest("货物件数必须为1到999999999的整数");
|
||||
for (java.math.BigDecimal number : new java.math.BigDecimal[] {weight, volume}) {
|
||||
if (number != null && (number.signum() <= 0 || number.scale() > 3
|
||||
|| number.precision() - number.scale() > 12)) throw badRequest("货物重量和体积必须为正数,最多12位整数和3位小数");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDraftStructure(DraftData data) {
|
||||
validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm());
|
||||
validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm());
|
||||
boolean hasCustomerId = data.customerId() != null;
|
||||
boolean hasCustomerName = StringUtils.hasText(data.customerName());
|
||||
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
|
||||
@@ -231,6 +262,7 @@ public class OceanBookingService {
|
||||
}
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
/** 放舱要求预计到港,供关联柜次的到港日历及默认到仓日期使用。 */
|
||||
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
@@ -242,6 +274,7 @@ public class OceanBookingService {
|
||||
|| !StringUtils.hasText(booking.getPlaceOfDelivery())
|
||||
|| !StringUtils.hasText(booking.getCarrierCode())
|
||||
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|
||||
|| (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName())))
|
||||
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
||||
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
|
||||
}
|
||||
@@ -298,6 +331,17 @@ public class OceanBookingService {
|
||||
v.setPlaceOfDelivery(trim(d.placeOfDelivery()));
|
||||
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
|
||||
v.setRemark(trim(d.remark()));
|
||||
v.setFreightTerm(trim(d.freightTerm()));
|
||||
v.setServiceTerm(trim(d.serviceTerm()));
|
||||
v.setPackageUnit(trim(d.packageUnit()));
|
||||
v.setCargoDescription(trim(d.cargoDescription()));
|
||||
v.setMarksAndNos(trim(d.marksAndNos()));
|
||||
v.setDeclaredPackageCount(d.declaredPackageCount());
|
||||
v.setDeclaredGrossWeightKg(d.declaredGrossWeightKg());
|
||||
v.setDeclaredVolumeCbm(d.declaredVolumeCbm());
|
||||
v.setActualPackageCount(d.actualPackageCount());
|
||||
v.setActualGrossWeightKg(d.actualGrossWeightKg());
|
||||
v.setActualVolumeCbm(d.actualVolumeCbm());
|
||||
}
|
||||
|
||||
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
|
||||
@@ -370,7 +414,7 @@ public class OceanBookingService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
|
||||
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); return v; }).toList(); }
|
||||
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
|
||||
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
|
||||
try {
|
||||
|
||||
+14
-3
@@ -11,16 +11,27 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class AppointmentForecastService {
|
||||
public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest,
|
||||
AppointmentForecastMapper.Fact previous,String timeBasis) { }
|
||||
AppointmentForecastMapper.Fact previous,String timeBasis,
|
||||
java.time.LocalDateTime portArrivalAt) { }
|
||||
private final AppointmentForecastMapper mapper;
|
||||
public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;}
|
||||
private final com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources;
|
||||
/** 读取船节点及日历权威到港来源,不从调用端接收推算日期。 */
|
||||
public AppointmentForecastService(AppointmentForecastMapper mapper,
|
||||
com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources) {
|
||||
this.mapper=mapper; this.sources=sources;
|
||||
}
|
||||
/** 返回明确来源柜的到港事实,保留未知日期。 */
|
||||
public List<Forecast> batch(List<Long> ids) {
|
||||
if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0)
|
||||
|| new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次");
|
||||
var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId));
|
||||
var arrivals=sources.byIds(ids).stream().collect(Collectors.toMap(
|
||||
com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource::containerBookingId,
|
||||
java.util.function.Function.identity()));
|
||||
return ids.stream().map(id->{
|
||||
var rows=facts.getOrDefault(id,List.of());
|
||||
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED");
|
||||
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED",
|
||||
arrivals.containsKey(id)?arrivals.get(id).arrivalAt():null);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -4,6 +4,7 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -12,6 +13,9 @@ import java.util.List;
|
||||
public class LoadingCompletionCommandRepository {
|
||||
@Resource private LoadingCompletionCommandMapper mapper;
|
||||
|
||||
/** 按事务提交后的命令标识读取唯一完成请求。 */
|
||||
public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); }
|
||||
|
||||
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
|
||||
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
|
||||
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
|
||||
@@ -28,7 +32,13 @@ public class LoadingCompletionCommandRepository {
|
||||
public String checkpoint(long commandId, long tenantId, long inboundId) {
|
||||
return mapper.checkpoint(commandId, tenantId, inboundId);
|
||||
}
|
||||
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
|
||||
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
|
||||
/** 命令行锁覆盖租约检查与检查点写入,旧执行者不能在租约易主后覆盖新结果。 */
|
||||
@Transactional
|
||||
public boolean checkpoint(long commandId, String worker, long tenantId, long inboundId,
|
||||
int sequence, String stage) {
|
||||
if (mapper.lockCheckpointLease(commandId, worker) == null) return false;
|
||||
int written = mapper.checkpointCompleted(commandId, worker, tenantId, inboundId, sequence, stage);
|
||||
// 同阶段重放可能被驱动计为零行;命令行仍被本事务锁住,重验租约区分幂等与过期拒绝。
|
||||
return written > 0 || mapper.lockCheckpointLease(commandId, worker) != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,77 @@ import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
/** Lease-based recovery worker for PDA completion commands. */
|
||||
/** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */
|
||||
@Service
|
||||
public class LoadingCompletionWorker {
|
||||
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private final String workerId = "pda-completion-" + UUID.randomUUID();
|
||||
|
||||
@Resource private LoadingCompletionCommandRepository commands;
|
||||
@Resource private LoadingPickRepository picks;
|
||||
@Resource private LoadingPickService loadingPicks;
|
||||
@Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
|
||||
public void dispatch() {
|
||||
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
||||
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
loadingPicks.processCompletion(candidate, workerId);
|
||||
} catch (RuntimeException exception) {
|
||||
handleFailure(candidate, exception);
|
||||
}
|
||||
/** 唤醒只发生在受理事务提交后,异步执行不延长 HTTP 受理请求。 */
|
||||
@TransactionalEventListener
|
||||
public void accepted(LoadingPickService.CompletionAccepted accepted) {
|
||||
try {
|
||||
completionExecutor.execute(() -> dispatchAccepted(accepted));
|
||||
} catch (RejectedExecutionException exception) {
|
||||
// 执行器繁忙或关闭时保留持久化命令,仍由定时扫描领取,不能影响已提交回执。
|
||||
log.warn("pda_completion_wakeup_deferred commandId={}", accepted.commandId(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) {
|
||||
/** 唤醒与恢复共用原子领取;同一命令不会被两个执行入口重复结算。 */
|
||||
private void dispatchAccepted(LoadingPickService.CompletionAccepted accepted) {
|
||||
try {
|
||||
LoadingCompletionCommandEntity command = commands.command(accepted.commandId());
|
||||
if (command != null) process(command, "immediate", accepted.acceptedAtNanos());
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("pda_completion_wakeup_failed commandId={}", accepted.commandId(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描到期或租约过期命令,覆盖唤醒丢失、进程重启及失败退避恢复。 */
|
||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
|
||||
public void dispatch() {
|
||||
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
|
||||
process(candidate, "scheduled", System.nanoTime());
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留租约、检查点和失败恢复语义,记录领取及实际处理耗时。 */
|
||||
private void process(LoadingCompletionCommandEntity command, String source, long queuedAt) {
|
||||
// 每次领取使用独立所有者,避免旧执行线程在租约过期、同进程重新领取后继续写入。
|
||||
String leaseOwner = "pda-completion-" + UUID.randomUUID();
|
||||
if (!commands.claim(command.getId(), leaseOwner, LocalDateTime.now().plusMinutes(10))) return;
|
||||
long started = System.nanoTime();
|
||||
boolean succeeded = false;
|
||||
log.info("pda_completion_started commandId={} taskId={} source={} wakeupMs={}",
|
||||
command.getId(), command.getPickTaskId(), source, (started - queuedAt) / 1_000_000);
|
||||
try {
|
||||
loadingPicks.processCompletion(command, leaseOwner);
|
||||
succeeded = true;
|
||||
} catch (RuntimeException exception) {
|
||||
handleFailure(command, leaseOwner, exception);
|
||||
} finally {
|
||||
log.info("pda_completion_finished commandId={} taskId={} source={} durationMs={} succeeded={}",
|
||||
command.getId(), command.getPickTaskId(), source, (System.nanoTime() - started) / 1_000_000,
|
||||
succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
/** 确定性失败关闭任务,其余故障按原退避与最大次数恢复。 */
|
||||
private void handleFailure(LoadingCompletionCommandEntity command, String leaseOwner, RuntimeException exception) {
|
||||
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
|
||||
&& exception.getCause() != null ? exception.getCause() : exception;
|
||||
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
|
||||
@@ -53,7 +93,7 @@ public class LoadingCompletionWorker {
|
||||
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
|
||||
boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS;
|
||||
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
|
||||
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
||||
boolean recorded = commands.fail(command.getId(), leaseOwner, terminal ? "FAILED" : "RETRYABLE", retryAt,
|
||||
stage, code, requestId, traceId);
|
||||
if (terminal && recorded) {
|
||||
picks.failCompletion(command.getPickTaskId());
|
||||
@@ -63,6 +103,7 @@ public class LoadingCompletionWorker {
|
||||
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
|
||||
}
|
||||
|
||||
/** 仅将已明确不可重试的本地业务门禁判为终态。 */
|
||||
private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) {
|
||||
if (!(cause instanceof BizException business)) {
|
||||
return false;
|
||||
|
||||
@@ -56,6 +56,7 @@ import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -87,6 +88,8 @@ public class LoadingPickService {
|
||||
@Resource
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@Resource
|
||||
private ApplicationEventPublisher completionEvents;
|
||||
@Resource
|
||||
private TransactionTemplate completionTransaction;
|
||||
@Resource
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@@ -552,6 +555,7 @@ public class LoadingPickService {
|
||||
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
||||
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
||||
}
|
||||
completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime()));
|
||||
}
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
@@ -598,9 +602,14 @@ public class LoadingPickService {
|
||||
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
|
||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||
}
|
||||
// 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。
|
||||
completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime()));
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
|
||||
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
|
||||
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
|
||||
|
||||
private void requireCompletionContainerNumber(long taskId) {
|
||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
@@ -613,7 +622,7 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Called only by the completion worker, after its durable lease has committed. */
|
||||
/** 仅在持久化租约领取成功后执行,远程结算不占用受理事务。 */
|
||||
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
||||
long taskId = command.getPickTaskId();
|
||||
inboundHoldGuard.requireTaskNotHeld(taskId);
|
||||
@@ -625,55 +634,55 @@ public class LoadingPickService {
|
||||
.toList();
|
||||
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
||||
List<GroupSnapshot> taskGroups = coloadGroups(owners);
|
||||
runCompletionStage("CABINET_EVIDENCE", () ->
|
||||
runCompletionStage(taskId, "CABINET_EVIDENCE", () ->
|
||||
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
|
||||
runCompletionStage("COMPLETED_EVENT_READY", () ->
|
||||
runCompletionStage(taskId, "COMPLETED_EVENT_READY", () ->
|
||||
loadingListService.requireCompletedEventReady(task.getLoadingPlanId()));
|
||||
|
||||
// Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates
|
||||
// before any warehouse resource or inbound status can be changed. This prevents a later customer gate from
|
||||
// leaving earlier customers partially completed.
|
||||
// 全柜所有客户先通过只读门禁,再开始资源结算,避免后续客户失败造成前序客户局部完成。
|
||||
for (int index = 0; index < owners.size(); index++) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
||||
runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
||||
owner.tenantId(), owner.inboundId()));
|
||||
if (!checkpointAtLeast(checkpoint, "READY")) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
|
||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(),
|
||||
index + 1, "READY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the
|
||||
// complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable.
|
||||
// 全柜门禁通过后,按客户检查点和稳定幂等键逐步结算,已完成的检查点可安全续做。
|
||||
for (int index = 0; index < owners.size(); index++) {
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
||||
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
||||
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
||||
runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
||||
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
||||
}
|
||||
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
|
||||
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
||||
runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
||||
owner.tenantId(), owner.inboundId(),
|
||||
command.getOperatorId(), command.getOperatorName(),
|
||||
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
|
||||
requireCompletionLease(command.getId(), worker);
|
||||
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
|
||||
recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1,
|
||||
"INBOUND_COMPLETED");
|
||||
}
|
||||
}
|
||||
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
||||
long transactionStarted = System.nanoTime();
|
||||
try {
|
||||
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
||||
} finally {
|
||||
log.info("pda_completion_transaction taskId={} durationMs={}", taskId,
|
||||
(System.nanoTime() - transactionStarted) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
|
||||
/** 远程结算完成后,仅在短事务内收敛 Firstleg 本地完成事实。 */
|
||||
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
|
||||
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
|
||||
long taskId = task.getId();
|
||||
@@ -688,11 +697,11 @@ public class LoadingPickService {
|
||||
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
||||
command.getOperatorId(), command.getOperatorName());
|
||||
}
|
||||
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||
runCompletionStage(taskId, "PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||
command.getOperatorId(), command.getOperatorName()));
|
||||
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
for (GroupSnapshot group : taskGroups) {
|
||||
runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
||||
runCompletionStage(taskId, "COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
||||
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
|
||||
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
|
||||
}
|
||||
@@ -750,6 +759,14 @@ public class LoadingPickService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查点失去当前有效租约时立即停止,不再推进后续客户或柜次。 */
|
||||
private void recordCompletionCheckpoint(long commandId, String worker, long tenantId, long inboundId,
|
||||
int sequence, String stage) {
|
||||
if (!completionCommands.checkpoint(commandId, worker, tenantId, inboundId, sequence, stage)) {
|
||||
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
|
||||
String requestHash, long operatorId) {
|
||||
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
||||
@@ -764,11 +781,18 @@ public class LoadingPickService {
|
||||
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
|
||||
}
|
||||
|
||||
private static void runCompletionStage(String stage, Runnable action) {
|
||||
/** 记录实际阶段耗时,区分业务处理与调度、前端刷新等待。 */
|
||||
private static void runCompletionStage(long taskId, String stage, Runnable action) {
|
||||
long started = System.nanoTime();
|
||||
boolean succeeded = false;
|
||||
try {
|
||||
action.run();
|
||||
succeeded = true;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new CompletionProcessingException(stage, exception);
|
||||
} finally {
|
||||
log.info("pda_completion_stage taskId={} stage={} durationMs={} succeeded={}",
|
||||
taskId, stage, (System.nanoTime() - started) / 1_000_000, succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -9,6 +9,25 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerCalendarSourceMapper {
|
||||
/** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
|
||||
c.destination_warehouse_code destinationWarehouseCode,
|
||||
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
|
||||
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
|
||||
FROM fl_container_booking c
|
||||
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
|
||||
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
|
||||
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
|
||||
WHERE c.record_status='ACTIVE' AND c.id IN
|
||||
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<ContainerCalendarSource> byIds(@Param("ids") List<Long> ids);
|
||||
|
||||
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
|
||||
@Select("""
|
||||
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
|
||||
|
||||
+2
@@ -11,6 +11,8 @@ public class ContainerCalendarSourceRepository {
|
||||
private final ContainerCalendarSourceMapper mapper;
|
||||
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
|
||||
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
|
||||
/** 内部批量读取明确柜次,日期口径与平台日历一致。 */
|
||||
public List<ContainerCalendarSource> byIds(List<Long> ids) { return mapper.byIds(ids); }
|
||||
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
|
||||
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
|
||||
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
|
||||
|
||||
@@ -27,7 +27,7 @@ public class WorkflowTabService {
|
||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
|
||||
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(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
@@ -61,11 +61,11 @@ public class WorkflowTabService {
|
||||
Set<String> keys=new LinkedHashSet<>();
|
||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||
boolean signed="DELIVERED".equals(process);
|
||||
// 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。
|
||||
boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded();
|
||||
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
|
||||
boolean clearanceStage=f.departed();
|
||||
if(!clearanceStage && !signed) {
|
||||
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
||||
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
|
||||
if(f.exportCustomsReady() || f.customsCompleted()) keys.add("EXPORT_CUSTOMS");
|
||||
}
|
||||
if(signed) keys.add("SIGNED");
|
||||
if(clearanceStage && !signed) {
|
||||
|
||||
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||
@@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
|
||||
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
|
||||
freight_term VARCHAR(64) NULL COMMENT '运费条款',
|
||||
service_term VARCHAR(64) NULL COMMENT '运输条款',
|
||||
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
|
||||
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
|
||||
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
|
||||
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
|
||||
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
|
||||
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
|
||||
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
|
||||
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
|
||||
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||
@@ -44,6 +55,14 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
('DRAFT','RELEASED')),
|
||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK
|
||||
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
||||
CONSTRAINT chk_ocean_booking_cargo CHECK (
|
||||
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
|
||||
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
|
||||
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
|
||||
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
|
||||
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
|
||||
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
|
||||
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
|
||||
CONSTRAINT chk_ocean_booking_schedule CHECK
|
||||
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
|
||||
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||
@@ -110,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
|
||||
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
|
||||
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
|
||||
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
||||
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE,
|
||||
@@ -211,8 +232,47 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.status = 'RELEASED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
||||
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
|
||||
IF OLD.status = 'RELEASED' AND NOT (
|
||||
(NEW.id <=> OLD.id)
|
||||
AND (NEW.booking_no <=> OLD.booking_no)
|
||||
AND (NEW.status <=> OLD.status)
|
||||
AND (NEW.customer_id <=> OLD.customer_id)
|
||||
AND (NEW.customer_name <=> OLD.customer_name)
|
||||
AND (NEW.channel_code <=> OLD.channel_code)
|
||||
AND (NEW.channel_name <=> OLD.channel_name)
|
||||
AND (NEW.warehouse_id <=> OLD.warehouse_id)
|
||||
AND (NEW.warehouse_name <=> OLD.warehouse_name)
|
||||
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
|
||||
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
|
||||
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
|
||||
AND (NEW.carrier_code <=> OLD.carrier_code)
|
||||
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
|
||||
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
|
||||
AND (NEW.vessel_name <=> OLD.vessel_name)
|
||||
AND (NEW.voyage_no <=> OLD.voyage_no)
|
||||
AND (NEW.departure_port_code <=> OLD.departure_port_code)
|
||||
AND (NEW.departure_port_name <=> OLD.departure_port_name)
|
||||
AND (NEW.destination_port_code <=> OLD.destination_port_code)
|
||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
||||
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
|
||||
AND (NEW.remark <=> OLD.remark)
|
||||
AND (NEW.freight_term <=> OLD.freight_term)
|
||||
AND (NEW.service_term <=> OLD.service_term)
|
||||
AND (NEW.package_unit <=> OLD.package_unit)
|
||||
AND (NEW.cargo_description <=> OLD.cargo_description)
|
||||
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
|
||||
AND (NEW.declared_package_count <=> OLD.declared_package_count)
|
||||
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
|
||||
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
|
||||
AND (NEW.created_by <=> OLD.created_by)
|
||||
AND (NEW.created_by_name <=> OLD.created_by_name)
|
||||
AND (NEW.created_at <=> OLD.created_at)
|
||||
AND NEW.version = OLD.version + 1
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
|
||||
@@ -34,6 +34,7 @@ paths:
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
|
||||
portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空}
|
||||
latest: {$ref: '#/components/schemas/Fact'}
|
||||
previous: {$ref: '#/components/schemas/Fact'}
|
||||
'401': {description: 内部身份缺失、错误或服务端未配置}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Ocean Booking API
|
||||
version: 2.1.0
|
||||
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only.
|
||||
version: 2.2.0
|
||||
description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason.
|
||||
paths:
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get:
|
||||
@@ -29,6 +29,14 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: Deleted}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
|
||||
put:
|
||||
summary: Supplement manual actual cargo on a RELEASED booking
|
||||
description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo.
|
||||
x-permission: platform:firstleg:ocean-booking:edit
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}}
|
||||
responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
@@ -89,7 +97,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
|
||||
get:
|
||||
summary: Render the current saved booking as an export shipping instruction PDF
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved declared values; container requirements describe requested containers only. Count in words is generated from the declared count and packageUnit.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses:
|
||||
@@ -97,7 +105,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
|
||||
get:
|
||||
summary: Render the current saved booking as a bill-of-lading confirmation PDF
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved manually entered actual values only; missing actual data displays — and never falls back to declared values. This is a confirmation copy; the externally issued bill of lading remains an uploaded attachment.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses:
|
||||
@@ -192,12 +200,33 @@ components:
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
placeOfDelivery: {type: string, nullable: true}
|
||||
estimatedSailingAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 草稿可留空,放舱前必填;不得早于预计开船时间,关联柜次后作为到港日历及默认到仓日期来源}
|
||||
remark: {type: string, nullable: true}
|
||||
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
|
||||
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
|
||||
packageUnit: {type: string, enum: [CARTONS, PCS, PALLETS], nullable: true}
|
||||
cargoDescription: {type: string, maxLength: 1000, nullable: true}
|
||||
marksAndNos: {type: string, maxLength: 1000, nullable: true}
|
||||
declaredPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
||||
declaredGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
declaredVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
||||
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
|
||||
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
|
||||
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}}
|
||||
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
|
||||
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
|
||||
ActualCargoRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [expectedVersion, reason]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
||||
actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true}
|
||||
actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true}
|
||||
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
|
||||
DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
|
||||
@@ -311,6 +340,8 @@ components:
|
||||
required: [oceanBooking, parties, containerRequirements, containers, documents, histories]
|
||||
properties:
|
||||
oceanBooking:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/DraftData'}
|
||||
type: object
|
||||
properties:
|
||||
bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号}
|
||||
|
||||
+19
@@ -32,6 +32,7 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("create", "platform:firstleg:ocean-booking:create"),
|
||||
Map.entry("updateActualCargo", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
|
||||
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
|
||||
Map.entry("release", "platform:firstleg:ocean-booking:release"));
|
||||
@@ -47,6 +48,24 @@ class PlatformOceanBookingControllerContractTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void actualCargoContractExposesOnlySupplementFieldsAndReusesEditPermission() throws Exception {
|
||||
assertThat(method("updateActualCargo").getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value())
|
||||
.containsExactly("/{oceanBookingId}/actual-cargo");
|
||||
Map<String, Object> document = new org.yaml.snakeyaml.Yaml().load(
|
||||
Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")));
|
||||
var paths = (Map<String, Object>) document.get("paths");
|
||||
var endpoint = (Map<String, Object>) paths.get("/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo");
|
||||
var operation = (Map<String, Object>) endpoint.get("put");
|
||||
assertThat(operation).containsEntry("x-permission", "platform:firstleg:ocean-booking:edit");
|
||||
var schemas = (Map<String, Object>) ((Map<String, Object>) document.get("components")).get("schemas");
|
||||
var request = (Map<String, Object>) schemas.get("ActualCargoRequest");
|
||||
assertThat((Map<String, Object>) request.get("properties")).containsOnlyKeys(
|
||||
"expectedVersion", "reason", "actualPackageCount", "actualGrossWeightKg", "actualVolumeCbm");
|
||||
assertThat((java.util.List<String>) request.get("required")).contains("expectedVersion", "reason");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathVariablesUseExplicitAggregateNames() {
|
||||
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
|
||||
|
||||
+10
@@ -46,6 +46,16 @@ class OceanBookingMapperContractTest {
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasedSupplementSqlCannotWriteTransportOrDeclaredCargo() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateActualCargo",
|
||||
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity.class, int.class, long.class, String.class)
|
||||
.getAnnotation(Update.class).value());
|
||||
assertThat(sql).contains("status='RELEASED' AND version=#{version}", "version=version+1",
|
||||
"actual_package_count=#{value.actualPackageCount}", "actual_gross_weight_kg=#{value.actualGrossWeightKg}", "actual_volume_cbm=#{value.actualVolumeCbm}")
|
||||
.doesNotContain("bill_of_lading_no=", "vessel_name=", "declared_", "warehouse_id=", "package_unit=");
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
|
||||
|
||||
+3
-3
@@ -27,8 +27,8 @@ class OceanBookingSqlContractTest {
|
||||
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
|
||||
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
|
||||
.doesNotContain("service_term")
|
||||
.doesNotContain("freight_term")
|
||||
.contains("service_term", "declared_package_count", "actual_package_count")
|
||||
.contains("freight_term", "contact_email", "tax_no")
|
||||
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
|
||||
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
|
||||
@@ -49,7 +49,7 @@ class OceanBookingSqlContractTest {
|
||||
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("OLD.status='DRAFT' AND NEW.status='RELEASED'")
|
||||
.contains("OLD.status = 'RELEASED'")
|
||||
.contains("OLD.status = 'RELEASED'", "NEW.declared_package_count <=> OLD.declared_package_count", "NEW.bill_of_lading_no <=> OLD.bill_of_lading_no", "NEW.version = OLD.version + 1")
|
||||
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
|
||||
.contains("request_hash CHAR(64) NOT NULL")
|
||||
.contains("<> 'RELEASED' THEN")
|
||||
|
||||
+34
-1
@@ -49,7 +49,7 @@ class OceanBookingPrintServiceTest {
|
||||
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
|
||||
.containsEntry("shipper", "Shipper\nAddress")
|
||||
.containsEntry("oceanVesselVoyage", "Vessel / V001")
|
||||
.containsEntry("cargoQuantity", "—")
|
||||
.containsEntry("cargoQuantity", "2*40HQ")
|
||||
.containsEntry("totalPackagesInWords", "—")
|
||||
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
|
||||
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
|
||||
@@ -95,6 +95,39 @@ class OceanBookingPrintServiceTest {
|
||||
.containsEntry("totalPackagesInWords", "—");
|
||||
}
|
||||
|
||||
@Test void routesDeclaredAndActualCargoSeparatelyAndGeneratesWords() {
|
||||
Detail detail = detail("RELEASED");
|
||||
var booking = detail.oceanBooking();
|
||||
booking.setPackageUnit("CARTONS"); booking.setDeclaredPackageCount(500); booking.setActualPackageCount(480);
|
||||
booking.setDeclaredGrossWeightKg(new BigDecimal("15000")); booking.setDeclaredVolumeCbm(new BigDecimal("68"));
|
||||
booking.setActualGrossWeightKg(new BigDecimal("14320.125")); booking.setActualVolumeCbm(new BigDecimal("65.321"));
|
||||
booking.setCargoDescription("HOUSEHOLD GOODS"); booking.setMarksAndNos("N/M");
|
||||
booking.setServiceTerm("CY-CY"); booking.setFreightTerm("FREIGHT PREPAID");
|
||||
detail.parties().get(0).setContactEmail("ops@example.test"); detail.parties().get(0).setTaxNo("TEST-TAX");
|
||||
when(bookings.detail(10L)).thenReturn(detail);
|
||||
when(print.generateFreightA4Documents(any(), any(), any()))
|
||||
.thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1}));
|
||||
service.shippingInstruction(10L, "split"); service.billConfirmation(10L, "split");
|
||||
var capture = ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
|
||||
verify(print, times(2)).generateFreightA4Documents(any(), any(), capture.capture());
|
||||
var declared = capture.getAllValues().get(0).documents().get(0);
|
||||
var actual = capture.getAllValues().get(1).documents().get(0);
|
||||
assertThat(declared.cargoRows().get(0).quantity()).isEqualTo("500");
|
||||
assertThat(declared.cargoRows().get(0).grossWeight()).isEqualTo("15000.000 KGS");
|
||||
assertThat(declared.fields()).containsEntry("totalPackagesInWords", "SAY FIVE HUNDRED CARTONS ONLY")
|
||||
.containsEntry("serviceTerm", "CY-CY").containsEntry("freightTerm", "FREIGHT PREPAID");
|
||||
assertThat(declared.fields().get("shipper")).contains("ops@example.test", "TAX NO: TEST-TAX");
|
||||
assertThat(actual.cargoRows().get(0).quantity()).isEqualTo("480");
|
||||
assertThat(actual.cargoRows().get(0).grossWeight()).isEqualTo("14320.125 KGS");
|
||||
assertThat(actual.cargoRows().get(0).measurement()).isEqualTo("65.321 CBM");
|
||||
assertThat(actual.fields()).containsEntry("totalPackagesInWords", "SAY FOUR HUNDRED EIGHTY CARTONS ONLY");
|
||||
booking.setActualPackageCount(null); booking.setActualGrossWeightKg(null); booking.setActualVolumeCbm(null);
|
||||
service.billConfirmation(10L, "missing");
|
||||
verify(print).generateFreightA4Documents(any(), eq("missing"), capture.capture());
|
||||
assertThat(capture.getValue().documents().get(0).cargoRows().get(0).quantity()).isEqualTo("—");
|
||||
assertThat(capture.getValue().documents().get(0).fields()).containsEntry("totalPackagesInWords", "—");
|
||||
}
|
||||
|
||||
private static Detail detail(String status) {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
|
||||
|
||||
+103
-5
@@ -231,6 +231,19 @@ class OceanBookingServiceTest {
|
||||
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
/** 缺预计到港不能放舱,避免关联柜次后丢失日历及默认到仓日期来源。 */
|
||||
@Test
|
||||
void rejectsReleaseWithoutEstimatedArrival() {
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
draft.setEstimatedArrivalAt(null);
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() {
|
||||
stubMasterData();
|
||||
@@ -386,6 +399,91 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
|
||||
OceanBookingEntity before = completeBooking("RELEASED", 3);
|
||||
before.setDeclaredPackageCount(500);
|
||||
before.setActualPackageCount(450);
|
||||
AtomicReference<OceanBookingEntity> stored = new AtomicReference<>(before);
|
||||
when(repository.find(1L)).thenAnswer(invocation -> stored.get());
|
||||
when(repository.updateActualCargo(any(), eq(3), eq(9L), eq("admin"))).thenAnswer(invocation -> {
|
||||
OceanBookingEntity after = invocation.getArgument(0);
|
||||
after.setVersion(4); stored.set(after); return 1;
|
||||
});
|
||||
var request = new ActualCargoRequest(3, 480, new BigDecimal("14000.125"), new BigDecimal("65.321"), "修正实装货量");
|
||||
var result = service.updateActualCargo(1L, 9L, "admin", "actual-save", request);
|
||||
assertThat(result.oceanBooking().getActualPackageCount()).isEqualTo(480);
|
||||
assertThat(result.oceanBooking().getDeclaredPackageCount()).isEqualTo(500);
|
||||
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
|
||||
assertThat(before.getActualPackageCount()).isEqualTo(450);
|
||||
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ACTUAL_CARGO");
|
||||
assertThat(history.getValue().getBeforeSnapshot()).contains("\"actualPackageCount\":450");
|
||||
assertThat(history.getValue().getAfterSnapshot()).contains("\"actualPackageCount\":480");
|
||||
assertThat(history.getValue().getReason()).isEqualTo("修正实装货量");
|
||||
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
|
||||
verify(repository, never()).replaceRequirements(anyLong(), any());
|
||||
verifyNoInteractions(documents, priceBooks, suppliers, warehouses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsActualCargoOnDraftOrStaleVersionAndRequiresReason() {
|
||||
OceanBookingEntity value = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(value);
|
||||
var request = new ActualCargoRequest(3, 100, null, null, "补录");
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-draft", request)).isInstanceOf(BizException.class);
|
||||
value.setStatus("RELEASED");
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-stale",
|
||||
new ActualCargoRequest(2, 100, null, null, "补录"))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-no-reason",
|
||||
new ActualCargoRequest(3, 100, null, null, " "))).isInstanceOf(BizException.class).hasMessageContaining("原因");
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-negative",
|
||||
new ActualCargoRequest(3, 100, new BigDecimal("-1"), null, "补录"))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-precision",
|
||||
new ActualCargoRequest(3, 100, null, new BigDecimal("1.0001"), "补录"))).isInstanceOf(BizException.class);
|
||||
when(repository.updateActualCargo(any(), anyInt(), anyLong(), any())).thenReturn(0);
|
||||
assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-concurrent", request)).isInstanceOf(BizException.class);
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayedActualCargoDoesNotWriteAgain() throws Exception {
|
||||
var request = new ActualCargoRequest(3, 100, null, null, "补录");
|
||||
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
var operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hash("UPDATE_ACTUAL_CARGO", 1L, request));
|
||||
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
|
||||
when(repository.claimOperation(1L, 9L, "UPDATE_ACTUAL_CARGO", "actual-replay", operation.getRequestHash())).thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_ACTUAL_CARGO", "actual-replay")).thenReturn(operation);
|
||||
assertThat(service.updateActualCargo(1L, 9L, "admin", "actual-replay", request).oceanBooking().getVersion()).isEqualTo(4);
|
||||
verify(repository, never()).updateActualCargo(any(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesUnnamedPartyContactDraftButRequiresNamesBeforeRelease() throws Exception {
|
||||
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.valueToTree(partialDraft(null));
|
||||
tree.putArray("parties").addObject().put("partyType", "SHIPPER").put("partyName", "")
|
||||
.put("contactEmail", "ops@example.test").put("taxNo", "TEST-TAX");
|
||||
stubCreatedDraft();
|
||||
service.create(9L, "admin", "partial-party", new CreateRequest(0, objectMapper.treeToValue(tree, DraftData.class)));
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity>> capture = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceParties(anyLong(), capture.capture());
|
||||
assertThat(capture.getValue()).singleElement().satisfies(party -> {
|
||||
assertThat(party.getContactEmail()).isEqualTo("ops@example.test");
|
||||
assertThat(party.getTaxNo()).isEqualTo("TEST-TAX");
|
||||
});
|
||||
var draft = completeBooking("DRAFT", 3);
|
||||
when(repository.find(1L)).thenReturn(draft);
|
||||
var parties = parties(); parties.get(0).setPartyName("");
|
||||
when(repository.parties(1L)).thenReturn(parties);
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "missing-party", new ReleaseRequest(3, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
|
||||
verify(repository, never()).transition(anyLong(), any(), any(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
private AtomicReference<OceanBookingEntity> stubCreatedDraft() {
|
||||
AtomicReference<OceanBookingEntity> inserted = new AtomicReference<>();
|
||||
doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; })
|
||||
@@ -412,7 +510,7 @@ class OceanBookingServiceTest {
|
||||
|
||||
private static DraftData partialDraft(String remark) {
|
||||
return new DraftData(null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, remark, null, null);
|
||||
null, null, null, null, null, null, null, null, null, remark, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static DraftData completeDraft() {
|
||||
@@ -420,11 +518,11 @@ class OceanBookingServiceTest {
|
||||
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
"LAX", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null,
|
||||
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
|
||||
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null, null, null),
|
||||
new PartyRequest("CONSIGNEE", "收货人", null, null, null, null, null),
|
||||
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null, null, null)),
|
||||
List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
|
||||
new BigDecimal("68"), new BigDecimal("19500"))));
|
||||
new BigDecimal("68"), new BigDecimal("19500"))), null, null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static OceanBookingEntity completeBooking(String status, int version) {
|
||||
|
||||
+14
-2
@@ -9,8 +9,20 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentForecastServiceTest {
|
||||
/** 无船期预计观察时仍返回日历所用的柜级到港事实,供海外统一推算日期。 */
|
||||
@Test void returnsTheCalendarArrivalEvenWhenLatestForecastIsAbsent() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);
|
||||
var sources=mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class);
|
||||
var arrival=LocalDateTime.of(2026,10,30,23,30);
|
||||
when(sources.byIds(List.of(1L))).thenReturn(List.of(
|
||||
new com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource(1L,"D0001","CONT1",
|
||||
null,null,null,null,arrival,"ACTUAL")));
|
||||
var result=new AppointmentForecastService(mapper,sources).batch(List.of(1L));
|
||||
assertThat(result.get(0).latest()).isNull();
|
||||
assertThat(result.get(0).portArrivalAt()).isEqualTo(arrival);
|
||||
}
|
||||
@Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class));
|
||||
var observed=LocalDateTime.of(2026,10,10,12,0);
|
||||
var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null);
|
||||
var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null);
|
||||
@@ -21,7 +33,7 @@ class AppointmentForecastServiceTest {
|
||||
verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
@Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class));
|
||||
for(var ids:List.of(List.<Long>of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L)))
|
||||
assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.DataSourceTransactionManager;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/** 用真实 Mapper SQL、事务代理和独立连接验证完成检查点的租约隔离。 */
|
||||
class LoadingCompletionCheckpointLeaseTest {
|
||||
private JdbcDataSource dataSource;
|
||||
private JdbcTemplate jdbc;
|
||||
private LoadingCompletionCommandMapper mapper;
|
||||
private LoadingCompletionCommandRepository commands;
|
||||
|
||||
/** 每个用例使用独立内存数据库,不访问共享环境。 */
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:completion-lease-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=1000");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fl_loading_completion_command(
|
||||
id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP,
|
||||
attempt_count INT,next_retry_at TIMESTAMP,updated_at TIMESTAMP,completed_at TIMESTAMP,
|
||||
failure_stage VARCHAR(40),failure_code VARCHAR(32),failure_request_id VARCHAR(64),failure_trace_id VARCHAR(64))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE fl_loading_completion_checkpoint(
|
||||
command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),
|
||||
updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))
|
||||
""");
|
||||
jdbc.update("""
|
||||
INSERT INTO fl_loading_completion_command(id,command_status,lease_owner,lease_until,attempt_count,next_retry_at)
|
||||
VALUES(900,'PROCESSING','old-owner',DATEADD('HOUR',1,CURRENT_TIMESTAMP),1,CURRENT_TIMESTAMP)
|
||||
""");
|
||||
var configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(LoadingCompletionCommandMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource);
|
||||
factory.setConfiguration(configuration);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingCompletionCommandMapper.class);
|
||||
commands = repository(mapper);
|
||||
}
|
||||
|
||||
/** 生产 Repository 的注解事务必须真实生效,覆盖锁定与写入两条 SQL。 */
|
||||
private LoadingCompletionCommandRepository repository(LoadingCompletionCommandMapper targetMapper) {
|
||||
var target = new LoadingCompletionCommandRepository();
|
||||
ReflectionTestUtils.setField(target, "mapper", targetMapper);
|
||||
var proxy = new ProxyFactory(target);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),
|
||||
new AnnotationTransactionAttributeSource()));
|
||||
return (LoadingCompletionCommandRepository) proxy.getProxy();
|
||||
}
|
||||
|
||||
/** 模拟原执行者续租后暂停,租约过期并被新执行者领取推进,再恢复旧线程。 */
|
||||
@Test
|
||||
void pausedOldOwnerCannotDowngradeTheNewOwnersCompletedCheckpoint() throws Exception {
|
||||
var renewed = new CountDownLatch(1);
|
||||
var resumeOldOwner = new CountDownLatch(1);
|
||||
var executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
var oldWrite = executor.submit(() -> {
|
||||
assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isTrue();
|
||||
renewed.countDown();
|
||||
assertThat(resumeOldOwner.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
return commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
|
||||
});
|
||||
assertThat(renewed.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
|
||||
assertThat(commands.claim(900, "new-owner", LocalDateTime.now().plusMinutes(10))).isTrue();
|
||||
assertThat(commands.checkpoint(900, "new-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue();
|
||||
resumeOldOwner.countDown();
|
||||
assertThat(oldWrite.get(10, TimeUnit.SECONDS)).isFalse();
|
||||
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "SETTLED")).isZero();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(jdbc.queryForObject("SELECT lease_owner FROM fl_loading_completion_command WHERE id=900", String.class))
|
||||
.isEqualTo("new-owner");
|
||||
} finally {
|
||||
resumeOldOwner.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** 锁定后的实际写入前,独立数据库连接仍不能取得同一命令行的锁。 */
|
||||
@Test
|
||||
void commandRowRemainsLockedAcrossTheLeaseCheckAndCheckpointInsert() {
|
||||
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
|
||||
doAnswer(call -> {
|
||||
assertThatThrownBy(() -> {
|
||||
try (var connection = dataSource.getConnection();
|
||||
var statement = connection.prepareStatement("SELECT id FROM fl_loading_completion_command WHERE id=900 FOR UPDATE NOWAIT")) {
|
||||
statement.executeQuery().close();
|
||||
}
|
||||
}).isInstanceOf(SQLException.class)
|
||||
.satisfies(error -> assertThat(((SQLException) error).getErrorCode()).isEqualTo(50200));
|
||||
return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5));
|
||||
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY");
|
||||
}
|
||||
|
||||
/** 初次锁定检查后若租约失效,实际 INSERT 也必须拒绝,不能仅依赖前一次检查。 */
|
||||
@Test
|
||||
void checkpointInsertRechecksLeaseValidityAfterTheInitialLock() {
|
||||
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
|
||||
doAnswer(call -> {
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
|
||||
return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5));
|
||||
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isNull();
|
||||
}
|
||||
|
||||
/** 驱动将已持久化的幂等重放计为零行时,仍须以锁内当前有效租约确认。 */
|
||||
@Test
|
||||
void zeroAffectedRowsCanConfirmAnIdempotentCheckpointUnderTheSameValidLease() {
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
|
||||
LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper));
|
||||
doAnswer(call -> {
|
||||
mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2),
|
||||
call.getArgument(3), call.getArgument(4), call.getArgument(5));
|
||||
return 0;
|
||||
}).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue();
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY");
|
||||
}
|
||||
|
||||
/** 已过期但尚未被重新领取的所有者也不能写检查点、续租或写命令终态。 */
|
||||
@Test
|
||||
void expiredLeaseCannotWriteCheckpointRenewFailOrComplete() {
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900");
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
|
||||
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero();
|
||||
assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isFalse();
|
||||
assertThat(commands.fail(900, "old-owner", "FAILED", LocalDateTime.now(), "LEASE_LOST", "A0409", null, null)).isFalse();
|
||||
assertThat(commands.complete(900, "old-owner")).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT command_status FROM fl_loading_completion_command WHERE id=900", String.class))
|
||||
.isEqualTo("PROCESSING");
|
||||
}
|
||||
|
||||
/** 同一当前所有者的重复或迟到阶段也只能保持或推进,不能降低完成检查点。 */
|
||||
@Test
|
||||
void validOwnerCheckpointStagesAreMonotonic() {
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED")).isTrue();
|
||||
commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("SETTLED");
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue();
|
||||
commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED");
|
||||
commands.checkpoint(900, "old-owner", 101, 50, 1, "READY");
|
||||
assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
|
||||
}
|
||||
|
||||
/** 完成或失败后的命令不允许追加检查点,即使所有者与租期字段仍有旧值。 */
|
||||
@Test
|
||||
void terminalCommandCannotAcceptAnotherCheckpoint() {
|
||||
jdbc.update("UPDATE fl_loading_completion_command SET command_status='COMPLETED' WHERE id=900");
|
||||
assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse();
|
||||
assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero();
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -31,6 +31,8 @@ class LoadingCompletionOwnerPersistenceTest {
|
||||
jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)");
|
||||
jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))");
|
||||
jdbc.execute("CREATE TABLE fl_loading_completion_command(id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO fl_loading_completion_command VALUES(900,'PROCESSING','owner-test',DATEADD('HOUR',1,CURRENT_TIMESTAMP))");
|
||||
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)");
|
||||
row(1, 101, 50, 3, "PARTIAL", 2, 10, 0);
|
||||
row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history
|
||||
@@ -76,11 +78,11 @@ class LoadingCompletionOwnerPersistenceTest {
|
||||
|
||||
@Test
|
||||
void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() {
|
||||
checkpoints.checkpointCompleted(900, 101, 50, 1, "READY");
|
||||
checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED");
|
||||
checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, 303, 70, 3, "READY");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "READY");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "SETTLED");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 202, 60, 2, "INBOUND_COMPLETED");
|
||||
checkpoints.checkpointCompleted(900, "owner-test", 303, 70, 3, "READY");
|
||||
assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY");
|
||||
|
||||
+103
-1
@@ -8,22 +8,124 @@ import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(LoadingCompletionWorker.class)
|
||||
@SpringJUnitConfig({LoadingCompletionWorker.class, LoadingCompletionWorkerTest.TransactionEvents.class})
|
||||
class LoadingCompletionWorkerTest {
|
||||
/** 与生产事务装配一致,注册事务事件工厂,避免普通事件工厂立即调用监听器。 */
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
static class TransactionEvents {}
|
||||
|
||||
@MockBean private LoadingCompletionCommandRepository commands;
|
||||
@MockBean private LoadingPickRepository picks;
|
||||
@MockBean private LoadingPickService loadingPicks;
|
||||
@MockBean(name = "applicationTaskExecutor") private TaskExecutor completionExecutor;
|
||||
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
|
||||
@jakarta.annotation.Resource private ApplicationEventPublisher events;
|
||||
|
||||
/** 真实事务同步回调必须在提交后才将完成工作交给执行器。 */
|
||||
@Test
|
||||
void committedAcceptanceStartsImmediatelyThroughTheDurableLease() {
|
||||
LoadingCompletionCommandEntity command = command(0);
|
||||
when(commands.command(900L)).thenReturn(command);
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
transactions().executeWithoutResult(status -> {
|
||||
events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
verify(completionExecutor, never()).execute(any());
|
||||
verify(loadingPicks, never()).processCompletion(any(), any());
|
||||
});
|
||||
|
||||
ArgumentCaptor<Runnable> work = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(completionExecutor).execute(work.capture());
|
||||
verify(loadingPicks, never()).processCompletion(any(), any());
|
||||
work.getValue().run();
|
||||
|
||||
verify(commands).claim(eq(900L), any(String.class), any(LocalDateTime.class));
|
||||
verify(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
}
|
||||
|
||||
/** 回滚后的命令没有权威落库事实,不允许唤醒或执行结算。 */
|
||||
@Test
|
||||
void rolledBackAcceptanceNeverSchedulesCompletion() {
|
||||
transactions().executeWithoutResult(status -> {
|
||||
events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
verify(completionExecutor, never()).execute(any());
|
||||
verify(commands, never()).command(900L);
|
||||
}
|
||||
|
||||
/** 即时唤醒不能绕过定时执行者已取得的租约。 */
|
||||
@Test
|
||||
void immediateWakeupDoesNotProcessAnAlreadyLeasedCommand() {
|
||||
when(commands.command(900L)).thenReturn(command(0));
|
||||
worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
ArgumentCaptor<Runnable> work = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(completionExecutor).execute(work.capture());
|
||||
work.getValue().run();
|
||||
verify(loadingPicks, never()).processCompletion(any(), any());
|
||||
}
|
||||
|
||||
/** 执行器拒绝时受理不报错,定时扫描仍可从原命令恢复。 */
|
||||
@Test
|
||||
void rejectedWakeupLeavesScheduledRecoveryAvailable() {
|
||||
doThrow(new RejectedExecutionException("busy")).when(completionExecutor).execute(any());
|
||||
LoadingCompletionCommandEntity command = command(0);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime()));
|
||||
worker.dispatch();
|
||||
verify(loadingPicks).processCompletion(eq(command), any(String.class));
|
||||
}
|
||||
|
||||
/** 同进程重领已到期命令也必须更换所有者,旧线程不能续租或落库。 */
|
||||
@Test
|
||||
void everyClaimUsesAnIndependentLeaseOwner() {
|
||||
LoadingCompletionCommandEntity command = command(0);
|
||||
when(commands.due(10)).thenReturn(List.of(command));
|
||||
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
|
||||
worker.dispatch();
|
||||
worker.dispatch();
|
||||
ArgumentCaptor<String> owners = ArgumentCaptor.forClass(String.class);
|
||||
verify(loadingPicks, times(2)).processCompletion(eq(command), owners.capture());
|
||||
assertThat(owners.getAllValues()).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
/** 隔离数据库依赖,仅用 Spring 的真实事务同步机制核对提交、回滚边界。 */
|
||||
private static TransactionTemplate transactions() {
|
||||
return new TransactionTemplate(new AbstractPlatformTransactionManager() {
|
||||
/** 为每次测试提供独立事务对象。 */
|
||||
@Override protected Object doGetTransaction() { return new Object(); }
|
||||
/** 测试事务不访问数据库。 */
|
||||
@Override protected void doBegin(Object transaction, TransactionDefinition definition) {}
|
||||
/** 让 Spring 正常触发提交同步回调。 */
|
||||
@Override protected void doCommit(DefaultTransactionStatus status) {}
|
||||
/** 让 Spring 正常触发回滚同步回调。 */
|
||||
@Override protected void doRollback(DefaultTransactionStatus status) {}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailureIsReleasedForBackoffRetry() {
|
||||
|
||||
@@ -50,6 +50,9 @@ import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.context.event.RecordApplicationEvents;
|
||||
import org.springframework.test.context.event.ApplicationEvents;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@@ -77,7 +80,9 @@ import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringJUnitConfig(LoadingPickService.class)
|
||||
@RecordApplicationEvents
|
||||
class LoadingPickServiceTest {
|
||||
@Autowired private ApplicationEvents completionEvents;
|
||||
@MockBean
|
||||
private LoadingPickRepository repository;
|
||||
@MockBean
|
||||
@@ -154,6 +159,8 @@ class LoadingPickServiceTest {
|
||||
when(repository.activeBookingByTask(20L)).thenReturn(booking);
|
||||
when(completionCommands.insert(any())).thenReturn(1);
|
||||
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
|
||||
when(completionCommands.checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
|
||||
when(repository.inboundOwners(anyLong())).thenReturn(List.of(
|
||||
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
|
||||
@@ -931,6 +938,7 @@ class LoadingPickServiceTest {
|
||||
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
|
||||
verify(repository, never()).taskForUpdate(20L);
|
||||
verify(completionCommands, times(1)).insert(any());
|
||||
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class).count()).isEqualTo(1);
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
@@ -997,6 +1005,8 @@ class LoadingPickServiceTest {
|
||||
|
||||
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
|
||||
verify(completionCommands).resume(902L);
|
||||
assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class)
|
||||
.map(LoadingPickService.CompletionAccepted::commandId)).containsExactly(902L);
|
||||
verify(repository).resumeCompletion(20L);
|
||||
verify(completionCommands, never()).insert(any());
|
||||
}
|
||||
@@ -1089,7 +1099,7 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L));
|
||||
verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(eq(931L), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
verify(completionCommands).complete(931L, "worker-completed-checkpoints");
|
||||
}
|
||||
@@ -1109,6 +1119,31 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
}
|
||||
|
||||
/** 任一检查点被租约拒绝后都不得继续下一业务步骤或最终完成柜次。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"READY", "SETTLED", "INBOUND_COMPLETED"})
|
||||
void lostLeaseAtCheckpointStopsFurtherCompletion(String rejectedStage) {
|
||||
LoadingPickTaskEntity completing = task();
|
||||
completing.setTaskStatus("COMPLETING");
|
||||
when(repository.task(20L)).thenReturn(completing);
|
||||
when(completionCommands.checkpoint(eq(940L), eq("old-owner"), eq(101L), eq(50L), eq(1), eq(rejectedStage)))
|
||||
.thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.processCompletion(completionCommand(940L, "lost-checkpoint"), "old-owner"))
|
||||
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
|
||||
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage())
|
||||
.isEqualTo("LEASE_LOST"));
|
||||
if ("READY".equals(rejectedStage)) {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
if (!"INBOUND_COMPLETED".equals(rejectedStage)) {
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), anyString());
|
||||
}
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(completionCommands, never()).complete(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEntirelySkippedTaskCannotCreateACompletionCommand() {
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task());
|
||||
@@ -1271,8 +1306,8 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), anyString());
|
||||
verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY");
|
||||
verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(),
|
||||
verify(completionCommands).checkpoint(904L, "worker-failure", 101L, 50L, 1, "READY");
|
||||
verify(completionCommands, never()).checkpoint(eq(904L), anyString(), anyLong(), anyLong(), anyInt(),
|
||||
eq("SETTLED"));
|
||||
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
@@ -1300,7 +1335,7 @@ class LoadingPickServiceTest {
|
||||
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(completionCommands, never()).checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
|
||||
+2
@@ -46,6 +46,8 @@ class ContainerCalendarSourceMapperTest {
|
||||
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());
|
||||
var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L));
|
||||
assertEquals(calendar,explicit);
|
||||
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");
|
||||
|
||||
@@ -36,8 +36,8 @@ class WorkflowBoundaryTest {
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||
}
|
||||
|
||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||
@Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() {
|
||||
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
|
||||
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
|
||||
var repository=mock(WorkflowFactRepository.class);
|
||||
var downstream=mock(LastlegWorkflowClient.class);
|
||||
var service=new WorkflowTabService(repository,downstream);
|
||||
@@ -45,12 +45,21 @@ class WorkflowBoundaryTest {
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||
verifyNoInteractions(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||
verifyNoInteractions(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(11,1,false,false,false,false))));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE");
|
||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||
clearInvocations(downstream);
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,true)));
|
||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 取消回显、分柜隔离和资料页签回归。 */
|
||||
class WorkflowBusinessTest {
|
||||
/** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */
|
||||
/** 实际开船前保留出口报关,实际开船后再按清关资料进入海外待办。 */
|
||||
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
|
||||
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false);
|
||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false);
|
||||
@@ -18,9 +18,9 @@ class WorkflowBusinessTest {
|
||||
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
|
||||
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(completed,"LOADED",null))
|
||||
.containsExactly("SAILING_CLEARANCE");
|
||||
.containsExactly("EXPORT_CUSTOMS");
|
||||
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
|
||||
.containsExactly("WAIT_UNLOAD","WAIT_DISPATCH");
|
||||
.containsExactly("EXPORT_CUSTOMS");
|
||||
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
|
||||
.containsExactly("SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 |
|
||||
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
|
||||
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜,并由 `ContainerWarehouseArrivalService` 统一解析:人工维护日期或已有计划冻结日期优先,未维护未建计划时读取 Firstleg 权威到港当地日期加两个自然日;缺失或同柜不一致时阻断,清关提柜、日历和人工/自动计划统一使用 `plannedDate`,默认日期不补零点,首个计划创建后按已有计划日期冻结;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java`、`service/LastlegDocumentNumberGenerator.java`;头程来源使用14位 `WC + yyyyMMdd + 四位流水`,建单经 `service/LoadingCompletedConsumer.java` 在来源柜锁内复用已有号,正式格式见工作区 `docs/业务单据编号契约.md` |
|
||||
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
|
||||
@@ -97,7 +97,7 @@
|
||||
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
|
||||
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
|
||||
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
|
||||
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
||||
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
|
||||
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
|
||||
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
|
||||
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
|
||||
@@ -112,7 +112,7 @@
|
||||
## 事件建单规则
|
||||
|
||||
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
||||
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
|
||||
- 同一冻结来源柜 `sourceContainerBookingId` 共用一个14位 `WC + yyyyMMdd + 四位流水` 尾程单号;首个交接事件在来源柜行锁内取号,后续客户或跨日交接复用该柜已保存的编号,事件重放不取新号。不同来源柜复用真实集装箱号时仍分别编号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。真实 `containerNo` 继续保留作实箱快照,`cabinetNo` 仅为柜次展示,不替代稳定来源柜身份。
|
||||
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
|
||||
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
|
||||
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public interface AppointmentForecastClient {
|
||||
record Request(List<Long> containerBookingIds) { }
|
||||
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
|
||||
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
|
||||
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { }
|
||||
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,LocalDateTime portArrivalAt) { }
|
||||
@PostMapping("/internal/firstleg/appointment-forecasts/batch")
|
||||
ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request);
|
||||
}
|
||||
|
||||
+5
@@ -25,6 +25,11 @@ public interface AppointmentWorkDispatchPort {
|
||||
void requireDeparture(String dispatchNo);
|
||||
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
|
||||
String departureBlockReason(String dispatchNo);
|
||||
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
|
||||
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
|
||||
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
|
||||
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
|
||||
List<ReceivingWindow> reviewEvidence(String dispatchNo);
|
||||
void removeItem(long dispatchId,long itemId);
|
||||
void requireTripBinding(String dispatchNo,long tripId);
|
||||
}
|
||||
|
||||
+13
-6
@@ -12,24 +12,31 @@ public interface WarehouseCalendarMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT * FROM (
|
||||
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
|
||||
SELECT o.source_container_booking_id sourceContainerBookingId,
|
||||
MAX(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo')),'null')) cabinetNo,
|
||||
MAX(o.container_no) containerNo,
|
||||
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
|
||||
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
|
||||
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
|
||||
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
|
||||
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
|
||||
WHERE po.source_container_booking_id=o.source_container_booking_id))
|
||||
AND MAX(o.arrival_time_version)>0
|
||||
THEN MAX(o.estimated_warehouse_at) END plannedAt,
|
||||
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
|
||||
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
|
||||
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
|
||||
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
|
||||
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
|
||||
FALSE canEdit
|
||||
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
|
||||
FALSE canEdit,
|
||||
(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1
|
||||
THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p
|
||||
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
|
||||
WHERE po.source_container_booking_id=o.source_container_booking_id) plannedDate,
|
||||
FALSE isDefault
|
||||
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
|
||||
WHERE o.source_container_booking_id IS NOT NULL
|
||||
GROUP BY o.source_container_booking_id
|
||||
) c WHERE
|
||||
(c.plannedAt >= #{from} AND c.plannedAt < #{to})
|
||||
OR (c.plannedDate >= DATE(#{from}) AND c.plannedDate < DATE(#{to}))
|
||||
<if test="ids != null and !ids.isEmpty()">
|
||||
OR c.sourceContainerBookingId IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ public final class WarehouseCalendarModels {
|
||||
private WarehouseCalendarModels() { }
|
||||
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
|
||||
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
|
||||
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
|
||||
public record Entry(String sourceContainerBookingId, String cabinetNo, String containerNo, String warehouseName,
|
||||
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
|
||||
long planCount, boolean canEdit) { }
|
||||
long planCount, boolean canEdit, LocalDate plannedDate, boolean isDefault) { }
|
||||
}
|
||||
|
||||
+14
-4
@@ -10,8 +10,10 @@ import java.util.List;
|
||||
@Service
|
||||
public class WarehouseCalendarService {
|
||||
private final WarehouseCalendarRepository repository;
|
||||
private final com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals;
|
||||
/** 装配日历只读仓储查询。 */
|
||||
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
|
||||
public WarehouseCalendarService(WarehouseCalendarRepository repository,
|
||||
com.baiyue.lastleg.service.ContainerWarehouseArrivalService arrivals) { this.repository = repository; this.arrivals=arrivals; }
|
||||
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
|
||||
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
|
||||
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|
||||
@@ -20,8 +22,16 @@ public class WarehouseCalendarService {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
|
||||
var rows = repository.calendar(query);
|
||||
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
|
||||
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
|
||||
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
|
||||
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
|
||||
var resolved=arrivals.resolve(rows.stream().map(WarehouseCalendarModels.Entry::sourceContainerBookingId).toList())
|
||||
.stream().collect(java.util.stream.Collectors.toMap(
|
||||
com.baiyue.lastleg.dto.ContainerArrivalTimeView::sourceContainerBookingId,java.util.function.Function.identity()));
|
||||
return rows.stream().map(row -> {
|
||||
var arrival=resolved.get(row.sourceContainerBookingId());
|
||||
if(arrival==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到仓日期读取不完整");
|
||||
return new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.cabinetNo(), row.containerNo(),
|
||||
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
|
||||
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null,
|
||||
arrival.plannedDate(),arrival.isDefault());
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@FeignClient(name="order-service", contextId="lastlegOrderPreDepartureClient", path="/internal/order/v1/predeparture-orders")
|
||||
public interface OrderPreDepartureClient {
|
||||
@GetMapping
|
||||
ApiResponse<PageResult<Row>> page(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam(required=false) Long customerId, @RequestParam(required=false) String orderNo,
|
||||
@RequestParam(required=false) String inboundCode, @RequestParam int page, @RequestParam int size);
|
||||
record Row(Long orderId, Long customerId, String orderNo, String inboundCode, String mainStatus,
|
||||
String customerName, String lxCode, String receivingWarehouseName, String destinationWarehouseName,
|
||||
String destinationCode, Integer packageCount, BigDecimal volumeCbm, BigDecimal grossWeightKg,
|
||||
BigDecimal netWeightKg, LocalDateTime expectedArrivalAt, String containerNo, String cabinetNo,
|
||||
LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
}
|
||||
+9
@@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets;
|
||||
public class DispatchQueryController {
|
||||
@Resource private DispatchAggregateService aggregateService;
|
||||
@Resource private DispatchBolPrintService printService;
|
||||
@Resource private com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@@ -47,6 +48,14 @@ public class DispatchQueryController {
|
||||
return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query));
|
||||
}
|
||||
|
||||
/** 内部需求池沿独立只读权限,不授予销售整车客户及司机资料读取权限。 */
|
||||
@GetMapping("/capacity-demands")
|
||||
@PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
|
||||
public ApiResponse<PageResult<com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Demand>> capacityDemands(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) {
|
||||
requirePlatform(user); return ApiResponse.success(capacityDemands.page(query));
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
|
||||
@Operation(summary = "Get dispatch details and all pallet destinations")
|
||||
|
||||
+2
@@ -9,6 +9,8 @@ public class DispatchAggregate {
|
||||
private Long id;
|
||||
private String dispatchNo;
|
||||
private String taskType;
|
||||
/** 是否向内部散板需求池发布本车余位。 */
|
||||
private Boolean publishLooseboardDemand = true;
|
||||
private Long tripId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
|
||||
+14
-3
@@ -40,12 +40,12 @@ public interface DispatchAggregateMapper {
|
||||
|
||||
/** 首次创建整车所有权及初始版本,不写customer_id。 */
|
||||
@Insert("""
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,publish_looseboard_demand,trip_id,overseas_warehouse_id,overseas_warehouse_code,
|
||||
overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,
|
||||
dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at,
|
||||
estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,
|
||||
remark,created_by,created_at,updated_at)
|
||||
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.publishLooseboardDemand},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode},
|
||||
#{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash},
|
||||
#{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson},
|
||||
#{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount},
|
||||
@@ -66,6 +66,17 @@ public interface DispatchAggregateMapper {
|
||||
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}</script>")
|
||||
List<DispatchAggregate> page(@Param("q") Search query);
|
||||
|
||||
/** 只读余位来源仅含勾选发布、未发车的有效派送排车,不混入取货和本土卡车。 */
|
||||
@Select("""
|
||||
<script>SELECT d.* FROM ll_dispatch_order d
|
||||
WHERE d.publish_looseboard_demand=TRUE AND d.task_type='DELIVERY' AND d.status='ACTIVE'
|
||||
AND d.departed_at IS NULL AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
|
||||
<if test='q.origin != null'>AND d.overseas_warehouse_name LIKE CONCAT('%',#{q.origin},'%')</if>
|
||||
<if test='q.destination != null'>AND d.route LIKE CONCAT('%',#{q.destination},'%')</if>
|
||||
ORDER BY d.planned_departure_at,d.created_at DESC,d.id DESC</script>
|
||||
""")
|
||||
List<DispatchAggregate> capacityDemands(@Param("q") com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query);
|
||||
|
||||
/** 分页总数复用完全相同筛选,不能用当前页长度替代。 */
|
||||
@Select("<script>SELECT COUNT(*) FROM ll_dispatch_order d WHERE 1=1 " + BASE_FILTER + CURRENT_FILTER + "</script>")
|
||||
long count(@Param("q") Search query);
|
||||
@@ -91,7 +102,7 @@ public interface DispatchAggregateMapper {
|
||||
|
||||
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
|
||||
@Update("""
|
||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
|
||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},publish_looseboard_demand=#{r.publishLooseboardDemand},
|
||||
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
|
||||
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
|
||||
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/** 内部余位池仅公开本车运输摘要,不包含客户货物或司机私人资料。 */
|
||||
public final class DispatchCapacityDemands {
|
||||
/** 只承载只读查询模型。 */
|
||||
private DispatchCapacityDemands() { }
|
||||
public record Query(Integer page, Integer size, String origin, String destination) {
|
||||
/** 查询范围有界,默认每页二十条。 */
|
||||
public Query {
|
||||
page = page == null ? 1 : page; size = size == null ? 20 : size;
|
||||
if (page < 1 || size < 1 || size > 100) throw new IllegalArgumentException("余位需求分页参数无效");
|
||||
origin = origin == null || origin.isBlank() ? null : origin.trim();
|
||||
destination = destination == null || destination.isBlank() ? null : destination.trim();
|
||||
}
|
||||
}
|
||||
public record Demand(String dispatchNo, String warehouseName, String route, Instant plannedDepartureAt,
|
||||
Integer vehiclePalletSpaces, Integer scheduledPalletCount, Integer remainingPalletSpaces,
|
||||
BigDecimal remainingWeightKg, BigDecimal remainingVolumeCbm, boolean capacityKnown, String capacityReason) { }
|
||||
}
|
||||
+8
-5
@@ -23,13 +23,16 @@ public final class DispatchCommands {
|
||||
/** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */
|
||||
public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark,
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations) { }
|
||||
List<CargoSelection> cargo, List<BatchAllocation> batchAllocations, Boolean publishLooseboardDemand) {
|
||||
/** 新建排车默认发布内部余位,显式取消时保持不发布。 */
|
||||
public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; }
|
||||
}
|
||||
|
||||
public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt,
|
||||
String remark) {
|
||||
String remark, Boolean publishLooseboardDemand, Maintenance maintenance) {
|
||||
/** 缺版本与有效版本零必须区分。 */
|
||||
public Update { recordVersion = requireVersion(recordVersion); }
|
||||
public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); }
|
||||
}
|
||||
|
||||
public record AddCargo(Long recordVersion, String idempotencyKey, List<CargoSelection> cargo,
|
||||
@@ -66,9 +69,9 @@ public final class DispatchCommands {
|
||||
public Pick { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
/** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */
|
||||
/** 按服务端已核平的扫码托盘确认装车,不要求现场重复测量或补填箱数。 */
|
||||
public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash,
|
||||
List<LoadingMeasurement> measurements, String remark) {
|
||||
String remark) {
|
||||
/** 后台与PDA共用必填版本约束。 */
|
||||
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
|
||||
}
|
||||
|
||||
+33
-1
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
@@ -42,7 +44,37 @@ public final class DispatchContracts {
|
||||
public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone,
|
||||
DriverEvidence evidence) { }
|
||||
|
||||
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { }
|
||||
/** 本趟可空维护资料,不替代正式预约或实际发车事实。 */
|
||||
public record Maintenance(@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
|
||||
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
|
||||
String loadType, String fleetEmail, String vehicleType,
|
||||
String appointmentAccount, String appointmentNo) {
|
||||
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
|
||||
public Maintenance {
|
||||
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
|
||||
pickupMethod = option(pickupMethod, "提卸方式", "一提一卸", "一提多卸");
|
||||
loadType = option(loadType, "装载类型", "托盘", "地板", "混装");
|
||||
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
|
||||
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
|
||||
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
|
||||
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
|
||||
}
|
||||
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
|
||||
private static String option(String value, String label, String... allowed) {
|
||||
String normalized = limited(value, label, 128);
|
||||
if (normalized != null && !List.of(allowed).contains(normalized)) throw new IllegalArgumentException(label + "无效");
|
||||
return normalized;
|
||||
}
|
||||
/** 统一文本空值及长度边界,不把补充资料写入备注或车牌。 */
|
||||
private static String limited(String value, String label, int max) {
|
||||
String normalized = value == null || value.isBlank() ? null : value.trim();
|
||||
if (normalized != null && normalized.length() > max) throw new IllegalArgumentException(label + "最多" + max + "字符");
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
|
||||
|
||||
public record Destination(String key, String type, String warehouseId, String warehouseCode,
|
||||
String warehouseName, String address, String postalCode,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/** 本趟补充日期允许清空,但不能将时间串或不存在的日期截断、修正为有效日期。 */
|
||||
public final class DispatchMaintenanceDateDeserializer extends StdDeserializer<LocalDate> {
|
||||
/** 仅绑定维护资料的可空日期,不改变其他业务日期解析规则。 */
|
||||
public DispatchMaintenanceDateDeserializer() { super(LocalDate.class); }
|
||||
|
||||
/** 空白归空,其余只接受四位年、两位月日的严格ISO日期。 */
|
||||
@Override
|
||||
public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
if (!parser.hasToken(JsonToken.VALUE_STRING)) return (LocalDate) context.handleUnexpectedToken(LocalDate.class, parser);
|
||||
String value = parser.getText().trim();
|
||||
if (value.isEmpty()) return null;
|
||||
try {
|
||||
if (!value.matches("\\d{4}-\\d{2}-\\d{2}")) throw new DateTimeParseException("日期格式无效", value, 0);
|
||||
return LocalDate.parse(value);
|
||||
} catch (DateTimeParseException failure) {
|
||||
return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, "计划派送日期须为有效的YYYY-MM-DD");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public final class DispatchViews {
|
||||
boolean pickingReconciled, boolean arranged,
|
||||
Map<Action, Gate> actions) { }
|
||||
|
||||
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { }
|
||||
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
|
||||
|
||||
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
|
||||
String destinationKey, String destinationWarehouseCode,
|
||||
@@ -64,11 +64,12 @@ public final class DispatchViews {
|
||||
ReviewState reviewState, Reviews reviews, Counts counts,
|
||||
boolean pickingReconciled, Map<Action, Gate> actions,
|
||||
VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents,
|
||||
List<OrderDetail> orderDetails) { }
|
||||
List<OrderDetail> orderDetails, boolean publishLooseboardDemand) { }
|
||||
|
||||
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
|
||||
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
|
||||
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions,
|
||||
Instant plannedDepartureAt, Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments) { }
|
||||
|
||||
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
|
||||
Destination destination, long scheduledPackages, long pickedPackages,
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ public class DispatchAggregateRepository {
|
||||
public DispatchAggregate lock(String number) { return mapper.lock(number); }
|
||||
/** 整车分页。 */
|
||||
public List<DispatchAggregate> page(DispatchAggregateMapper.Search query) { return mapper.page(query); }
|
||||
/** 一次读取当前有效的内部余位来源,服务计算满载筛选后再分页。 */
|
||||
public List<DispatchAggregate> capacityDemands(com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) { return mapper.capacityDemands(query); }
|
||||
/** 整车分页总数。 */
|
||||
public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); }
|
||||
/** 全搜索基集的阶段和审核分布。 */
|
||||
|
||||
+63
-27
@@ -305,11 +305,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId());
|
||||
row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName());
|
||||
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
|
||||
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources));
|
||||
Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources));
|
||||
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
|
||||
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
|
||||
@@ -372,13 +373,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
|
||||
if(trim(request.reason())==null)throw bad("取消原因不能为空");
|
||||
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消");
|
||||
var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
||||
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
|
||||
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
|
||||
releaseSources(row,actor,items,now());
|
||||
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
|
||||
return saveCommand(row,actor,identity,request.reason());
|
||||
}
|
||||
|
||||
/** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */
|
||||
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
|
||||
requirePlatform(actor);
|
||||
@@ -390,9 +392,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
|
||||
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
|
||||
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
|
||||
var vehiclePhotos=request.vehicle()==null?null:request.vehicle().evidenceFileIds();
|
||||
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
|
||||
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
|
||||
row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setResourcesJson(write(resources(actual,photos,request.maintenance())));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
|
||||
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
|
||||
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
|
||||
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
|
||||
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
|
||||
return saveCommand(row,actor,identity,request.remark());
|
||||
@@ -432,8 +437,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name();
|
||||
if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化");
|
||||
if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空");
|
||||
if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
|
||||
if(loading) {
|
||||
if(request.decision()==Decision.APPROVED && !Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交");
|
||||
if(loading && request.decision()==Decision.APPROVED) {
|
||||
var dispatch=reviews(row).dispatchReview();
|
||||
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
|
||||
}
|
||||
@@ -535,10 +540,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true));
|
||||
if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true));
|
||||
}
|
||||
var absent=new ArrayList<String>();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
|
||||
var absent=new ArrayList<String>();if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex");
|
||||
missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList());
|
||||
pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(),
|
||||
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
|
||||
group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),1,weight,volume,index,List.copyOf(absent),List.copyOf(proof)));
|
||||
}
|
||||
if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源");
|
||||
String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets));
|
||||
@@ -560,17 +565,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
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("逐项真实拣货尚未核平");
|
||||
String appointmentBlock=appointments.departureBlockReason(number);
|
||||
if(appointmentBlock!=null)throw conflict(appointmentBlock);
|
||||
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
|
||||
if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量");
|
||||
var supplied=new HashMap<Long,LoadingMeasurement>();
|
||||
for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复");
|
||||
for(var p:preview.pallets()) {
|
||||
var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex()
|
||||
|| p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对");
|
||||
}
|
||||
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
|
||||
// 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。
|
||||
var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,
|
||||
p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
|
||||
verifyReviewResources(row);var context=context(actor,row);
|
||||
var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList();
|
||||
var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList();
|
||||
fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark());
|
||||
var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at);
|
||||
row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name());
|
||||
@@ -642,7 +645,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
|
||||
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
|
||||
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));}
|
||||
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
|
||||
return hashes.source(facts);
|
||||
}
|
||||
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
|
||||
@@ -669,17 +672,29 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
|
||||
void verifyReviewResources(DispatchAggregate row) {
|
||||
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
|
||||
requireReviewAppointment(row);
|
||||
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
|
||||
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
|
||||
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
|
||||
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
|
||||
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
|
||||
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
|
||||
if(v.mode()==ResourceMode.EXTERNAL || v.evidenceFileIds()!=null && !v.evidenceFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
|
||||
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
|
||||
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
|
||||
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
|
||||
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
|
||||
}
|
||||
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
|
||||
void requireReviewAppointment(DispatchAggregate row) {
|
||||
String reason=reviewAppointmentBlock(row);
|
||||
if(reason!=null)throw conflict(reason);
|
||||
}
|
||||
private String reviewAppointmentBlock(DispatchAggregate row) {
|
||||
String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
|
||||
if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row)))
|
||||
return "预约或运输安排已变化,原审核依据失效,请重新核对审核";
|
||||
return reason;
|
||||
}
|
||||
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
|
||||
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
|
||||
@@ -738,7 +753,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
statistics(repository.summaries(filter)),Instant.now());
|
||||
}
|
||||
|
||||
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
|
||||
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
|
||||
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
|
||||
var confirmedAppointments=this.appointments.reviewEvidence(number);
|
||||
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
|
||||
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
|
||||
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
|
||||
/** 四页签读取同一资源快照,维护资料和本趟车辆照片不回退为主档或备注。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.Detail detail(Actor actor, String number) {
|
||||
var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
|
||||
@@ -748,17 +776,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b));
|
||||
var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(),
|
||||
group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList();
|
||||
var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
|
||||
b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream()
|
||||
.map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList();
|
||||
var appointments=reviewAppointments(row);
|
||||
var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),
|
||||
d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()),
|
||||
d.status(),d.packageCount(),d.palletCount())).toList();
|
||||
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
|
||||
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
|
||||
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
|
||||
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver(),resources.maintenance()),
|
||||
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
|
||||
documents,orderDetails(cargo));
|
||||
documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand()));
|
||||
}
|
||||
|
||||
/** 恢复动作结果前重新核验当前单据范围和原动作权限。 */
|
||||
@@ -882,6 +908,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
}
|
||||
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
|
||||
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
|
||||
String reviewBlock=active && (stage==Stage.BEFORE_LOADING || stage==Stage.LOADING)
|
||||
?reviewAppointmentBlock(row):null;
|
||||
for(var action:Action.values()) {
|
||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
|
||||
boolean state=switch(action) {
|
||||
@@ -899,6 +927,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
};
|
||||
boolean permitted=hasPermission(actor,action);
|
||||
gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件"));
|
||||
if(state && permitted && reviewBlock!=null && Set.of(Action.SUBMIT_REVIEW,Action.DISPATCH_REVIEW,Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE).contains(action)) {
|
||||
boolean rejectionAllowed=action==Action.DISPATCH_REVIEW || action==Action.LOADING_REVIEW;
|
||||
gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock));
|
||||
}
|
||||
if(action==Action.LOADING_COMPLETE && state && permitted) {
|
||||
String reason=appointments.departureBlockReason(row.getDispatchNo());
|
||||
if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason));
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableMap(gates);
|
||||
}
|
||||
@@ -968,12 +1004,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */
|
||||
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) {
|
||||
/** 合并Fleet权威资源、本趟照片和维护资料,不写入车辆或司机主档。 */
|
||||
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos,Maintenance maintenance) {
|
||||
var v=shell.vehicle(); var d=shell.driver();
|
||||
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
|
||||
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)));
|
||||
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)),maintenance);
|
||||
}
|
||||
|
||||
/** 主档选择不接受请求覆盖其车牌和容量。 */
|
||||
|
||||
+6
@@ -17,6 +17,12 @@ public class DispatchAppointmentAllocations {
|
||||
private final AppointmentWorkDispatchPort provider;
|
||||
public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;}
|
||||
|
||||
/** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */
|
||||
public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);}
|
||||
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
|
||||
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
|
||||
|
||||
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
|
||||
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
|
||||
var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList();
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 从日常排车实时投影内部余位,不创建第二套车次、发布单或占位。 */
|
||||
@Service
|
||||
public class DispatchCapacityDemandService {
|
||||
private final DispatchAggregateRepository repository;
|
||||
private final ObjectMapper json;
|
||||
/** 复用排车仓储和正式装载快照解析。 */
|
||||
public DispatchCapacityDemandService(DispatchAggregateRepository repository, ObjectMapper json) {
|
||||
this.repository = repository; this.json = json;
|
||||
}
|
||||
/** 当前候选一次查询;已知满载先过滤,随后分页,保证总数与结果一致。 */
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public PageResult<DispatchCapacityDemands.Demand> page(DispatchCapacityDemands.Query query) {
|
||||
var values = repository.capacityDemands(query).stream().map(this::project).filter(Objects::nonNull).toList();
|
||||
long start = Math.min((long)(query.page()-1)*query.size(), values.size());
|
||||
int end = (int)Math.min(start+query.size(), values.size());
|
||||
return new PageResult<>(values.subList((int)start,end), query.page(), query.size(), values.size(), (values.size()+query.size()-1)/query.size());
|
||||
}
|
||||
/** 实托数不等于标准托位;缺少可靠装载证据时明确未知,不能拿三十托或零值推算。 */
|
||||
private DispatchCapacityDemands.Demand project(DispatchAggregate row) {
|
||||
Integer slots = null, remaining = null; BigDecimal weight = null, volume = null;
|
||||
String reason = "占用标准托位、重量及体积待确认";
|
||||
try {
|
||||
var resources = row.getResourcesJson() == null ? null : json.readValue(row.getResourcesJson(), DispatchContracts.Resources.class);
|
||||
var cap = resources == null || resources.vehicle() == null ? null : resources.vehicle().capacity();
|
||||
if (cap != null) slots = cap.palletSpaces();
|
||||
var load = row.getLoadingConfirmationJson() == null ? null : json.readValue(row.getLoadingConfirmationJson(), DispatchContracts.ConfirmedLoad.class);
|
||||
if (cap == null || slots == null || slots <= 0 || !positive(cap.maxLoadKg()) || !positive(cap.maxVolumeCbm())) reason = "车辆真实容量待确认";
|
||||
if (cap != null && load != null && load.pallets() != null && !load.pallets().isEmpty()
|
||||
&& load.pallets().stream().allMatch(p -> p.warehousePalletId()!=null && p.palletSpaces()>0)
|
||||
&& load.pallets().stream().map(DispatchContracts.LoadingMeasurement::warehousePalletId).distinct().count()==load.pallets().size()) {
|
||||
// 首版均在起运仓上车,后续仅卸货;任一已知维度满载都退出需求池。
|
||||
long occupied = load.pallets().stream().mapToLong(DispatchContracts.LoadingMeasurement::palletSpaces).sum();
|
||||
if (slots != null && slots > 0) { if (occupied >= slots) return null; remaining = Math.toIntExact(slots-occupied); }
|
||||
if (positive(cap.maxLoadKg()) && load.pallets().stream().allMatch(p -> positive(p.weightKg()))) {
|
||||
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::weightKg).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
if (used.compareTo(cap.maxLoadKg())>=0) return null; weight=cap.maxLoadKg().subtract(used);
|
||||
}
|
||||
if (positive(cap.maxVolumeCbm()) && load.pallets().stream().allMatch(p -> positive(p.volumeCbm()))) {
|
||||
var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::volumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add);
|
||||
if (used.compareTo(cap.maxVolumeCbm())>=0) return null; volume=cap.maxVolumeCbm().subtract(used);
|
||||
}
|
||||
if (remaining!=null && weight!=null && volume!=null) reason=null;
|
||||
}
|
||||
} catch (JsonProcessingException invalid) { reason="车辆或装载资料待核对"; }
|
||||
return new DispatchCapacityDemands.Demand(row.getDispatchNo(),row.getOverseasWarehouseName(),row.getRoute(),
|
||||
row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(ZoneOffset.UTC),slots,row.getPalletCount(),remaining,weight,volume,reason==null,reason);
|
||||
}
|
||||
/** 三维容量均须有真实正值。 */
|
||||
private static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; }
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ public class DispatchLoadingDepartureService {
|
||||
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
|
||||
var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get();
|
||||
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
|
||||
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.remark()));
|
||||
var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos());
|
||||
var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded;
|
||||
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(),
|
||||
|
||||
+3
-1
@@ -89,7 +89,9 @@ public class DispatchPdaQueryService {
|
||||
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
|
||||
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
|
||||
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
|
||||
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions),
|
||||
root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),
|
||||
root.getEstimatedArrivalAt()==null?null:root.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC),workflow.reviewAppointments(root)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ public class DispatchPickingService {
|
||||
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
|
||||
if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING
|
||||
|| context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码");
|
||||
workflow.requireReviewAppointment(row);
|
||||
if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法");
|
||||
try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");}
|
||||
var currentItems=repository.cargo(row.getId());
|
||||
|
||||
+12
@@ -382,6 +382,18 @@ public class DispatchService {
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消只锁本车待释放的申请与原实托,不以当前库存、实收或组托完整性作为撤销安排的资格。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void lockCancellationSources(String warehouse,List<DispatchItem> items) {
|
||||
if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("取消排车仍须明确起运仓库");
|
||||
var applications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType()))
|
||||
.map(DispatchItem::getSourceBusinessNo).collect(Collectors.toCollection(TreeSet::new));
|
||||
// 先锁实际需要恢复预占的换标申请,再取得共同实托串行点;计划来源不伪造实托或查询库存。
|
||||
if(!applications.isEmpty())lockRelabelApplications(applications);
|
||||
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
|
||||
if(!pallets.isEmpty())palletSerial.lock(pallets);
|
||||
}
|
||||
|
||||
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
|
||||
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ public class TransportReviewPhotos {
|
||||
}
|
||||
/** 读取已保存资源;空排车还没有照片。 */
|
||||
public Resources resources(DispatchAggregate row) {
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null);
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null,null);
|
||||
try{return json.readValue(row.getResourcesJson(),Resources.class);}
|
||||
catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);}
|
||||
}
|
||||
|
||||
+5
-5
@@ -109,21 +109,21 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
|
||||
history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key);
|
||||
history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history);
|
||||
}
|
||||
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
|
||||
/** 主单展示真实主档分配,并保留本趟维护资料及同一资源的照片。 */
|
||||
private void resources(DispatchAggregate row,FleetTripEntity trip) {
|
||||
var old=readResources(row);
|
||||
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
|
||||
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),
|
||||
old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()),
|
||||
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),
|
||||
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null))));
|
||||
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null),old.maintenance())));
|
||||
}
|
||||
/** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */
|
||||
/** 照片变更纳入主单版本和审核依据,保留原维护资料,失败由外层事务一起回滚。 */
|
||||
public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) {
|
||||
var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver();
|
||||
if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配");
|
||||
var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos),
|
||||
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos));
|
||||
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos),old.maintenance());
|
||||
if(next.equals(old))return;
|
||||
long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1);
|
||||
row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
|
||||
@@ -134,7 +134,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
|
||||
}
|
||||
/** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */
|
||||
private Resources readResources(DispatchAggregate row) {
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null);
|
||||
if(row.getResourcesJson()==null)return new Resources(null,null,null);
|
||||
try{return json.readValue(row.getResourcesJson(),Resources.class);}
|
||||
catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@ public record ContainerArrivalTimeView(
|
||||
LocalDateTime estimatedWarehouseArrivalAt,
|
||||
LocalDateTime actualWarehouseArrivalAt,
|
||||
int version,
|
||||
long planCount
|
||||
long planCount,
|
||||
java.time.LocalDate plannedDate,
|
||||
boolean isDefault
|
||||
) { }
|
||||
|
||||
+26
-9
@@ -12,14 +12,14 @@ public final class TripCapacityPolicy {
|
||||
|
||||
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
|
||||
public Load {
|
||||
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|
||||
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
|
||||
if (palletSpaces < 0 || weightKg != null && weightKg.signum() < 0 || volumeCbm != null && volumeCbm.signum() < 0) {
|
||||
throw new IllegalArgumentException("托位、重量和体积须为非负数");
|
||||
}
|
||||
}
|
||||
public Load plus(Load other) {
|
||||
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
|
||||
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
|
||||
weightKg == null || other.weightKg == null ? null : weightKg.add(other.weightKg),
|
||||
volumeCbm == null || other.volumeCbm == null ? null : volumeCbm.add(other.volumeCbm));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public final class TripCapacityPolicy {
|
||||
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
|
||||
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
|
||||
Objects.requireNonNull(load, "load");
|
||||
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
|
||||
if (load.palletSpaces() == 0 || load.weightKg() != null && load.weightKg().signum() == 0 || load.volumeCbm() != null && load.volumeCbm().signum() == 0) {
|
||||
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
|
||||
}
|
||||
}
|
||||
@@ -37,31 +37,48 @@ public final class TripCapacityPolicy {
|
||||
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
|
||||
|
||||
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
|
||||
return calculate(stopCount, capacity, bookings, false);
|
||||
}
|
||||
|
||||
/** 装车使用已有测量;未知维度保留空值,仅校验已知货量,不把未知当作可售余量。 */
|
||||
public static List<Segment> calculateActual(int stopCount, Load capacity, List<Booking> bookings) {
|
||||
return calculate(stopCount, capacity, bookings, true);
|
||||
}
|
||||
|
||||
private static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings, boolean actual) {
|
||||
Objects.requireNonNull(capacity, "capacity");
|
||||
Objects.requireNonNull(bookings, "bookings");
|
||||
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
|
||||
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
|
||||
if (capacity.weightKg() == null || capacity.volumeCbm() == null || capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
|
||||
throw new IllegalArgumentException("车辆容量必须大于零");
|
||||
}
|
||||
var numbers = new HashSet<String>();
|
||||
for (Booking booking : bookings) {
|
||||
Objects.requireNonNull(booking, "booking");
|
||||
if (!actual && (booking.load().weightKg() == null || booking.load().volumeCbm() == null))
|
||||
throw new IllegalArgumentException("计划承运货物须有重量和体积");
|
||||
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
|
||||
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
|
||||
}
|
||||
List<Segment> segments = new ArrayList<>();
|
||||
for (int index = 0; index < stopCount - 1; index++) {
|
||||
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
BigDecimal knownWeight = BigDecimal.ZERO, knownVolume = BigDecimal.ZERO;
|
||||
for (Booking booking : bookings) {
|
||||
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
|
||||
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) {
|
||||
occupied = occupied.plus(booking.load());
|
||||
if (booking.load().weightKg() != null) knownWeight = knownWeight.add(booking.load().weightKg());
|
||||
if (booking.load().volumeCbm() != null) knownVolume = knownVolume.add(booking.load().volumeCbm());
|
||||
}
|
||||
}
|
||||
if (occupied.palletSpaces() > capacity.palletSpaces()
|
||||
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|
||||
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
|
||||
|| knownWeight.compareTo(capacity.weightKg()) > 0
|
||||
|| knownVolume.compareTo(capacity.volumeCbm()) > 0) {
|
||||
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
|
||||
}
|
||||
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
|
||||
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
|
||||
occupied.weightKg() == null ? null : capacity.weightKg().subtract(occupied.weightKg()),
|
||||
occupied.volumeCbm() == null ? null : capacity.volumeCbm().subtract(occupied.volumeCbm()));
|
||||
segments.add(new Segment(index, index + 1, occupied, remaining));
|
||||
}
|
||||
return List.copyOf(segments);
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ public final class FleetHandoverPolicy {
|
||||
return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey());
|
||||
}
|
||||
private static boolean positive(Load load) {
|
||||
return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
|
||||
return load != null && load.palletSpaces() > 0 && load.weightKg() != null && load.volumeCbm() != null && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
|
||||
}
|
||||
private static boolean blank(String text) { return text == null || text.isBlank(); }
|
||||
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
|
||||
|
||||
+11
-4
@@ -31,7 +31,11 @@ public class FleetBookingReservations {
|
||||
/** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */
|
||||
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
|
||||
Long excludedId, FleetBookingEntity addition) {
|
||||
return segments(trip,own.pallets(),excludedId,addition);
|
||||
return segments(trip,own.pallets(),excludedId,addition,false);
|
||||
}
|
||||
/** 已扫码装车无需重复测量,未知重量体积不计作可接运力。 */
|
||||
public List<TripCapacityPolicy.Segment> actualSegments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> pallets) {
|
||||
return segments(trip,pallets,null,null,true);
|
||||
}
|
||||
/** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */
|
||||
public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) {
|
||||
@@ -40,17 +44,20 @@ public class FleetBookingReservations {
|
||||
throw conflict("请先重新核对自有货容量计划,再确认外接实收");
|
||||
// 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。
|
||||
List<FleetExecutionModels.Measurement> pallets=releasedReceipt?List.of():own.capacityPlan().pallets();
|
||||
segments(trip,pallets,row.getId(),row);
|
||||
segments(trip,pallets,row.getId(),row,false);
|
||||
}
|
||||
/** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */
|
||||
private List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> own,
|
||||
Long excludedId,FleetBookingEntity addition) {
|
||||
Long excludedId,FleetBookingEntity addition,boolean actual) {
|
||||
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
|
||||
for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
|
||||
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
|
||||
for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
|
||||
if(addition!=null) add(loads,stops,addition);
|
||||
try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); }
|
||||
try {
|
||||
var capacity=new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm());
|
||||
return actual?TripCapacityPolicy.calculateActual(stops.size(),capacity,loads):TripCapacityPolicy.calculate(stops.size(),capacity,loads);
|
||||
}
|
||||
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
|
||||
}
|
||||
public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) {
|
||||
|
||||
+7
-7
@@ -88,9 +88,8 @@ public class FleetTripExecutionService {
|
||||
List<FleetExecutionModels.Measurement> measurements,String reason) {
|
||||
var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true);
|
||||
if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输");
|
||||
var ownSegments=capacity(trip,cargo,measurements);
|
||||
var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now());
|
||||
var segments=bookingReservations.segments(trip,plan,null,null);
|
||||
capacity(trip,cargo,measurements,true);
|
||||
var segments=bookingReservations.actualSegments(trip,measurements);
|
||||
// 同时核对原有外接预订占用;不因新主流程绕过已确认容量。
|
||||
var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now());
|
||||
trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator);
|
||||
@@ -103,7 +102,7 @@ public class FleetTripExecutionService {
|
||||
var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对");
|
||||
if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId());
|
||||
if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId());
|
||||
bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null);
|
||||
bookingReservations.actualSegments(trip,load.pallets());
|
||||
appointmentAllocations.requireDeparture(no);
|
||||
}
|
||||
|
||||
@@ -157,7 +156,7 @@ public class FleetTripExecutionService {
|
||||
String fingerprint = hash(write(cargo));
|
||||
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量");
|
||||
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
|
||||
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now());
|
||||
var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets, false), operator, Instant.now());
|
||||
bookingReservations.segments(trip, plan, null, null);
|
||||
trip.setPlannedLoadJson(write(plan));
|
||||
history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator);
|
||||
@@ -234,7 +233,7 @@ public class FleetTripExecutionService {
|
||||
return null;
|
||||
}
|
||||
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
|
||||
List<FleetExecutionModels.Measurement> pallets) {
|
||||
List<FleetExecutionModels.Measurement> pallets, boolean actual) {
|
||||
List<FleetTripModels.Stop> stops;
|
||||
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); }
|
||||
@@ -262,7 +261,8 @@ public class FleetTripExecutionService {
|
||||
if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积");
|
||||
if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null)
|
||||
throw conflict("车辆容量未确认");
|
||||
return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings);
|
||||
var limits = new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm());
|
||||
return actual ? TripCapacityPolicy.calculateActual(stops.size(), limits, bookings) : TripCapacityPolicy.calculate(stops.size(), limits, bookings);
|
||||
} catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); }
|
||||
}
|
||||
private void requireResource(String kind, long id, long tripId) {
|
||||
|
||||
@@ -301,7 +301,7 @@ public class FleetTripService {
|
||||
return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString());
|
||||
}
|
||||
|
||||
/** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */
|
||||
/** 复验主档资格与名称容量;本趟已由排车核验归属的车辆照片保持独立,不被主档重建清空。 */
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle,
|
||||
FleetExecutionModels.DispatchDriver driver) {
|
||||
@@ -315,7 +315,7 @@ public class FleetTripService {
|
||||
var assignment=repository.resource("VEHICLE",actual.id());
|
||||
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用");
|
||||
vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(),
|
||||
actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status();
|
||||
actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),vehicle.evidenceFileIds()==null?List.of():List.copyOf(vehicle.evidenceFileIds()));vehicleStatus=actual.status();
|
||||
} else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案");
|
||||
if(driver!=null && "MASTER".equals(driver.mode())) {
|
||||
if(driver.driverId()==null)throw bad("请选择司机档案");
|
||||
@@ -426,8 +426,8 @@ public class FleetTripService {
|
||||
if (trip.getLoadJson() != null) {
|
||||
var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class);
|
||||
if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces()
|
||||
|| segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|
||||
|| segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
|
||||
|| segment.occupied().weightKg() != null && segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0
|
||||
|| segment.occupied().volumeCbm() != null && segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0))
|
||||
throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排");
|
||||
}
|
||||
if (!Objects.equals(trip.getVehicleId(), vehicle.id()))
|
||||
|
||||
-5
@@ -79,11 +79,6 @@ public interface InboundPlanOrderMapper {
|
||||
String findSourceContainerBookingId(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT estimated_warehouse_at FROM ll_order " +
|
||||
"WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id")
|
||||
List<LocalDateTime> containerEstimatedWarehouseTimes(
|
||||
@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findOrderType(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
+3
-9
@@ -69,6 +69,7 @@ public class InboundPlanService {
|
||||
@Resource private LastlegOrderCommandRecordMapper commandMapper;
|
||||
@Resource private LastlegSourcePalletMapper sourcePalletMapper;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival;
|
||||
|
||||
public List<String> sourcePalletNos(String customerId, long orderId, String orderType) {
|
||||
return "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
@@ -775,20 +776,13 @@ public class InboundPlanService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 计划日期使用与清关和预约日历相同的权威解析,不以约仓时间代替。 */
|
||||
private LocalDate requiredContainerPlannedArrivalDate(String sourceContainerBookingId) {
|
||||
String bookingId = trimToNull(sourceContainerBookingId);
|
||||
if (bookingId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单未绑定来源柜,无法取得预计到仓时间");
|
||||
}
|
||||
List<LocalDateTime> values = orderMapper.containerEstimatedWarehouseTimes(bookingId);
|
||||
if (values == null || values.isEmpty() || values.stream().anyMatch(Objects::isNull)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间缺失,请先补齐柜级时间");
|
||||
}
|
||||
LocalDateTime expected = values.get(0);
|
||||
if (values.stream().anyMatch(value -> !Objects.equals(expected, value))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜预计到仓时间不一致,请先修正柜级时间");
|
||||
}
|
||||
return expected.toLocalDate();
|
||||
return warehouseArrival.requiredDate(bookingId);
|
||||
}
|
||||
|
||||
private void requirePlanContainer(InboundPlan plan, String containerNo) {
|
||||
|
||||
+52
-5
@@ -141,15 +141,61 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致");
|
||||
if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致");
|
||||
}
|
||||
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); }
|
||||
public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,true); }
|
||||
public String departureBlockReason(String dispatchNo) {
|
||||
try { checkDeparture(dispatchNo,false);return null; }
|
||||
try { checkDeparture(dispatchNo,false,true);return null; }
|
||||
catch(org.springframework.web.server.ResponseStatusException error) {
|
||||
if(error.getStatusCode().value()!=409) throw error;
|
||||
return error.getReason();
|
||||
}
|
||||
}
|
||||
private void checkDeparture(String dispatchNo,boolean locked) {
|
||||
/** 审核使用计划/实收分配,不要求尚未发生的拣货;发车继续核对真实已拣来源。 */
|
||||
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival) {
|
||||
try {
|
||||
checkDeparture(dispatchNo,false,false);
|
||||
var windows=reviewEvidence(dispatchNo);
|
||||
if(!windows.isEmpty()) {
|
||||
if(departure==null || arrival==null)return "请先明确计划来车/发车时间和预计送达时间,再提交审核";
|
||||
if(arrival.isBefore(departure))return "预计送达时间不能早于计划发车时间";
|
||||
var first=windows.stream().min(Comparator.comparing(ReceivingWindow::startAt)).orElseThrow();
|
||||
if(!departure.isBefore(first.startAt()))return "计划发车时间须早于本车最早的FBA预约送达时间";
|
||||
if(arrival.isAfter(first.endAt()==null?first.startAt():first.endAt()))return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
|
||||
}
|
||||
return null;
|
||||
} catch(org.springframework.web.server.ResponseStatusException error) {
|
||||
if(error.getStatusCode().value()!=409)throw error;
|
||||
return error.getReason();
|
||||
}
|
||||
}
|
||||
/** 审核依据取当前有效收货确认,不能用旧批次头的计划时间替代。 */
|
||||
public List<ReceivingWindow> reviewEvidence(String dispatchNo) {
|
||||
var result=new ArrayList<ReceivingWindow>();var seen=new HashSet<List<String>>();
|
||||
for(var row:mapper.bindings(dispatchNo)) {
|
||||
if(row.batchKey().startsWith("DISPATCH:") || !seen.add(List.of(row.batchKey(),row.customerId(),row.destinationKey())))continue;
|
||||
var agreement=receiving.currentAgreement(row.batchKey(),row.customerId());
|
||||
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) || agreement.details().appointmentAt()==null)continue;
|
||||
var member=agreement.members().stream().filter(m->row.batchKey().equals(m.batchKey()) && row.customerId().equals(m.customerId())).findFirst().orElse(null);
|
||||
if(member==null)continue;
|
||||
var details=agreement.details();
|
||||
result.add(new ReceivingWindow(row.batchKey(),member.appointmentNo(),row.customerId(),row.destinationKey(),details.confirmationNo(),
|
||||
receivingInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()),
|
||||
details.appointmentEndAt()==null?null:receivingInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()),agreement.agreementKey(),agreement.version()));
|
||||
}
|
||||
return result.stream().sorted(Comparator.comparing(ReceivingWindow::batchKey).thenComparing(ReceivingWindow::customerId).thenComparing(ReceivingWindow::destinationKey)).toList();
|
||||
}
|
||||
/** 保留仓库当地时区及明确偏移,不把美国预约时间按服务器时区解释。 */
|
||||
private java.time.Instant receivingInstant(java.time.LocalDateTime at,String timezone,String offset) {
|
||||
try {
|
||||
if(timezone==null)throw conflict("预约收货时间的时区尚未明确");
|
||||
var zone=java.time.ZoneId.of(timezone);
|
||||
var valid=zone.getRules().getValidOffsets(at);
|
||||
if(valid.isEmpty() || offset==null && valid.size()!=1)throw conflict("预约收货时间的时区偏移尚未明确");
|
||||
var selected=offset==null?valid.get(0):java.time.ZoneOffset.of(offset);
|
||||
if(!valid.contains(selected))throw conflict("预约收货时间的时区偏移无效");
|
||||
return at.toInstant(selected);
|
||||
} catch(java.time.DateTimeException error) {throw conflict("预约收货时间的时区或偏移无效");}
|
||||
}
|
||||
private void checkDeparture(String dispatchNo,boolean locked,boolean picked) {
|
||||
var rows=mapper.bindings(dispatchNo);
|
||||
var physical=mapper.dispatchPhysical(dispatchNo);
|
||||
for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId())))
|
||||
@@ -158,6 +204,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
if(locked) for(var batch:batches) lock(batch);
|
||||
for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) {
|
||||
requireNoBatch(item.customerId(),item.planPalletId());
|
||||
if(!picked && mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")))throw conflict("本车FBA货物尚未关联已确认预约,请客服核对送达时间后提交审核");
|
||||
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车");
|
||||
}
|
||||
for(var batch:batches) {
|
||||
@@ -175,8 +222,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
|
||||
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) {
|
||||
var first=group.get(0);
|
||||
var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow();
|
||||
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()),
|
||||
group.stream().map(a->allocation(a,null)).toList(),true,true);
|
||||
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),item.sourceStage(),item.packages()),
|
||||
group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked);
|
||||
}
|
||||
}
|
||||
private void requireReceiving(String batch,String customer,List<AppointmentWorkDispatchMapper.Bound> rows) {
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPo
|
||||
var members=members(input);if(members.isEmpty()) throw bad("请选择需要重新预约的客户");
|
||||
var before=read(batches(members));var selected=selected(before,members);
|
||||
var tripNos=tripNos(selected);var numbers=dispatchNos(selected);
|
||||
amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers);
|
||||
// 尚未排车的客户仍需核对完整执行版本,但没有原排车资源需要加锁。
|
||||
if(!numbers.isEmpty()) amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers);
|
||||
for(var number:numbers) {
|
||||
dispatches.completion(number,true);
|
||||
dispatches.lockForPlanning(number);
|
||||
|
||||
@@ -210,19 +210,26 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
|
||||
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */
|
||||
/** 仅查询已冻结来源柜的头程交接尾程号,调用方先取得柜级互斥锁。 */
|
||||
@Select("SELECT DISTINCT order_no FROM ll_order WHERE source_container_booking_id=#{bookingId} " +
|
||||
"AND source_region='FIRSTLEG' ORDER BY order_no")
|
||||
List<String> containerOrderNumbers(@Param("bookingId") String bookingId);
|
||||
|
||||
/** 人工时间与计划冻结日期分别返回;默认日期由共用业务解析器读取权威到港后生成。 */
|
||||
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
|
||||
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
|
||||
"AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " +
|
||||
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
|
||||
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
|
||||
"AND MAX(o.arrival_time_version)>0 " +
|
||||
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
|
||||
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
|
||||
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
|
||||
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
|
||||
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
|
||||
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
|
||||
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " +
|
||||
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount," +
|
||||
"(SELECT CASE WHEN COUNT(p.planned_arrival_date)=COUNT(*) AND COUNT(DISTINCT p.planned_arrival_date)=1 " +
|
||||
"THEN MAX(p.planned_arrival_date) END FROM ll_inbound_plan p JOIN ll_order po " +
|
||||
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
|
||||
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS plannedDate,FALSE AS isDefault " +
|
||||
"FROM ll_order o WHERE o.source_container_booking_id IN " +
|
||||
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"GROUP BY o.source_container_booking_id</script>")
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 柜级人工到仓与已有入库计划日期的唯一数据库读取入口。 */
|
||||
@Repository
|
||||
public class ContainerWarehouseArrivalRepository {
|
||||
private final LastlegOrderMapper mapper;
|
||||
/** 复用本库柜级事实查询。 */
|
||||
public ContainerWarehouseArrivalRepository(LastlegOrderMapper mapper) { this.mapper=mapper; }
|
||||
/** 保留同柜版本及计划数量,不把缺失时间转换成零点。 */
|
||||
public List<ContainerArrivalTimeView> find(List<String> ids) { return mapper.findContainerArrivalTimes(ids); }
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 读取来源柜已经分配的业务尾程号,不按客户拆分柜级身份。 */
|
||||
@Repository
|
||||
public class LastlegOrderNumberRepository {
|
||||
private final LastlegOrderMapper mapper;
|
||||
|
||||
/** 复用所属服务订单 Mapper。 */
|
||||
public LastlegOrderNumberRepository(LastlegOrderMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 调用方已持有来源柜门禁行锁,同柜的后续客户事件沿用首个尾程号。 */
|
||||
public String findContainerOrderNo(String bookingId) {
|
||||
List<String> values = mapper.containerOrderNumbers(bookingId);
|
||||
if (values.size() > 1) throw new IllegalStateException("同一来源柜存在多个尾程业务编号");
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient;
|
||||
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
|
||||
import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** 清关列表、预约日历与入库计划共用的到仓日期解析。 */
|
||||
@Service
|
||||
public class ContainerWarehouseArrivalService {
|
||||
private final ContainerWarehouseArrivalRepository repository;
|
||||
private final AppointmentForecastClient forecasts;
|
||||
private final String token;
|
||||
|
||||
/** 到港事实只从 Firstleg 权威来源读取,人工与计划日期只访问本库。 */
|
||||
public ContainerWarehouseArrivalService(ContainerWarehouseArrivalRepository repository,
|
||||
AppointmentForecastClient forecasts,@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.repository=repository; this.forecasts=forecasts; this.token=token;
|
||||
}
|
||||
|
||||
/** 人工时间及计划冻结日期优先,其余按最新到港当地日期加两个自然日。 */
|
||||
public List<ContainerArrivalTimeView> resolve(List<String> ids) {
|
||||
if(ids.isEmpty()) return List.of();
|
||||
var rows=repository.find(ids);
|
||||
var pending=rows.stream().filter(row->row.version()==0 && row.planCount()==0
|
||||
&& row.estimatedWarehouseArrivalAt()==null).map(row->Long.valueOf(row.sourceContainerBookingId())).toList();
|
||||
Map<Long,LocalDate> defaults=new HashMap<>();
|
||||
for(int start=0;start<pending.size();start+=200) {
|
||||
var batch=pending.subList(start,Math.min(start+200,pending.size()));
|
||||
var response=forecasts.batch(token,new AppointmentForecastClient.Request(batch));
|
||||
if(response==null || !response.successful() || response.data()==null || response.data().size()!=batch.size())
|
||||
throw unavailable();
|
||||
var seen=new java.util.HashSet<Long>();
|
||||
for(var fact:response.data()) {
|
||||
if(fact==null || !batch.contains(fact.containerBookingId()) || !seen.add(fact.containerBookingId())) throw unavailable();
|
||||
if(fact.portArrivalAt()!=null) defaults.put(fact.containerBookingId(),fact.portArrivalAt().toLocalDate().plusDays(2));
|
||||
}
|
||||
}
|
||||
return rows.stream().map(row->{
|
||||
boolean automatic=row.version()==0 && row.planCount()==0 && row.estimatedWarehouseArrivalAt()==null;
|
||||
LocalDate date=row.estimatedWarehouseArrivalAt()!=null?row.estimatedWarehouseArrivalAt().toLocalDate()
|
||||
:automatic?defaults.get(Long.valueOf(row.sourceContainerBookingId())):row.plannedDate();
|
||||
return new ContainerArrivalTimeView(row.sourceContainerBookingId(),row.estimatedWarehouseArrivalAt(),
|
||||
row.actualWarehouseArrivalAt(),row.version(),row.planCount(),date,automatic && date!=null);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/** 生成计划沿用同一解析结果,缺失或不一致时阻断,不改写来源事实。 */
|
||||
public LocalDate requiredDate(String id) {
|
||||
var rows=resolve(List.of(id));
|
||||
if(rows.size()!=1 || rows.get(0).version()<0 || rows.get(0).plannedDate()==null)
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"来源柜预计到仓日期缺失或不一致,请先核对柜级时间");
|
||||
return rows.get(0).plannedDate();
|
||||
}
|
||||
|
||||
/** 来源失败必须明确提示,不能用本地旧快照伪造默认日期。 */
|
||||
private ResponseStatusException unavailable() {
|
||||
return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到港日期暂不可读取,请稍后重试");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ import java.util.Set;
|
||||
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||
@Component
|
||||
public class LastlegDocumentNumberGenerator {
|
||||
private static final Set<String> PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
|
||||
private static final Set<String> PREFIXES = Set.of("WC", "IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR");
|
||||
private final DocumentSequenceRepository sequences;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
+2
-18
@@ -1,14 +1,9 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
|
||||
/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */
|
||||
/** 海外单据类型映射;业务单号统一使用本服务日流水。 */
|
||||
@Component
|
||||
public class LastlegOrderNoGenerator {
|
||||
private final LastlegDocumentNumberGenerator documents;
|
||||
@@ -21,6 +16,7 @@ public class LastlegOrderNoGenerator {
|
||||
/** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */
|
||||
public String next(String businessType) {
|
||||
String prefix = switch (businessType) {
|
||||
case "LASTLEG" -> "WC";
|
||||
case "ONE_CLICK_DROPSHIP" -> "DFCK";
|
||||
case "OUTBOUND" -> "CK";
|
||||
case "RELABEL_OUTBOUND" -> "HBCK";
|
||||
@@ -45,16 +41,4 @@ public class LastlegOrderNoGenerator {
|
||||
return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */
|
||||
public String forContainerNo(String containerNo) {
|
||||
if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required");
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8));
|
||||
long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong();
|
||||
return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-"
|
||||
+ String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
|
||||
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
|
||||
import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation;
|
||||
import com.baiyue.lastleg.client.OrderPreDepartureClient;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
@@ -42,6 +43,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -83,16 +85,28 @@ public class LastlegOrderService {
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
|
||||
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
|
||||
@Resource private ContainerWarehouseArrivalService warehouseArrival;
|
||||
@Resource private WarehouseResourceGateway warehouseResourceGateway;
|
||||
@Resource private ContainerShippingGateMapper containerShippingGates;
|
||||
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
|
||||
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
|
||||
@Resource private OrderPreDepartureClient orderPreDepartureClient;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
|
||||
return page(customerId, query, null);
|
||||
}
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
|
||||
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
|
||||
Long customer = parseLong(customerId);
|
||||
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
|
||||
query.page().intValue(), query.size().intValue());
|
||||
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
|
||||
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
|
||||
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
|
||||
}
|
||||
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
|
||||
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
|
||||
@@ -112,6 +126,53 @@ public class LastlegOrderService {
|
||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
/** 全部页签合并订单服务的未开船头程数据与本服务已有尾程单,尾程记录优先覆盖同一来源订单。 */
|
||||
private PageResult<LastlegOrderSummary> aggregateAllPage(String customerId, LastlegOrderRequests.Query query) {
|
||||
var source = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId), query.orderNo(), query.inboundCode(), 1, 2000);
|
||||
if (source == null || source.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
|
||||
List<LastlegOrder> existing = orderMapper.selectList(filter(customerId, query, null)
|
||||
.orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
|
||||
Map<String, String> existingNodes = containerNodes(existing);
|
||||
Map<Long, LastlegOrderMapper.OrderWeightRow> existingWeights = orderWeights(existing);
|
||||
Map<String, LastlegOrderSummary> merged = new LinkedHashMap<>();
|
||||
source.data().records().forEach(row -> {
|
||||
var summary = preDepartureSummary(row);
|
||||
merged.put(orderKey(summary.customerId(), summary.sourceOrderNo()), summary);
|
||||
});
|
||||
for (LastlegOrder order : existing) {
|
||||
String key = orderKey(order.getCustomerId(), StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo());
|
||||
merged.put(key, summary(order, List.of(), StringUtils.hasText(order.getSourceContainerBookingId())
|
||||
? existingNodes.get(order.getSourceContainerBookingId()) : null, "UNSCHEDULED",
|
||||
existingWeights.get(order.getId()), List.of(), null, null));
|
||||
}
|
||||
List<LastlegOrderSummary> rows = merged.values().stream().sorted(java.util.Comparator.comparing(LastlegOrderSummary::createdAt,
|
||||
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))).toList();
|
||||
int from = Math.min((query.page().intValue() - 1) * query.size().intValue(), rows.size());
|
||||
int to = Math.min(from + query.size().intValue(), rows.size());
|
||||
long total = rows.size();
|
||||
return new PageResult<>(rows.subList(from, to), query.page(), query.size(), total,
|
||||
total == 0 ? 0 : (total + query.size() - 1) / query.size());
|
||||
}
|
||||
|
||||
private static String orderKey(String customerId, String orderNo) {
|
||||
return String.valueOf(customerId) + ":" + String.valueOf(orderNo);
|
||||
}
|
||||
|
||||
/** 将订单服务权威头程摘要投影为只读尾程列表行;尚未生成尾程单的字段保持为空。 */
|
||||
private LastlegOrderSummary preDepartureSummary(OrderPreDepartureClient.Row row) {
|
||||
LastlegOrder order = new LastlegOrder();
|
||||
// 头程聚合行尚未生成尾程单,尾程订单号必须保持为空;来源头程订单号单独保留。
|
||||
order.setId(row.orderId()); order.setOrderNo(null); order.setOrderType("FIRST_LEG");
|
||||
order.setInboundCode(row.inboundCode()); order.setLxCode(row.lxCode()); order.setSourceOrderNo(row.orderNo());
|
||||
order.setCustomerId(row.customerId() == null ? null : String.valueOf(row.customerId())); order.setCustomerName(row.customerName());
|
||||
order.setSourceWarehouseName(row.receivingWarehouseName()); order.setOverseasWarehouseName(row.receivingWarehouseName());
|
||||
order.setFbaWarehouseCode(row.destinationCode());
|
||||
order.setContainerNo(row.containerNo()); order.setPackageCount(row.packageCount()); order.setVolumeCbm(row.volumeCbm());
|
||||
order.setEstimatedArrivalAt(row.expectedArrivalAt()); order.setCurrentStatus(row.mainStatus());
|
||||
order.setCreatedAt(row.createdAt()); order.setUpdatedAt(row.updatedAt()); order.setVersion(0);
|
||||
return summary(order, List.of(), null, "UNSCHEDULED", null, List.of(), null, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<ContainerPlanProgressSummary> containerProgressPage(
|
||||
LastlegOrderRequests.ContainerProgressQuery query) {
|
||||
@@ -142,6 +203,7 @@ public class LastlegOrderService {
|
||||
orderMapper.findContainerProgressItems(bookingId));
|
||||
}
|
||||
|
||||
/** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */
|
||||
@Transactional(readOnly = true)
|
||||
public List<ContainerArrivalTimeView> containerArrivalTimes(
|
||||
LastlegOrderRequests.ContainerArrivalTimeQuery request) {
|
||||
@@ -150,7 +212,7 @@ public class LastlegOrderService {
|
||||
if (bookingIds.size() != request.sourceContainerBookingIds().size()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复");
|
||||
}
|
||||
return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds));
|
||||
return warehouseArrival.resolve(List.copyOf(bookingIds));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -194,8 +256,9 @@ public class LastlegOrderService {
|
||||
return requireContainerArrivalTime(bookingId);
|
||||
}
|
||||
|
||||
/** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */
|
||||
private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) {
|
||||
List<ContainerArrivalTimeView> values = orderMapper.findContainerArrivalTimes(List.of(bookingId));
|
||||
List<ContainerArrivalTimeView> values = warehouseArrival.resolve(List.of(bookingId));
|
||||
if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
|
||||
if (values.get(0).version() < 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致");
|
||||
|
||||
+5
-1
@@ -44,12 +44,14 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private InboundPlanCartonMapper cartonMapper;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private com.baiyue.lastleg.repository.LastlegOrderNumberRepository orderNumbers;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private ContainerShippingGateMapper shippingGates;
|
||||
@Resource private LastlegSourcePalletMapper sourcePallets;
|
||||
@Resource private ContainerLoadingEvidenceMapper loadingEvidence;
|
||||
|
||||
/** 校验交接身份并在柜级锁内为各客户的冻结入仓单建立同柜尾程记录。 */
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public DeliveryAck consume(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
@@ -94,7 +96,9 @@ public class LoadingCompletedConsumer {
|
||||
// Seeding the shipping version from the loading-plan version makes a valid first node
|
||||
// (aggregateVersion=0) look stale whenever the loading plan has a higher version.
|
||||
JsonNode rawOrders = envelope.payload().path("orders");
|
||||
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
||||
// 来源柜行锁覆盖跨客户交接事件,首票取号,后续票沿用同柜已落库编号。
|
||||
String orderNo = orderNumbers.findContainerOrderNo(sourceContainerBookingId);
|
||||
if (orderNo == null) orderNo = orderNoGenerator.next("LASTLEG");
|
||||
for (int index = 0; index < payload.orders().size(); index++) {
|
||||
var source = payload.orders().get(index);
|
||||
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 本次仅新增排车内部余位发布选项;执行需单独授权,不回填、清理或重置业务数据。
|
||||
ALTER TABLE ll_dispatch_order
|
||||
ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位' AFTER task_type;
|
||||
@@ -1895,6 +1895,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
|
||||
@@ -8,8 +8,8 @@ paths:
|
||||
description: >-
|
||||
仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
|
||||
返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。
|
||||
同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。
|
||||
未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。
|
||||
同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0时才有值;仅日期的冻结计划通过plannedDate返回。
|
||||
plannedDate/isDefault由与清关提柜和入库计划共用的服务端解析器返回,默认按权威到港当地日期加2个自然日;此只读接口不写入库计划、不补造时刻。
|
||||
actualAt只读PDA首次收货时间,不替换计划日期。
|
||||
具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。
|
||||
保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。
|
||||
@@ -41,10 +41,13 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
sourceContainerBookingId: {type: string}
|
||||
cabinetNo: {type: string, nullable: true, description: 来源交接冻结的柜次号,作为日历主标识}
|
||||
containerNo: {type: string, nullable: true}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
warehouseCode: {type: string, nullable: true}
|
||||
plannedAt: {type: string, format: date-time, nullable: true}
|
||||
plannedDate: {type: string, format: date, nullable: true, description: 人工日期、已生成计划冻结日期或权威默认日期}
|
||||
isDefault: {type: boolean, description: 仅当前未维护未建计划且有默认日期时为true}
|
||||
actualAt: {type: string, format: date-time, nullable: true}
|
||||
version: {type: integer, minimum: -1}
|
||||
planCount: {type: integer, minimum: 0}
|
||||
|
||||
@@ -602,8 +602,8 @@ components:
|
||||
properties:
|
||||
warehousePalletId: {type: string}
|
||||
palletSpaces: {type: integer, minimum: 1}
|
||||
weightKg: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
|
||||
volumeCbm: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
|
||||
weightKg: {type: number, nullable: true, description: 已确认装车的未知实测保持空值;计划容量输入仍须填写正数, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
|
||||
volumeCbm: {type: number, nullable: true, description: 已确认装车的未知实测保持空值;计划容量输入仍须填写正数, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999}
|
||||
deliveryIndex: {type: integer, minimum: 1, description: 卸货站索引,起运仓固定为0}
|
||||
PlanningEnvelope:
|
||||
allOf:
|
||||
@@ -641,8 +641,8 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
palletSpaces: {type: integer}
|
||||
weightKg: {type: number}
|
||||
volumeCbm: {type: number}
|
||||
weightKg: {type: number, nullable: true, description: 实测不完整时未知,不表示零重量或可售余量}
|
||||
volumeCbm: {type: number, nullable: true, description: 实测不完整时未知,不表示零体积或可售余量}
|
||||
ConfirmLoad:
|
||||
type: object
|
||||
required: [recordVersion, manifestHash, pallets, reason]
|
||||
|
||||
@@ -4,6 +4,35 @@ info:
|
||||
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/last-leg/dispatches/capacity-demands:
|
||||
get:
|
||||
summary: 读取有效派送排车的内部散板余位需求
|
||||
description: 仅平台内部capacity-offer:list或manage可读;返回运输摘要,不返回客户、司机私人资料。实时排除未勾选、取货、本土卡车、已发车、取消及已知满载排车;缺测余位明确未知。
|
||||
operationId: listDispatchCapacityDemands
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
|
||||
- { name: origin, in: query, schema: { type: string } }
|
||||
- { name: destination, in: query, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: 统一业务响应,data为包含records、page、size、total和pages的分页结果。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: { type: array, items: { $ref: '#/components/schemas/DispatchCapacityDemand' } }
|
||||
page: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
|
||||
size: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
|
||||
total: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
|
||||
pages: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] }
|
||||
|
||||
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons:
|
||||
post:
|
||||
summary: 测量完成前增加预报箱数并补充箱唛
|
||||
@@ -3553,7 +3582,9 @@ components:
|
||||
required: [sourceContainerBookingId, version, planCount]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string }
|
||||
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 }
|
||||
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护的整柜时间;默认日期及只有日期的冻结计划不补造时间 }
|
||||
plannedDate: { type: string, format: date, nullable: true, description: 与日历和入库计划共用的服务端日期,人工或冻结日期优先,否则按权威到港当地日期加两个自然日 }
|
||||
isDefault: { type: boolean, description: 未人工维护未建计划时使用权威到港推算日期 }
|
||||
actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
|
||||
version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 }
|
||||
planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 }
|
||||
@@ -3961,6 +3992,10 @@ components:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.Update
|
||||
properties:
|
||||
publishLooseboardDemand:
|
||||
type: boolean
|
||||
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
|
||||
|
||||
recordVersion:
|
||||
oneOf:
|
||||
- type: string
|
||||
@@ -3989,7 +4024,13 @@ components:
|
||||
remark:
|
||||
type: string
|
||||
nullable: true
|
||||
maintenance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchMaintenance'
|
||||
nullable: true
|
||||
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
|
||||
required:
|
||||
- publishLooseboardDemand
|
||||
- recordVersion
|
||||
- idempotencyKey
|
||||
DeliveryStatusChange:
|
||||
@@ -4524,7 +4565,7 @@ components:
|
||||
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
|
||||
ManualInboundPlanRequest:
|
||||
type: object
|
||||
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。
|
||||
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由共用服务端解析器返回 plannedDate:人工时间或已有计划冻结日期优先,其余按 Firstleg 权威到港当地日期加两个自然日;同柜所有计划保持一致,不接受人工提交,不补造默认时间。
|
||||
required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
@@ -5356,6 +5397,43 @@ components:
|
||||
- name
|
||||
- phone
|
||||
- evidence
|
||||
DispatchMaintenance:
|
||||
type: object
|
||||
x-java-record: DispatchContracts.Maintenance
|
||||
description: 保存于排车resourcesJson.maintenance的唯一正式资料结构,不新增费用金额或审核门禁。
|
||||
properties:
|
||||
plannedDeliveryDate:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
deliveryMethod:
|
||||
type: string
|
||||
enum: [FBA自提, 客户自提, 卡派打托, 卡派地板, 快递, 外配]
|
||||
nullable: true
|
||||
pickupMethod:
|
||||
type: string
|
||||
enum: [一提一卸, 一提多卸]
|
||||
nullable: true
|
||||
loadType:
|
||||
type: string
|
||||
enum: [托盘, 地板, 混装]
|
||||
nullable: true
|
||||
fleetEmail:
|
||||
type: string
|
||||
maxLength: 254
|
||||
nullable: true
|
||||
vehicleType:
|
||||
type: string
|
||||
enum: [厢式货车, 平板车, 集装箱卡车]
|
||||
nullable: true
|
||||
appointmentAccount:
|
||||
type: string
|
||||
maxLength: 128
|
||||
nullable: true
|
||||
appointmentNo:
|
||||
type: string
|
||||
maxLength: 128
|
||||
nullable: true
|
||||
DispatchResources:
|
||||
type: object
|
||||
x-java-record: DispatchContracts.Resources
|
||||
@@ -5368,6 +5446,11 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchDriverSnapshot'
|
||||
nullable: true
|
||||
maintenance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchMaintenance'
|
||||
nullable: true
|
||||
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
|
||||
required:
|
||||
- vehicle
|
||||
- driver
|
||||
@@ -5783,10 +5866,31 @@ components:
|
||||
required:
|
||||
- sourceType
|
||||
- sourceVersion
|
||||
DispatchCapacityDemand:
|
||||
type: object
|
||||
x-java-record: DispatchCapacityDemands.Demand
|
||||
properties:
|
||||
dispatchNo: { type: string }
|
||||
warehouseName: { type: string, nullable: true }
|
||||
route: { type: string, nullable: true }
|
||||
plannedDepartureAt: { type: string, format: date-time, nullable: true }
|
||||
vehiclePalletSpaces: { type: integer, nullable: true }
|
||||
scheduledPalletCount: { type: integer, nullable: true }
|
||||
remainingPalletSpaces: { type: integer, nullable: true }
|
||||
remainingWeightKg: { type: number, nullable: true }
|
||||
remainingVolumeCbm: { type: number, nullable: true }
|
||||
capacityKnown: { type: boolean }
|
||||
capacityReason: { type: string, nullable: true }
|
||||
required: [dispatchNo, capacityKnown]
|
||||
DispatchCreate:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.Create
|
||||
properties:
|
||||
publishLooseboardDemand:
|
||||
type: boolean
|
||||
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
|
||||
default: true
|
||||
|
||||
idempotencyKey:
|
||||
type: string
|
||||
warehouseId:
|
||||
@@ -6023,18 +6127,13 @@ components:
|
||||
manifestHash:
|
||||
type: string
|
||||
nullable: true
|
||||
measurements:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchLoadingMeasurement'
|
||||
nullable: true
|
||||
remark:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- recordVersion
|
||||
- idempotencyKey
|
||||
description: 必须先由真实PDA拣货核平;这里只确认实载,不代填拣货箱数,不等于实际发车。
|
||||
description: 必须先由真实PDA拣货核平;按已扫实托确认装车;重量体积由服务端已有测量带出,缺失不阻塞,不接受现场补填测量或箱数。
|
||||
DispatchDepart:
|
||||
type: object
|
||||
x-java-record: DispatchCommands.Depart
|
||||
@@ -6306,6 +6405,11 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchDriverSnapshot'
|
||||
nullable: true
|
||||
maintenance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchMaintenance'
|
||||
nullable: true
|
||||
description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。
|
||||
required:
|
||||
- vehicle
|
||||
- driver
|
||||
@@ -6480,6 +6584,10 @@ components:
|
||||
type: object
|
||||
x-java-record: DispatchViews.Detail
|
||||
properties:
|
||||
publishLooseboardDemand:
|
||||
type: boolean
|
||||
description: 是否向内部散板需求池发布本车余位;新建默认勾选。
|
||||
|
||||
dispatchNo:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -6535,6 +6643,7 @@ components:
|
||||
$ref: '#/components/schemas/DispatchOrderDetail'
|
||||
nullable: true
|
||||
required:
|
||||
- publishLooseboardDemand
|
||||
- dispatchNo
|
||||
- status
|
||||
- stage
|
||||
@@ -6589,6 +6698,21 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DispatchCounts'
|
||||
nullable: true
|
||||
plannedDepartureAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当前排车计划发车时间,审核必须核对。
|
||||
estimatedArrivalAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当前排车预计送达时间。
|
||||
fbaAppointments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DispatchFbaAppointment'
|
||||
description: 按本车各客户批次读取当前有效收货确认;缺失确认显示PENDING。
|
||||
pickingReconciled:
|
||||
type: boolean
|
||||
items:
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class AppointmentImpactPersistenceTest {
|
||||
return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null);
|
||||
}
|
||||
static void observe(AnnotationConfigApplicationContext c,long revision,int day) {
|
||||
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED"))));
|
||||
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED",null))));
|
||||
}
|
||||
static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) {
|
||||
return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null);
|
||||
|
||||
+21
-3
@@ -11,11 +11,21 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class WarehouseCalendarMapperTest {
|
||||
public class WarehouseCalendarMapperTest {
|
||||
/** H2 测试替身按真实 JSON 内容读取柜次,不把样例值写死到 SQL。 */
|
||||
public static String jsonExtract(String json,String path) throws Exception {
|
||||
return json==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).path(path.substring(2)).toString();
|
||||
}
|
||||
/** 测试替身复用 JSON 解码语义。 */
|
||||
public static String jsonUnquote(String value) throws Exception {
|
||||
return value==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).asText();
|
||||
}
|
||||
/** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */
|
||||
@Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception {
|
||||
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection=ds.getConnection(); var sql=connection.createStatement()) {
|
||||
sql.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'");
|
||||
sql.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'");
|
||||
sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," +
|
||||
"overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," +
|
||||
"estimated_warehouse_at TIMESTAMP,arrival_time_version INT)");
|
||||
@@ -26,6 +36,12 @@ class WarehouseCalendarMapperTest {
|
||||
"(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," +
|
||||
"(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)");
|
||||
sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')");
|
||||
sql.execute("ALTER TABLE ll_order ADD source_event_id VARCHAR");
|
||||
sql.execute("ALTER TABLE ll_inbound_plan ADD planned_arrival_date DATE");
|
||||
sql.execute("UPDATE ll_inbound_plan SET planned_arrival_date='2026-11-12'");
|
||||
sql.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR,payload_json VARCHAR)");
|
||||
sql.execute("INSERT INTO ll_loading_event_inbox VALUES('source-1','{\"cabinetNo\":\"D0001\"}')");
|
||||
sql.execute("UPDATE ll_order SET source_event_id='source-1' WHERE source_container_booking_id='1'");
|
||||
}
|
||||
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||
config.addMapper(WarehouseCalendarMapper.class);
|
||||
@@ -34,9 +50,11 @@ class WarehouseCalendarMapperTest {
|
||||
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
|
||||
assertEquals(3,rows.size());
|
||||
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
|
||||
assertEquals("D0001",rows.get(0).cabinetNo());
|
||||
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
|
||||
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
|
||||
// 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。
|
||||
assertEquals(1,rows.get(2).planCount()); assertNull(rows.get(2).plannedAt());
|
||||
assertEquals(java.time.LocalDate.of(2026,11,12),rows.get(2).plannedDate());
|
||||
// 清关提柜与日历读取相同冻结事实,默认日期由共用服务端解析器返回。
|
||||
var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
|
||||
assertEquals(3,customs.size());
|
||||
for(var calendar:rows) {
|
||||
|
||||
+8
-3
@@ -10,10 +10,15 @@ import static org.mockito.Mockito.*;
|
||||
class WarehouseCalendarServiceTest {
|
||||
/** 读取资格不代替写权限,已有入库计划仍冻结时间。 */
|
||||
@Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() {
|
||||
var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository);
|
||||
var repository=mock(WarehouseCalendarRepository.class);
|
||||
var arrivals=mock(com.baiyue.lastleg.service.ContainerWarehouseArrivalService.class);
|
||||
var service=new WarehouseCalendarService(repository,arrivals);
|
||||
var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1"));
|
||||
var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false);
|
||||
var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false);
|
||||
var open=new WarehouseCalendarModels.Entry("1","D0001","CONT","Warehouse","W",null,null,0,0,false,null,false);
|
||||
var frozen=new WarehouseCalendarModels.Entry("2","D0002","CONT2","Warehouse","W",null,null,1,1,false,null,false);
|
||||
when(arrivals.resolve(List.of("1","2"))).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.dto.ContainerArrivalTimeView("1",null,null,0,0,LocalDate.of(2026,11,2),true),
|
||||
new com.baiyue.lastleg.dto.ContainerArrivalTimeView("2",null,null,1,1,LocalDate.of(2026,11,3),false)));
|
||||
when(repository.calendar(query)).thenReturn(List.of(open,frozen));
|
||||
assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit());
|
||||
assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit));
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "remark"),
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand"),
|
||||
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class DispatchSchemaContractTest {
|
||||
"ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class);
|
||||
for(var entry:tables.entrySet()) {
|
||||
String body=table(sql,entry.getKey());var columns=new TreeSet<String>();
|
||||
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
|
||||
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|TINYINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
|
||||
while(matcher.find())columns.add(matcher.group(1));
|
||||
var fields=new TreeSet<String>();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT));
|
||||
assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id");
|
||||
|
||||
+15
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.when;
|
||||
class DispatchQueryControllerTest {
|
||||
@Mock DispatchAggregateService service;
|
||||
@Mock DispatchBolPrintService printService;
|
||||
@Mock com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
|
||||
private DispatchQueryController controller;
|
||||
|
||||
@BeforeEach
|
||||
@@ -36,6 +37,7 @@ class DispatchQueryControllerTest {
|
||||
controller = new DispatchQueryController();
|
||||
ReflectionTestUtils.setField(controller, "aggregateService", service);
|
||||
ReflectionTestUtils.setField(controller, "printService", printService);
|
||||
ReflectionTestUtils.setField(controller, "capacityDemands", capacityDemands);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -72,6 +74,19 @@ class DispatchQueryControllerTest {
|
||||
assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
|
||||
}
|
||||
|
||||
/** 需求池使用独立列表权限且只允许平台身份,不向客户开放摘要来源。 */
|
||||
@Test void capacityDemandPoolUsesItsOwnPermissionAndRejectsMerchantIdentity() throws Exception {
|
||||
var query=new com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query(1,20,null,null);
|
||||
var permission=DispatchQueryController.class.getMethod("capacityDemands",AuthenticatedUser.class,query.getClass()).getAnnotation(PreAuthorize.class);
|
||||
assertThat(permission.value()).isEqualTo("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')");
|
||||
when(capacityDemands.page(query)).thenReturn(new PageResult<>(List.of(),1,20,0,0));
|
||||
assertThat(controller.capacityDemands(platform(),query).data().records()).isEmpty();
|
||||
var merchant=new AuthenticatedUser(7L,9L,9L,8L,"customer","MERCHANT","WEB",Set.of());
|
||||
assertThatThrownBy(()->controller.capacityDemands(merchant,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(403));
|
||||
assertThatThrownBy(()->controller.capacityDemands(null,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(401));
|
||||
verify(capacityDemands).page(query);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
|
||||
}
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ public final class DispatchPublicJsonContract {
|
||||
rejectsMissing(requests, request, "{}");
|
||||
rejectsMissing(requests, request, "{\"recordVersion\":null}");
|
||||
rejectsMissing(requests, request, "{\"recordVersion\":-1}");
|
||||
Object zero = requests.readValue("{\"recordVersion\":0}", request);
|
||||
Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request);
|
||||
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
|
||||
}
|
||||
rejectsMissing(requests, DispatchCommands.Update.class,
|
||||
|
||||
+67
-14
@@ -93,7 +93,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
var d=resources.driver();var proof=d.evidence();
|
||||
var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),
|
||||
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)));
|
||||
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)), null);
|
||||
e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number);
|
||||
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
|
||||
var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L);
|
||||
@@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
|
||||
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())));
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), null);
|
||||
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
|
||||
}
|
||||
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
|
||||
@@ -201,10 +201,55 @@ public class DispatchAggregatePersistenceTest {
|
||||
verifyNoInteractions(facts,sources,serial);
|
||||
}
|
||||
|
||||
/** 有货取消只释放原安排;未实收计划和已有实托都不重验当前货物资格,也不扣减或恢复库存。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans={true,false})
|
||||
void cancellationReleasesOriginalClaimsWithoutRequiringCurrentCargoReadiness(boolean planned) throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",10);var b=f.source(2,"101",502,"ONT8",10);
|
||||
if(planned)for(var row:f.candidates.values())row.warehousePalletId(null).sourceStage("PLAN");
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("cargo-cancel",List.of(a,b))));
|
||||
var root=f.repository.find(created.dispatchNo());
|
||||
var originalCargo=f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
|
||||
var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
|
||||
var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial);
|
||||
when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("当前货物不满足装车条件"));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules);
|
||||
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-cargo-original","仅撤销运输安排");
|
||||
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);
|
||||
assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(originalCargo);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='cancel-cargo-original'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip WHERE id=?",String.class,root.getTripId())).isEqualTo("CANCELLED");
|
||||
verifyNoInteractions(facts,sources);
|
||||
if(planned)verifyNoInteractions(serial);else {verify(serial).lock(List.of(501L,502L));verifyNoMoreInteractions(serial);}
|
||||
}
|
||||
|
||||
/** 撤销安排不校验装车就绪,但真实扫码和旧版本仍阻断且不释放原占用。 */
|
||||
@Test void cancellationStillRejectsStaleVersionAndRealPickWithZeroHeaderCounts() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",10);
|
||||
var created=f.tx.execute(t->f.service.create(f.actor,f.create("guarded-cancel",List.of(source))));
|
||||
var stale=new DispatchCommands.ReasonCommand(created.recordVersion()+1,"stale-cancel","旧版本不能释放占用");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),stale))).hasMessageContaining("版本已变化");
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item",Long.class);
|
||||
f.db.update("INSERT INTO ll_dispatch_pda_pick(customer_id,dispatch_item_id,packages)VALUES('101',?,1)",item);
|
||||
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"picked-cancel","不能隐去真实扫码");
|
||||
assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("真实执行");
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
|
||||
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
|
||||
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
|
||||
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null);
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
|
||||
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
|
||||
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
@@ -244,7 +289,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
}
|
||||
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
|
||||
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
|
||||
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划");
|
||||
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null);
|
||||
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
|
||||
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
|
||||
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
|
||||
@@ -283,10 +328,8 @@ public class DispatchAggregatePersistenceTest {
|
||||
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
|
||||
assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100");
|
||||
assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual);
|
||||
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces");
|
||||
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
|
||||
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
|
||||
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
|
||||
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm");
|
||||
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),"实际核对");
|
||||
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
|
||||
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
|
||||
@@ -298,6 +341,10 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
|
||||
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
var facts=((DispatchResults.Loaded)loaded.result()).confirmedLoad();
|
||||
assertThat(facts.pallets()).hasSize(2);
|
||||
assertThat(facts.pallets().get(0).weightKg()).isEqualByComparingTo("100");
|
||||
assertThat(facts.pallets().get(1).weightKg()).isNull();assertThat(facts.pallets().get(1).volumeCbm()).isNull();
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
|
||||
@@ -316,7 +363,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2");
|
||||
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
|
||||
assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse();
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
|
||||
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
}
|
||||
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
|
||||
@@ -452,8 +499,14 @@ public class DispatchAggregatePersistenceTest {
|
||||
f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item);
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo()));
|
||||
assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1);
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL");
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z"));
|
||||
// 批次头写SUCCESS不代表存在当前有效收货确认,不再把旧头时间作为审批依据。
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).status()).isEqualTo("PENDING");
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isNull();
|
||||
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1)));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
|
||||
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0);
|
||||
assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z"));
|
||||
var c=f.source(3,"101",503,"ONT8",2);
|
||||
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
|
||||
var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b))));
|
||||
@@ -602,7 +655,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
}
|
||||
|
||||
/** 空计划和资源是合法待完善事实,不补假日期或主档。 */
|
||||
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); }
|
||||
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of(), true); }
|
||||
|
||||
/** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */
|
||||
public DispatchCommands.Create configured(String key,List<DispatchCommands.CargoSelection> sources) {
|
||||
@@ -611,7 +664,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
|
||||
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
|
||||
departure,null,null,sources,List.of());
|
||||
departure,null,null,sources,List.of(), true);
|
||||
}
|
||||
|
||||
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
|
||||
@@ -630,6 +683,6 @@ public class DispatchAggregatePersistenceTest {
|
||||
}
|
||||
|
||||
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
|
||||
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
|
||||
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Boolean.class||type==boolean.class?"BOOLEAN":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,6 +40,6 @@ class DispatchAppointmentAllocationsTest {
|
||||
when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整");
|
||||
}
|
||||
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
|
||||
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations);
|
||||
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations, true);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user