Compare commits

...
4 Commits
Author SHA1 Message Date
liuyihe f86fab838a 一件代发拣货 2026-09-01 10:35:54 +08:00
liuyihe 2f1bda8369 一件代发拣货 2026-09-01 09:57:10 +08:00
liuyihe a213be67c5 尾程暂扣回传 2026-08-31 15:22:32 +08:00
liuyihe a4189a0074 提交 2026-08-31 14:39:40 +08:00
65 changed files with 971 additions and 165 deletions
+1
View File
@@ -17,6 +17,7 @@
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
@@ -13,6 +13,7 @@ public final class EventTypes {
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -0,0 +1,17 @@
package com.baiyue.common.event;
import java.time.Instant;
/** 海外尾程按来源入仓单回传的暂扣状态事实。 */
public record OverseasInboundOrderDetainedChangedV1(
String sourceInboundId,
String inboundCode,
String lastlegOrderId,
String lastlegOrderNo,
boolean detained,
String reason,
String operatorId,
String operatorName,
Instant changedAt
) {
}
-1
View File
@@ -7,7 +7,6 @@ spring:
lastleg:
dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
future-outbound-api.enabled: ${LASTLEG_FUTURE_OUTBOUND_API_ENABLED:true}
baiyue:
clients:
domestic-warehouse:
+12 -30
View File
@@ -4,54 +4,36 @@
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>baiyue-nexus-public</id>
<name>柏悦 Nexus Maven 聚合仓库</name>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<mirrorOf>external:*</mirrorOf>
<id>aliyun-public</id>
<name>阿里云 Maven 公共镜像</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<servers>
<server>
<id>baiyue-nexus-public</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-releases</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-snapshots</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
</servers>
<profiles>
<profile>
<id>柏悦 Nexus</id>
<id>国内公共镜像</id>
<repositories>
<repository>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>柏悦 Nexus</activeProfile>
<activeProfile>国内公共镜像</activeProfile>
</activeProfiles>
</settings>
@@ -2502,6 +2502,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
@@ -2791,6 +2792,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -2828,6 +2830,7 @@ BEGIN
END$$
DELIMITER ;
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL unified_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
@@ -13504,6 +13507,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_at', '
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_by', '创建人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_by_name', '创建人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'current_hold_reason', '当前扣货理由快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version', '海外暂扣事件最新聚合版本,用于阻止乱序覆盖');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customer_id', '客户逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customer_name', '客户名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customs_inspection', '是否商检');
@@ -16298,12 +16302,35 @@ DROP PROCEDURE IF EXISTS add_container_empty_report_fields;
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
payload_json JSON NOT NULL COMMENT '原始事件负载', status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED',
received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)',
PRIMARY KEY (event_id), KEY idx_container_event_aggregate (aggregate_id,aggregate_version)
) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox';
) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox';
DROP PROCEDURE IF EXISTS add_inbound_overseas_hold_version;
DELIMITER $$
CREATE PROCEDURE add_inbound_overseas_hold_version()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_inbound_order'
AND column_name='last_overseas_hold_version'
) THEN
ALTER TABLE fl_inbound_order
ADD COLUMN last_overseas_hold_version BIGINT UNSIGNED NULL
COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖'
AFTER current_hold_reason;
END IF;
ALTER TABLE fl_container_event_inbox
MODIFY COLUMN aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
MODIFY COLUMN aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本',
COMMENT='海外到国内业务事件消费Inbox';
END$$
DELIMITER ;
CALL add_inbound_overseas_hold_version();
DROP PROCEDURE IF EXISTS add_inbound_overseas_hold_version;
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
ALTER TABLE fl_inbound_outbox
@@ -1055,6 +1055,22 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'
), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl;
EXECUTE ll_inbound_plan_version_ddl;
DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'
), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl;
EXECUTE ll_order_merge_batch_ddl;
DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
@@ -30,6 +30,7 @@ public class EventBridgeMetrics {
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
+3 -1
View File
@@ -14,7 +14,7 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛。`box_code`、二维码内容和现有打印版式不携带 SKU,不能因该绑定改变。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
@@ -33,6 +33,7 @@
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
@@ -52,6 +53,7 @@
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -63,7 +63,12 @@ public interface OrderCargoClient {
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
}
}
}
@@ -4,8 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.common.event.EventTypes;
import com.baiyue.firstleg.service.OverseasContainerEventConsumer;
import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
@@ -20,14 +21,17 @@ import java.security.MessageDigest;
@RequiredArgsConstructor
public class InternalOverseasContainerEventController {
private final OverseasContainerEventConsumer consumer;
private final OverseasInboundOrderDetainedConsumer detainedConsumer;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping("/internal/firstleg/v1/container-events")
public ApiResponse<ContainerBookingEntity> receive(
public ApiResponse<Object> receive(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
authenticate(token);
return ApiResponse.success(consumer.consume(event));
Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
? detainedConsumer.consume(event) : consumer.consume(event);
return ApiResponse.success(result);
}
private void authenticate(String token) {
@@ -22,6 +22,7 @@ public class InboundCartonLabelEntity {
private Long inboundDetailId;
private Integer cargoItemNo;
private String cargoName;
private String skuCode;
private Integer detailPackageCount;
private String inboundCode;
private String boxCode;
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_inbound_hold_record")
public class InboundHoldRecordEntity {
@TableId(type = IdType.INPUT) private Long id;
private Long tenantId;
private Long inboundId;
private String holdAction;
private String reason;
private Long evidenceFileId;
private Long operatorId;
private String operatorName;
private LocalDateTime operatedAt;
}
@@ -53,6 +53,7 @@ public class InboundOrderEntity extends BaseEntity {
private String destinationName;
private Boolean holdFlag;
private String currentHoldReason;
private Long lastOverseasHoldVersion;
private Integer totalPackageCount;
private BigDecimal actualGrossWeightKg;
private BigDecimal chargeableWeightKg;
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundHoldRecordMapper extends BaseMapper<InboundHoldRecordEntity> {
}
@@ -5,10 +5,25 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Select;
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
@Mapper
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Select("SELECT * FROM fl_inbound_order WHERE id=#{inboundId} AND deleted=0 FOR UPDATE")
InboundOrderEntity selectActiveByIdForUpdate(@Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_order
SET hold_flag=#{detained}, current_hold_reason=#{reason},
last_overseas_hold_version=#{sourceVersion}, updated_by=#{operatorId}, version=version+1
WHERE id=#{inboundId} AND tenant_id=#{tenantId} AND deleted=0
AND (last_overseas_hold_version IS NULL OR last_overseas_hold_version < #{sourceVersion})
""")
int applyOverseasHold(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("detained") boolean detained, @Param("reason") String reason,
@Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
@@ -62,6 +62,16 @@ public class InboundCartonLabelRepository {
.set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint));
}
public void initializeLegacySku(long tenantId, long inboundId, long inboundDetailId, String skuCode) {
if (skuCode == null || skuCode.isBlank()) return;
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
.eq(InboundCartonLabelEntity::getInboundDetailId, inboundDetailId)
.isNull(InboundCartonLabelEntity::getSkuCode)
.set(InboundCartonLabelEntity::getSkuCode, skuCode.trim()));
}
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -50,6 +50,14 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛");
}
existing.forEach(label -> cargoes.stream()
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
label.getInboundDetailId(), cargo.skuNo())));
String fingerprint = fingerprint(cargoes);
if (!existing.isEmpty()) {
String existingFingerprint = consistent.get(0).getCargoFingerprint();
@@ -202,6 +210,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setInboundDetailId(cargo.cargoId());
label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName());
label.setSkuCode(cargo.skuNo());
label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
@@ -256,7 +265,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private String fingerprint(List<OrderCargoClient.CargoLabelBinding> cargoes) {
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Locale;
import java.util.Objects;
/** 将海外暂扣事实按来源入仓单绑定回写到国内头程入仓单。 */
@Service
@RequiredArgsConstructor
public class OverseasInboundOrderDetainedConsumer {
private final InboundOrderMapper inboundOrders;
private final InboundHoldRecordMapper holdRecords;
private final ContainerEventInboxMapper inbox;
private final ObjectMapper objectMapper;
@Transactional
public InboundOrderEntity consume(EventEnvelope event) {
validateEnvelope(event);
OverseasInboundOrderDetainedChangedV1 payload = payload(event);
long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效");
long tenantId = parsePositive(event.tenantId(), "客户 ID 无效");
if (!payload.sourceInboundId().equals(event.aggregateId())) {
throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致");
}
if (payload.changedAt() == null || payload.inboundCode() == null || payload.inboundCode().isBlank()
|| payload.reason() == null || payload.reason().isBlank()) {
throw new IllegalArgumentException("暂扣事件缺少入仓码、原因或变更时间");
}
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
if (existing != null) {
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
}
return requiredInbound(inboundId, tenantId, payload.inboundCode());
}
ContainerEventInboxEntity inboxRow = inbox(event);
try {
inbox.insert(inboxRow);
} catch (DuplicateKeyException duplicate) {
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
return requiredInbound(inboundId, tenantId, payload.inboundCode());
}
InboundOrderEntity inbound = requiredInbound(inboundId, tenantId, payload.inboundCode());
if (inbound.getLastOverseasHoldVersion() == null
|| event.aggregateVersion() > inbound.getLastOverseasHoldVersion()) {
int updated = inboundOrders.applyOverseasHold(tenantId, inboundId, payload.detained(),
payload.detained() ? payload.reason() : null, event.aggregateVersion(),
parseOperatorId(payload.operatorId()));
if (updated == 1) insertHistory(event, payload, tenantId, inboundId);
}
inboxRow.setStatus("PROCESSED");
inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(inboxRow);
return requiredInbound(inboundId, tenantId, payload.inboundCode());
}
private void validateEnvelope(EventEnvelope event) {
event.requireValid();
if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|| event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS
|| event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) {
throw new IllegalArgumentException("不支持的海外入仓单暂扣事件");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
}
}
private OverseasInboundOrderDetainedChangedV1 payload(EventEnvelope event) {
try { return objectMapper.treeToValue(event.payload(), OverseasInboundOrderDetainedChangedV1.class); }
catch (Exception exception) { throw new IllegalArgumentException("海外入仓单暂扣事件负载无效", exception); }
}
private InboundOrderEntity requiredInbound(long inboundId, long tenantId, String inboundCode) {
InboundOrderEntity value = inboundOrders.selectActiveByIdForUpdate(inboundId);
if (value == null) throw new IllegalArgumentException("来源头程入仓单不存在");
if (!Objects.equals(value.getTenantId(), tenantId) || !Objects.equals(value.getInboundCode(), inboundCode)) {
throw new IllegalArgumentException("事件与来源头程入仓单归属不一致");
}
return value;
}
private void insertHistory(EventEnvelope event, OverseasInboundOrderDetainedChangedV1 payload,
long tenantId, long inboundId) {
InboundHoldRecordEntity row = new InboundHoldRecordEntity();
row.setId(IdWorker.getId()); row.setTenantId(tenantId); row.setInboundId(inboundId);
row.setHoldAction(payload.detained() ? "HOLD" : "RELEASE"); row.setReason(payload.reason());
row.setOperatorId(parseOperatorId(payload.operatorId())); row.setOperatorName(payload.operatorName());
row.setOperatedAt(LocalDateTime.ofInstant(payload.changedAt(), ZoneOffset.UTC));
holdRecords.insert(row);
}
private ContainerEventInboxEntity inbox(EventEnvelope event) {
ContainerEventInboxEntity row = new ContainerEventInboxEntity();
row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion());
row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion());
row.setPayloadHash(event.payloadHash().toLowerCase(Locale.ROOT)); row.setPayloadJson(event.payload().toString());
row.setStatus("RECEIVED"); row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC));
return row;
}
private long parsePositive(String value, String message) {
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
catch (RuntimeException ignored) { }
throw new IllegalArgumentException(message);
}
private long parseOperatorId(String value) {
try { return value == null ? 0L : Long.parseLong(value); }
catch (NumberFormatException ignored) { return 0L; }
}
}
@@ -15,6 +15,7 @@ DELIMITER ;
CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL phase2_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
DROP PROCEDURE phase2_carton_label_add_column;
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
@@ -196,6 +197,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -102,8 +102,8 @@ CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本',
aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本',
aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本',
payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
payload_json JSON NOT NULL COMMENT '原始事件负载',
status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED',
@@ -111,7 +111,7 @@ CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)',
PRIMARY KEY (event_id),
KEY idx_container_event_aggregate (aggregate_id, aggregate_version)
) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox';
) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox';
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
DELIMITER $$
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OverseasInboundOrderDetainedConsumerTest {
@Mock InboundOrderMapper inboundOrders;
@Mock InboundHoldRecordMapper holdRecords;
@Mock ContainerEventInboxMapper inbox;
ObjectMapper objectMapper;
OverseasInboundOrderDetainedConsumer consumer;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
consumer = new OverseasInboundOrderDetainedConsumer(inboundOrders, holdRecords, inbox, objectMapper);
}
@Test
void appliesHoldBySourceInboundIdAndWritesHistory() {
InboundOrderEntity inbound = inbound(null);
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound);
when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1);
consumer.consume(event(7, true, "等待查验"));
verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L);
ArgumentCaptor<InboundHoldRecordEntity> history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(holdRecords).insert(history.capture());
assertThat(history.getValue().getInboundId()).isEqualTo(1001L);
assertThat(history.getValue().getHoldAction()).isEqualTo("HOLD");
verify(inbox).updateById(argThat(row -> "PROCESSED".equals(row.getStatus())));
}
@Test
void staleReleaseCannotOverwriteNewerHoldState() {
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L));
consumer.consume(event(7, false, "解除"));
verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong());
verifyNoInteractions(holdRecords);
verify(inbox).updateById(argThat(row -> "PROCESSED".equals(row.getStatus())));
}
private InboundOrderEntity inbound(Long sourceVersion) {
InboundOrderEntity value = new InboundOrderEntity();
value.setId(1001L); value.setTenantId(88L); value.setInboundCode("IN-1001");
value.setLastOverseasHoldVersion(sourceVersion);
return value;
}
private EventEnvelope event(long version, boolean detained, String reason) {
var payload = new OverseasInboundOrderDetainedChangedV1("1001", "IN-1001", "9001",
"LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z"));
var node = objectMapper.valueToTree(payload);
return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version),
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(),
RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L,
"00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
}
}
+8 -4
View File
@@ -27,14 +27,19 @@
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
@@ -46,9 +51,8 @@
## 四期开发范围路由
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 尾程服务覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购、本土卡车、本土散板、排车、出库、派送、客户拜访及异常退回等完整业务域。
- 排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 与其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。
## 数据库表索引与注释约束
@@ -32,7 +32,6 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -102,7 +101,8 @@ public class LastlegOrderController {
@RequestParam(required = false) String customerId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SetDetained request) {
orderService.setDetained(platformScope(user, customerId), id, request, operatorName(user));
orderService.setDetained(platformScope(user, customerId), id, request,
String.valueOf(user.userId()), operatorName(user));
return ApiResponse.success(null);
}
@@ -119,19 +119,29 @@ public class LastlegOrderController {
return ApiResponse.success(null);
}
@PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge")
public ApiResponse<Void> setPalletMergeBatch(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) {
orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user));
return ApiResponse.success(null);
}
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
public ResponseEntity<StreamingResponseBody> export(
public ResponseEntity<byte[]> export(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(platformScope(user, customerId), query);
StreamingResponseBody stream = output -> { output.write(body); output.flush(); };
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
.body(stream);
.body(body);
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@@ -7,7 +7,6 @@ import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses;
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -25,7 +24,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
public class CustomerVisitController {
@Resource private CustomerVisitService service;
@@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -19,7 +18,6 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
@Tag(name = "Outbound and delivery")
public class DeliveryTransferController {
@@ -7,14 +7,12 @@ import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name="lastleg.future-outbound-api.enabled", havingValue="true")
@RequestMapping("/api/pda/v1/last-leg/outbound-tasks")
public class OutboundPdaController {
@Resource private DeliveryTransferService service;
@@ -13,7 +13,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -27,7 +26,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@Tag(name = "Tail-leg vehicle dispatch")
public class DispatchController {
@@ -7,14 +7,12 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
public class DispatchPdaController {
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
@@ -7,7 +7,8 @@ public final class DispatchPdaResponses {
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
List<Item> items) {}
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
@@ -154,6 +154,14 @@ public class DispatchPdaWorkflowService {
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
requireVehicle(order);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task != null && "COMPLETED".equals(task.getStatus())) {
List<DispatchItem> completedItems = items.findByDispatchOrder(customerId, order.getId());
long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToLong(Integer::longValue).sum();
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
return new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), order.getStatus(), planned,
picked, Math.max(0, planned - picked), picked < planned, true);
}
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
throw conflict("Dispatch PDA task is not available for completion");
}
@@ -225,7 +233,8 @@ public class DispatchPdaWorkflowService {
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
@@ -268,6 +277,12 @@ public class DispatchPdaWorkflowService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static long n(Long value) { return value == null ? 0 : value; }
private static long n(Integer value) { return value == null ? 0 : value; }
static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) {
String original = trim(originalFbaWarehouseCode);
String destination = trim(destinationWarehouseCode);
return original != null && destination != null && !original.equalsIgnoreCase(destination)
? original + " -> " + destination : null;
}
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -18,8 +18,8 @@ public final class LastlegOrderRequests {
}
public record Query(
@Min(1) long page,
@Min(1) @Max(200) long size,
@Min(1) Long page,
@Min(1) @Max(200) Long size,
String orderNo,
String orderType,
String inboundCode,
@@ -35,8 +35,8 @@ public final class LastlegOrderRequests {
LocalDateTime createdTo
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
if (page == null || page == 0) page = 1L;
if (size == null || size == 0) size = 20L;
}
}
@@ -79,4 +79,10 @@ public final class LastlegOrderRequests {
@NotNull Boolean palletMergeRequired
) {
}
public record SetPalletMergeBatch(
@NotEmpty @Size(max = 200) List<Long> orderIds,
@NotNull Boolean palletMergeRequired
) {
}
}
@@ -42,7 +42,8 @@ public record LastlegOrderSummary(
String relabelStatus,
String palletMergeFlag,
String palletMergeBatchId,
List<String> palletMergeOrderNos,
@Schema(description = "同一合托组内其他来源入仓单的入仓码")
List<String> palletMergeInboundNos,
String currentStatus,
String subOrderStatus,
String privateRemark,
@@ -52,6 +52,7 @@ public class LastlegOrder {
private String relabelOrderNo;
private String relabelStatus;
private String palletMergeFlag;
private String palletMergeBatchId;
private String currentStatus;
private String subOrderStatus;
private String privateRemark;
@@ -8,7 +8,6 @@ import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -17,7 +16,6 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg")
public class ExceptionOrderController {
@Resource private ExceptionOrderService service;
@@ -26,7 +26,8 @@ public record InboundPlanListResponse(
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
String dispatchStatus,
LocalDateTime createdAt
LocalDateTime createdAt,
Integer version
) {
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
Long orderId, String inboundCode, String orderType,
@@ -39,6 +40,6 @@ public record InboundPlanListResponse(
this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId,
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
expectedLoadingAt, dispatchStatus, createdAt);
expectedLoadingAt, dispatchStatus, createdAt, 0);
}
}
@@ -18,15 +18,24 @@ public record InboundPlanResponse(
int pickedPackageCount,
List<Line> lines,
List<Pallet> pallets,
List<Allocation> allocations
List<Allocation> allocations,
int version
) {
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines, pallets, allocations, 0);
}
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
String containerNo, String status, int lineCount, int palletCount,
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
List<Allocation> allocations, int version) {
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations);
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
}
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Data;
import java.math.BigDecimal;
@@ -24,4 +25,6 @@ public class InboundPlan {
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -23,12 +23,9 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," +
"SUM(CASE WHEN d.id IS NOT NULL " +
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " +
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
"AS destinationMismatchCount " +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
"0 AS destinationMismatchCount " +
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
@@ -45,8 +45,9 @@ public interface InboundPlanQueryMapper {
"l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" +
"CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id" +
" <if test='customerId != null and customerId != \"\"'>AND di.customer_id=#{customerId}</if>)" +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanListResponse> rows(@Param("customerId") String customerId,
@Param("q") InboundPlanRequests.Query query,
@@ -15,7 +15,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */
@Component
@Slf4j
public class InboundUnloadCompletedOutboxRelay {
@@ -44,7 +44,7 @@ public class InboundUnloadCompletedOutboxRelay {
} catch (Exception exception) {
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
exception.getMessage());
log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
}
}
}
@@ -53,7 +53,7 @@ public class InboundUnloadCompletedOutboxRelay {
com.baiyue.common.core.api.ApiResponse<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
if (!response.successful()) {
throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code()
throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId());
}
if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据");
@@ -227,9 +227,34 @@ public class InboundPlanService {
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited");
int currentVersion = plan.getVersion() == null ? 0 : plan.getVersion();
if (request.version() != currentVersion)
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan version is stale; reload before editing");
plan.setOverseasWarehouseId(request.overseasWarehouseId());
plan.setUpdatedAt(LocalDateTime.now());
planMapper.updateById(plan);
// Keep the denormalized destination snapshot used by list queries in sync with the plan.
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId())
.eq(InboundPlanLine::getCustomerId, plan.getCustomerId()));
for (InboundPlanLine line : lines) {
line.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) line.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim());
lineMapper.updateById(line);
}
// Pallets carry the same destination snapshot and are consumed by PDA/resource assignment.
// Update them in this transaction so a plan edit cannot leave lines and pallets pointing at
// different warehouses.
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId())
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()));
for (InboundPlanPallet pallet : pallets) {
pallet.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId()));
if (trimToNull(request.warehouseCode()) != null) pallet.setDestinationWarehouseCode(request.warehouseCode().trim());
if (trimToNull(request.warehouseName()) != null) pallet.setDestinationWarehouseName(request.warehouseName().trim());
palletMapper.updateById(pallet);
}
return detail(customerId, plan);
}
@@ -342,7 +367,7 @@ public class InboundPlanService {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
}
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
if (order.destinationCount() != 1 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode()
+ " must have one destination warehouse consistent with all cargo details");
@@ -388,7 +413,8 @@ public class InboundPlanService {
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList());
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
plan.getVersion() == null ? 0 : plan.getVersion());
}
private BigDecimal volume(SourceOrderSnapshot order) {
@@ -85,7 +85,8 @@ public class InventoryQueryController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.BalanceQuery query) {
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
String scope = platformScope(user, customerId);
return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv");
}
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@@ -95,11 +96,11 @@ public class InventoryQueryController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@Valid InventoryQueryRequests.LedgerQuery query) {
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
String scope = platformScope(user, customerId);
return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv");
}
private static ResponseEntity<StreamingResponseBody> csv(byte[] body, String filename) {
StreamingResponseBody stream = output -> { output.write(body); output.flush(); };
private static ResponseEntity<StreamingResponseBody> csv(StreamingResponseBody stream, String filename) {
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
@@ -17,13 +17,14 @@ import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import java.util.Set;
@Service
public class InventoryQueryService {
private static final int EXPORT_LIMIT = 10_000;
private static final Set<String> BALANCE_STATUSES = Set.of(
"AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT");
private static final Set<String> POSITION_STATUSES = Set.of(
@@ -79,34 +80,74 @@ public class InventoryQueryService {
}
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) {
InventoryBalanceView v = balanceView(value);
output.write((String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
}
public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output)
throws IOException {
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
List<String> rows = new ArrayList<>();
rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," +
"destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," +
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages");
mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add(
"holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryBalanceView> batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList();
if (batch.isEmpty()) break;
for (InventoryBalanceView v : batch) output.write((
String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()),
csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()),
csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()),
csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()),
csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()),
csv(v.procurementPackages()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
validateDateRange(query);
output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) {
InventoryLedgerView v = ledgerView(value);
output.write((String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
}
return output.toByteArray();
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
}
public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output)
throws IOException {
validateDateRange(query);
List<String> rows = new ArrayList<>();
rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," +
"inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," +
"beforePackages,deltaPackages,afterPackages,operatorId,remark");
mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add(
"beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long offset = 0;
while (true) {
List<InventoryLedgerView> batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList();
if (batch.isEmpty()) break;
for (InventoryLedgerView v : batch) output.write((
String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()),
csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()),
csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()),
csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()),
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark()))));
return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8);
csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
output.flush();
if (batch.size() < batchSize) break;
offset += batchSize;
}
}
private InventoryBalanceView balanceView(InventoryBalance value) {
@@ -11,10 +11,18 @@ import java.util.List;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
record PalletMergeRelationRow(Long orderId, String relatedOrderNo, String mergeGroupNo) {}
record PalletMergeRelationRow(Long orderId, String relatedInboundNo, String mergeGroupNo) {}
@Select("<script>SELECT relation.order_id AS orderId, related.order_no AS relatedOrderNo, " +
@Select("<script>SELECT relation.order_id AS orderId," +
"COALESCE(NULLIF(related.inbound_code,''),NULLIF(related.inbound_order_no,''),related.source_order_no) AS relatedInboundNo, " +
"relation.merge_group_no AS mergeGroupNo FROM (" +
"SELECT marked.customer_id,marked.id AS order_id,related.id AS related_order_id," +
"marked.pallet_merge_batch_id AS merge_group_no FROM ll_order marked " +
"JOIN ll_order related ON related.customer_id=marked.customer_id " +
"AND related.pallet_merge_batch_id=marked.pallet_merge_batch_id AND related.id&lt;&gt;marked.id " +
"WHERE marked.pallet_merge_batch_id IS NOT NULL AND marked.id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"UNION ALL " +
"SELECT customer_id,source_order_id AS order_id,target_order_id AS related_order_id,merge_group_no " +
"FROM ll_inbound_pallet_merge WHERE status='CONFIRMED' AND source_order_id IN " +
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
@@ -59,9 +67,17 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag},updated_at=#{updatedAt} " +
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
"updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id}")
int updatePalletMergeFlag(@Param("customerId") String customerId, @Param("id") long id,
@Param("flag") String flag,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag},pallet_merge_batch_id=#{batchId},updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{id}")
int updatePalletMergeBatch(@Param("customerId") String customerId, @Param("id") long id,
@Param("flag") String flag, @Param("batchId") String batchId,
@Param("updatedAt") java.time.LocalDateTime updatedAt);
}
@@ -13,11 +13,14 @@ import java.util.List;
@Repository
public class UnloadCompletedOutboxRepository {
private static final List<String> OVERSEAS_TO_CN_TYPES = List.of(
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED);
@Resource private LastlegEventOutboxMapper mapper;
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
@@ -30,7 +33,7 @@ public class UnloadCompletedOutboxRepository {
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
@@ -44,7 +47,7 @@ public class UnloadCompletedOutboxRepository {
public LastlegEventOutbox claimed(long id, String worker) {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.eq(LastlegEventOutbox::getLockedBy, worker));
}
@@ -72,7 +75,7 @@ public class UnloadCompletedOutboxRepository {
private LambdaUpdateWrapper<LastlegEventOutbox> claimedBase(long id, String worker) {
return new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES)
.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.eq(LastlegEventOutbox::getLockedBy, worker);
}
@@ -0,0 +1,73 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** 在尾程业务事务内冻结按来源入仓单回传国内的暂扣状态事件。 */
@Service
public class InboundOrderDetainedOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private ObjectMapper objectMapper;
public void publish(LastlegOrder order, boolean detained, String reason, String operatorId,
String operatorName, Instant changedAt, long changeVersion) {
requireText(order.getSourceInboundId(), "sourceInboundId");
requireText(order.getInboundCode(), "inboundCode");
requireText(order.getCustomerId(), "customerId");
var payload = new OverseasInboundOrderDetainedChangedV1(order.getSourceInboundId(),
order.getInboundCode(), String.valueOf(order.getId()), order.getOrderNo(), detained,
reason, operatorId, operatorName, changedAt);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String eventKey = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED + ':'
+ order.getSourceInboundId() + ':' + changeVersion;
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt,
RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER",
order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(),
eventKey, order.getSourceEventId(), payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox();
event.setId(IdWorker.getId());
event.setCustomerId(order.getCustomerId());
event.setAggregateType(envelope.aggregateType());
event.setAggregateId(envelope.aggregateId());
event.setEventType(envelope.eventType());
event.setEventVersion(envelope.eventVersion());
event.setEventKey(eventKey);
event.setPayloadJson(json(envelope));
event.setStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(LocalDateTime.ofInstant(changedAt, ZoneOffset.UTC));
mapper.insert(event);
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalStateException(field + " is required");
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize inbound detained event", exception);
}
}
}
@@ -29,7 +29,11 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -51,6 +55,7 @@ public class LastlegOrderService {
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private InventoryCommandService inventory;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
@@ -120,6 +125,12 @@ public class LastlegOrderService {
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorName) {
setDetained(customerId, orderId, request, null, operatorName);
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorId, String operatorName) {
LastlegOrder current = findOwned(customerId, orderId);
LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId);
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
@@ -146,6 +157,10 @@ public class LastlegOrderService {
operation.setResult(request.remark().trim());
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
if (StringUtils.hasText(order.getSourceInboundId())) {
detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName,
now.toInstant(ZoneOffset.UTC), operation.getId());
}
}
@Transactional
@@ -181,6 +196,46 @@ public class LastlegOrderService {
operationLogMapper.insert(operation);
}
@Transactional
public void setPalletMergeBatch(String customerId, LastlegOrderRequests.SetPalletMergeBatch request,
String operatorName) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
if (orders.stream().map(LastlegOrder::getCustomerId).distinct().count() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must belong to one customer");
}
if (orders.stream().map(order -> StringUtils.hasText(order.getTransferWarehouseCode())
? order.getTransferWarehouseCode() : order.getFbaWarehouseCode()).filter(Objects::nonNull).distinct().count() > 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Pallet merge orders must have one destination warehouse");
}
if (orders.stream().anyMatch(order -> "INBOUND_COMPLETED".equals(order.getCurrentStatus())
|| PALLET_MERGED.equals(order.getPalletMergeFlag()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed pallet merge orders cannot be changed");
}
String batchId = request.palletMergeRequired()
? "PMG-" + java.util.UUID.randomUUID().toString().replace("-", "") : null;
String flag = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null;
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
if (orderMapper.updatePalletMergeBatch(order.getCustomerId(), order.getId(), flag, batchId, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge group was not updated");
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId()); operation.setCustomerId(order.getCustomerId());
operation.setOrderId(order.getId());
operation.setOperationNode(request.palletMergeRequired() ? "批量标记合托" : "取消合托标记");
operation.setOperatorName(operatorName);
operation.setResult(request.palletMergeRequired() ? "PDA合托分组 " + batchId : "已取消PDA合托分组");
operation.setCreatedAt(now); operationLogMapper.insert(operation);
}
}
@Transactional
public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
@@ -236,12 +291,25 @@ public class LastlegOrderService {
}
public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) {
List<LastlegOrder> orders = orderMapper.selectList(filter(customerId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)");
for (LastlegOrder order : orders) {
lines.add(String.join(",", List.of(
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
writeExportCsv(customerId, query, output);
return output.toByteArray();
} catch (IOException e) {
throw new IllegalStateException("Unable to export tail-leg orders", e);
}
}
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
throws IOException {
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
final int batchSize = 1000;
long page = 1;
while (true) {
List<LastlegOrder> orders = orderMapper.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, batchSize),
filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)).getRecords();
if (orders.isEmpty()) break;
for (LastlegOrder order : orders) {
String line = String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
csv(order.getInboundCode()),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
@@ -251,9 +319,13 @@ public class LastlegOrderService {
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))));
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n";
output.write(line.getBytes(StandardCharsets.UTF_8));
}
output.flush();
if (orders.size() < batchSize) break;
page++;
}
return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<LastlegOrder> filter(String customerId, LastlegOrderRequests.Query query) {
@@ -320,7 +392,7 @@ public class LastlegOrderService {
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedOrderNo).filter(Objects::nonNull).distinct().toList(),
related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(),
o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(),
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
}
@@ -39,8 +39,6 @@ lastleg:
enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api:
enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
future-outbound-api:
enabled: ${LASTLEG_FUTURE_OUTBOUND_API_ENABLED:true}
baiyue:
http:
@@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_order_no VARCHAR(128) COMMENT '换标单号',
relabel_status VARCHAR(32) COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) COMMENT '合托标记',
pallet_merge_batch_id VARCHAR(64) NULL COMMENT '平台预标记合托分组号',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成',
sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总',
private_remark VARCHAR(1000) COMMENT '备注(仅自己)',
@@ -97,6 +98,9 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_list(customer_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -432,6 +436,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '入库计划乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_plan_no (plan_no),
UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id),
@@ -18,6 +18,8 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -30,7 +32,7 @@ class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void platformEndpointRejectsMerchantIdentity() {
@@ -50,6 +52,32 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void exportBuildsCsvBeforeReturningTheResponse() {
byte[] csv = "csv".getBytes(java.nio.charset.StandardCharsets.UTF_8);
when(service.exportCsv("200", query)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), "200", query);
assertArrayEquals(csv, response.getBody());
verify(service).exportCsv("200", query);
}
@Test
void platformOperatorCanExportAllCustomersWithoutFilters() {
byte[] csv = "all customers".getBytes(java.nio.charset.StandardCharsets.UTF_8);
LastlegOrderRequests.Query emptyQuery = new LastlegOrderRequests.Query(
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
when(service.exportCsv(null, emptyQuery)).thenReturn(csv);
var response = controller.export(user(0L, "PLATFORM"), null, emptyQuery);
assertEquals(Long.valueOf(1), emptyQuery.page());
assertEquals(Long.valueOf(20), emptyQuery.size());
assertArrayEquals(csv, response.getBody());
verify(service).exportCsv(isNull(), eq(emptyQuery));
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class);
@@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null);
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantListUsesOnlyAuthenticatedTenant() {
@@ -1,10 +1,13 @@
package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.delivery.controller.OutboundPdaController;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
import com.baiyue.lastleg.dispatch.controller.SmartDispatchController;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.customervisit.controller.CustomerVisitController;
import com.baiyue.lastleg.exceptionorder.controller.ExceptionOrderController;
import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController;
import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController;
import org.junit.jupiter.api.Test;
@@ -39,14 +42,12 @@ class Phase4BrowserApiBoundaryTest {
}
@Test
void excludedOutboundControllersAreDisabledUnlessExplicitlyEnabledLater() {
void outboundControllersAreNotPropertyGated() {
for (Class<?> type : new Class<?>[] {
DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class
DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class,
OutboundPdaController.class, CustomerVisitController.class, ExceptionOrderController.class
}) {
ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class);
assertTrue(condition != null, type.getSimpleName());
assertTrue(Arrays.asList(condition.name()).contains("lastleg.future-outbound-api.enabled"));
assertFalse(condition.matchIfMissing());
assertFalse(type.isAnnotationPresent(ConditionalOnProperty.class), type.getSimpleName());
}
}
@@ -65,11 +66,10 @@ class Phase4BrowserApiBoundaryTest {
}
@Test
void dispatchQueryIsEnabledByDefaultWithoutEnablingOutboundCommands() throws Exception {
void dispatchQueryAndSmartDispatchAreEnabledByDefault() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/application.yml"));
assertTrue(yaml.contains("enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}"));
assertTrue(yaml.contains("enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}"));
assertFalse(yaml.contains("future-outbound-api:"));
}
@Test
@@ -2,11 +2,17 @@ package com.baiyue.lastleg.dispatch.pda.service;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -15,7 +21,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
@@ -24,6 +33,16 @@ class DispatchPdaWorkflowServiceTest {
@Mock private DispatchOrderMapper orders;
@Mock private DispatchPdaOperationMapper operations;
@Mock private OutboundOrderMapper outboundOrders;
@Mock private DispatchItemMapper items;
@Mock private DispatchPdaTaskMapper tasks;
@Mock private DispatchPdaPickMapper picks;
@Test
void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() {
assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT"));
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8"));
assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT"));
}
@Test
void pushRequiresVehiclePlateBeforeCreatingPdaTask() {
@@ -118,4 +137,34 @@ class DispatchPdaWorkflowServiceTest {
assertEquals("DEPARTED", result.status());
assertEquals(true, result.replayed());
}
@Test
void completingAnAutomaticallyCompletedTaskReturnsIdempotentSuccess() {
DispatchPdaWorkflowService service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "tasks", tasks);
ReflectionTestUtils.setField(service, "picks", picks);
DispatchOrder order = new DispatchOrder();
order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1");
order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100");
DispatchPdaTask task = new DispatchPdaTask();
task.setId(2L); task.setStatus("COMPLETED");
DispatchItem item = new DispatchItem(); item.setPackageCount(6);
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L);
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("complete-1", "normal");
when(orders.findByDispatchNo("100", "PC1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "complete-1")).thenReturn(null);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
when(picks.listTask("100", 2L)).thenReturn(List.of(pick));
DispatchPdaResponses.PickingCompleted result = service.completePicking("100", "PC1", request);
assertEquals("READY_TO_DEPART", result.status());
assertEquals(6, result.pickedPackages());
assertEquals(true, result.replayed());
}
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.inboundplan.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanOrderMapperContractTest {
@Test
void transferWarehouseIsTheEffectivePalletDestination() throws Exception {
Select select = InboundPlanOrderMapper.class
.getMethod("lockOrder", String.class, Long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,'')))"));
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,'')))"));
assertTrue(sql.contains("THEN 1 ELSE COUNT(DISTINCT"));
assertTrue(sql.contains("0 AS destinationMismatchCount"));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.inboundplan.mapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanQueryMapperContractTest {
@Test
void platformListReadsVersionAndDoesNotRequireANullDispatchCustomer() throws Exception {
Select select = InboundPlanQueryMapper.class
.getMethod("rows", String.class,
com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests.Query.class,
long.class, long.class)
.getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertTrue(sql.contains("p.version version"));
assertTrue(sql.contains("WHERE di.order_id=l.order_id"));
assertTrue(sql.contains("<if test='customerId != null and customerId != \"\"'>AND di.customer_id=#{customerId}</if>"));
}
}
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
@@ -163,13 +164,19 @@ class InboundPlanServiceTest {
}
@Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
void transferWarehouseOverridesOriginalCargoDestination() {
SourceOrderSnapshot transferred = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(transferred));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
service.convert("100", 1L);
ArgumentCaptor<InboundPlanPallet> pallet = ArgumentCaptor.forClass(InboundPlanPallet.class);
verify(palletMapper).insert(pallet.capture());
assertEquals("WH-B", pallet.getValue().getDestinationWarehouseId());
assertEquals("WH-B", pallet.getValue().getDestinationWarehouseCode());
}
@Test
@@ -193,6 +200,53 @@ class InboundPlanServiceTest {
.map(InboundPlanPalletAllocation::getPlannedPackageCount).toList());
}
@Test
void updateSynchronizesDestinationSnapshotToLinesAndPallets() {
InboundPlan plan = new InboundPlan();
plan.setId(20L);
plan.setPlanNo("IP-20");
plan.setCustomerId("100");
plan.setStatus("PENDING_ASSIGNMENT");
plan.setVersion(3);
InboundPlanLine line = new InboundPlanLine();
line.setId(21L);
line.setCustomerId("100");
line.setPlanId(20L);
line.setDestinationWarehouseId("OLD-WH");
line.setDestinationWarehouseCode("OLD-CODE");
line.setDestinationWarehouseName("Old warehouse");
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(22L);
pallet.setCustomerId("100");
pallet.setPlanId(20L);
pallet.setDestinationWarehouseId("OLD-WH");
pallet.setDestinationWarehouseCode("OLD-CODE");
pallet.setDestinationWarehouseName("Old warehouse");
when(planMapper.selectOne(any())).thenReturn(plan);
when(lineMapper.selectList(any())).thenReturn(List.of(line), List.of(line));
when(palletMapper.selectList(any())).thenReturn(List.of(pallet), List.of(pallet));
when(allocationMapper.selectList(any())).thenReturn(List.of());
service.update("100", "IP-20",
new UpdateInboundPlanRequest("100", 193000000000200003L,
" US-LAX-01 ", " Los Angeles Warehouse ", 3));
ArgumentCaptor<InboundPlanLine> updatedLine = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper).updateById(updatedLine.capture());
assertEquals("193000000000200003", updatedLine.getValue().getDestinationWarehouseId());
assertEquals("US-LAX-01", updatedLine.getValue().getDestinationWarehouseCode());
assertEquals("Los Angeles Warehouse", updatedLine.getValue().getDestinationWarehouseName());
ArgumentCaptor<InboundPlanPallet> updatedPallet = ArgumentCaptor.forClass(InboundPlanPallet.class);
verify(palletMapper).updateById(updatedPallet.capture());
assertEquals("193000000000200003", updatedPallet.getValue().getDestinationWarehouseId());
assertEquals("US-LAX-01", updatedPallet.getValue().getDestinationWarehouseCode());
assertEquals("Los Angeles Warehouse", updatedPallet.getValue().getDestinationWarehouseName());
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
@@ -29,6 +29,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -43,6 +45,7 @@ class LastlegOrderServiceTest {
@Mock LastlegOrderOperationLogMapper operations;
@Mock InboundPlanCartonMapper cartons;
@Mock InventoryCommandService inventory;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
LastlegOrderService service;
@BeforeEach
@@ -55,6 +58,7 @@ class LastlegOrderServiceTest {
ReflectionTestUtils.setField(service, "operationLogMapper", operations);
ReflectionTestUtils.setField(service, "cartonMapper", cartons);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
}
@Test
@@ -178,7 +182,7 @@ class LastlegOrderServiceTest {
@Test
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null,
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
"", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null);
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
resultPage.setTotal(0);
@@ -194,7 +198,7 @@ class LastlegOrderServiceTest {
}
@Test
void pageReturnsConfirmedPalletMergeRelatedOrderNumbers() {
void pageReturnsPalletMergeRelatedInboundNumbers() {
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IB-1");
order.setPalletMergeFlag("MERGED");
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(1, 10);
@@ -202,15 +206,15 @@ class LastlegOrderServiceTest {
resultPage.setTotal(1);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of(
new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1, 10, null, null, null, null,
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"),
new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1")));
var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null,
null, null, null, null, null, null, null, null, null);
var result = service.page(null, query);
assertEquals("PMG-1", result.records().get(0).palletMergeBatchId());
assertEquals(List.of("LMO-RELATED-2", "LMO-RELATED-3"), result.records().get(0).palletMergeOrderNos());
assertEquals(List.of("INBOUND-2", "INBOUND-3"), result.records().get(0).palletMergeInboundNos());
}
@Test
@@ -228,6 +232,7 @@ class LastlegOrderServiceTest {
verify(operations).insert(operation.capture());
assertEquals("暂扣", operation.getValue().getOperationNode());
assertEquals("等待质检", operation.getValue().getResult());
verify(detainedOutbox).publish(eq(order), eq(true), eq("等待质检"), isNull(), eq("operator"), any(), anyLong());
verify(inventory, never()).releaseInboundOrderHold(any(), any(), anyLong(), any(), any());
}
@@ -243,6 +248,7 @@ class LastlegOrderServiceTest {
verify(inventory).releaseInboundOrderHold("100", "operator", 11L, "LM-1", "质检通过");
assertEquals(false, order.getDetained());
verify(detainedOutbox).publish(eq(order), eq(false), eq("质检通过"), isNull(), eq("operator"), any(), anyLong());
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
@@ -251,6 +257,9 @@ class LastlegOrderServiceTest {
order.setCustomerId(customerId);
order.setCurrentStatus(status);
order.setInboundOrderNo(inboundOrderNo);
order.setOrderNo("LM-" + id);
order.setSourceInboundId(String.valueOf(1000 + id));
order.setInboundCode("IN-" + id);
return order;
}
+2 -1
View File
@@ -17,7 +17,7 @@
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 |
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java` |
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java` 向 Firstleg 提供箱唛生成的 SKU 快照来源;`CargoLabelBinding.skuNo` 对 `ONE_CLICK_DROPSHIP` 为强制字段。Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java`,变更两端 DTO 必须同步。 |
| 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
@@ -29,6 +29,7 @@
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
- 对 `ONE_CLICK_DROPSHIP`,货物明细 SKU 是箱唛绑定及海外一件代发库存追踪的前置条件,不得依赖货名、箱规或其他展示字段推导 SKU。修改 `CargoLabelBinding` 时同步更新 Firstleg 客户端、相关测试与服务间契约。
## 全量验收导航
@@ -74,8 +74,8 @@ public class InternalOrderCargoController {
}
return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream()
.map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(),
cargo.getWidthCm(), cargo.getHeightCm()))
.toList());
}
@@ -155,8 +155,13 @@ public class InternalOrderCargoController {
}
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
String skuNo, Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
}
}
public record MeasurementDeclarationSnapshot(
-13
View File
@@ -16,19 +16,6 @@
<name>柏悦头程平台</name>
<description>柏悦悦物流国内头程业务微服务平台</description>
<distributionManagement>
<repository>
<id>baiyue-nexus-releases</id>
<name>柏悦 Nexus 正式制品库</name>
<url>http://8.163.30.128:18081/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>baiyue-nexus-snapshots</id>
<name>柏悦 Nexus 快照制品库</name>
<url>http://8.163.30.128:18081/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
<modules>
<module>platform-bom</module>
<module>common-core</module>
+7
View File
@@ -239,6 +239,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no)
) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl; EXECUTE ll_inbound_plan_version_ddl; DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
@@ -440,6 +443,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
relabel_order_no VARCHAR(128) NULL COMMENT '换标单号',
relabel_status VARCHAR(32) NULL COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记',
pallet_merge_batch_id VARCHAR(64) NULL COMMENT '平台预标记合托分组号',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)',
customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)',
@@ -457,6 +461,9 @@ CREATE TABLE IF NOT EXISTS ll_order (
KEY idx_ll_order_list (tenant_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
+3 -3
View File
@@ -3,9 +3,9 @@
<localRepository>D:/firstlegRepo</localRepository>
<mirrors>
<mirror>
<id>central-direct</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<id>aliyun-public</id>
<name>阿里云 Maven 公共镜像</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>external:*</mirrorOf>
</mirror>
</mirrors>