Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
135 changed files with 9649 additions and 370 deletions
Showing only changes of commit f3c2bafec8 - Show all commits
@@ -8,16 +8,27 @@ import java.util.Arrays;
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
*/
public enum ResultCode {
/** 请求处理成功。 */
SUCCESS("200", "success", HttpStatus.OK),
/** 请求参数不符合接口要求。 */
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
/** 未登录、令牌无效或登录态已过期。 */
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
/** 已登录但没有操作权限。 */
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
/** 请求的资源不存在或不在可访问范围内。 */
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
/** 资源版本、幂等键或当前状态发生冲突。 */
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
/** 字段校验、格式校验或业务前置校验未通过。 */
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
/** 请求频率或并发数量超过限制。 */
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
/** 未被更细错误码覆盖的通用业务失败。 */
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
/** 服务内部发生未预期异常。 */
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
/** 下游服务、数据库或基础设施暂不可用。 */
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
private final String code;
@@ -24,5 +24,18 @@ public final class ExportContracts {
public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { }
public enum ExportColumnType { STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATETIME }
public enum ExportColumnType {
/** 文本列。 */
STRING,
/** 整数列。 */
INTEGER,
/** 高精度小数列。 */
DECIMAL,
/** 布尔值列。 */
BOOLEAN,
/** 日期列,不包含时间。 */
DATE,
/** 日期时间列。 */
DATETIME
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
# 二期、三期业务功能回归报告
测试时间:2026-08-07
## 测试范围
- 二期:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。
- 三期:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。
- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。
- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。
## 项目索引结论
- 二期主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。
- 三期智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。
- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。
## 二期结果
结果:未通过,主链路在创建头程订单草稿前被阻断。
- 已通过 15 项:平台/商户登录、Firstleg 健康检查、企业注册、营业执照及法人身份证上传、企业资质提交与平台审核、承运商与客户往来单位创建、收货仓创建、客户计费规则创建、费率版本创建。
- 阻断接口:`POST /api/merchant/v1/orders/first-leg`。
- 返回:业务码 `A0404`,提示往来单位不存在或不属于当前企业。
### 阻断原因
1. 订单服务 `OrderMasterDataGuard` 固定以公共归属 `tenantId=0` 查询往来单位和收货仓,并要求订单客户快照往来单位具有 `SHIPPER` 类型。
2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。
3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。
结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,二期无法从下单继续验证测量、称重、计费和入库。
## 三期结果
结果:通过。
真实 API 已复核的完成装柜清单:
- 装柜计划:`2085554481377796097`
- 查询接口:`GET /api/platform/v1/first-leg/loading-lists/2085554481377796097?warehouseId=2084548444218646530`
- 最终状态:`COMPLETED`
- 集装箱号:`PH3-20260807103117`
- 装柜箱数:2
- 总件数:20
- 总毛重:20kg
- 总体积:0.24m3
- 历史事件存在:`PDA_COMPLETE_LOADING`
- 明细已有冻结时间,符合完成后不可修改的规则。
定向服务层测试:29 项通过、0 失败、0 错误。
- `SmartLoadingServiceTest`:15 项
- `LoadingListServiceTest`:8 项
- `LoadingPickServiceTest`:2 项
- `PdaPickSettlementPlanTest`:3 项
- `SmartLoadingStatusTest`:1 项
## 构建与脚本观察
- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。
- 二期验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。
- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。
- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。
## 建议处理顺序
1. 移除头程订单及二期计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。
2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。
3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。
4. 完成以上两项后,重新执行二期全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。
+185
View File
@@ -0,0 +1,185 @@
# 系统状态枚举说明
## 使用范围
本文档说明当前后端实际使用的状态值。状态值是接口、数据库和前端联调契约,不能为了展示中文而修改其英文编码;前端展示应使用本说明中的中文含义。
代码中的 Java 枚举、状态常量和数据库状态字段均已补充中文注释。本说明用于集中查询,不替代各业务状态机的校验。
## 通用响应码
| 状态值 | 中文含义 |
| --- | --- |
| `200` | 请求成功。 |
| `A0400` | 请求参数不符合接口要求。 |
| `A0401` | 未登录、令牌无效或会话过期。 |
| `A0403` | 已登录但没有权限。 |
| `A0404` | 资源不存在或不在可访问范围。 |
| `A0409` | 状态、版本或幂等键冲突。 |
| `A0422` | 字段或业务前置校验失败。 |
| `A0429` | 请求频率或并发数量超限。 |
| `B0001` | 通用业务失败。 |
| `S0500` | 服务内部异常。 |
| `S0503` | 下游服务、数据库或基础设施暂不可用。 |
## 账号和权限
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 账号状态 | `ACTIVE` | 账号正常,可登录。 |
| 账号状态 | `DISABLED` | 账号已停用,不允许登录。 |
| 账号状态 | `LOCKED` | 账号已锁定,需要管理员解除。 |
| 客户资料状态 | `PROFILE_REQUIRED` | 客户资料尚未补全。 |
| 客户资料状态 | `PENDING` | 客户资料已提交,等待审核。 |
| 客户资料状态 | `ACTIVE` | 客户审核通过,可正常开展业务。 |
| 客户资料状态 | `REJECTED` | 客户资料审核驳回。 |
| 客户资料状态 | `DISABLED` | 客户已停用。 |
| 客户、成员、角色、权限状态 | `ACTIVE` | 已启用,可参与登录、授权或业务使用。 |
| 客户、成员、角色、权限状态 | `DISABLED` | 已停用,不可继续使用。 |
| 审核记录状态 | `PENDING` | 等待审核。 |
| 审核记录状态 | `APPROVED` | 审核通过。 |
| 审核记录状态 | `REJECTED` | 审核驳回。 |
| 审核记录状态 | `SUPERSEDED` | 已被更新后的审核记录替代。 |
## 订单状态
| 状态值 | 中文含义 |
| --- | --- |
| `DRAFT` | 草稿,可继续编辑。 |
| `SUBMITTED` | 已提交,等待平台审核。 |
| `APPROVED` | 审核通过,可接单或进入履约。 |
| `ACCEPTED` | 平台已接单。 |
| `REJECTED` | 审核驳回,不允许进入履约。 |
| `CANCELLED` | 已取消,订单结束。 |
| `PROCESSING` | 履约处理中。 |
| `COMPLETED` | 订单完成。 |
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。
## 仓库与库位
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 仓库、往来单位、快递公司 | `ACTIVE` | 启用,可被下单和作业引用。 |
| 仓库、往来单位、快递公司 | `INACTIVE` | 停用,不允许新增业务引用。 |
| 托盘 | `AVAILABLE` | 可分配。 |
| 托盘 | `RESERVED` | 已预占,尚未确认占用。 |
| 托盘 | `OCCUPIED` | 已确认占用。 |
| 库位 | `AVAILABLE` | 可用。 |
| 库位 | `RESERVED` | 已预占。 |
| 库位 | `OCCUPIED` | 已占用。 |
| 库位 | `DISABLED` | 已停用,不允许分配。 |
| 库位预占记录 | `RESERVED` | 预占生效中。 |
| 库位预占记录 | `CONFIRMED` | 预占已转为正式占用。 |
| 库位预占记录 | `RELEASED` | 已释放。 |
| 库位预占记录 | `EXPIRED` | 预占超时失效。 |
| 库位占用记录 | `OCCUPIED` | 正在占用。 |
| 库位占用记录 | `RELEASED` | 已释放。 |
## 二期入库
### 入库单主流程状态
| 状态值 | 中文含义 |
| --- | --- |
| `WAIT_MEASURE` | 等待录入或接收测量数据。 |
| `MEASURING` | 正在测量、识别重量或人工修正。 |
| `MEASURE_PENDING` | 测量已提交,等待审核。 |
| `MEASURE_REJECTED` | 测量审核被驳回,等待修正或异常放行。 |
| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 |
| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 |
| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 |
| `INBOUNDED` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 |
| `LOADED` | 装柜完成,等待派送完成。 |
| `DELIVERED` | 已完成派送。 |
| `VOIDED` | 已作废,不允许继续处理。 |
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
### 测量、重量和费用状态
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 测量批次 | `DRAFT` | 后台人工测量草稿,可编辑。 |
| 测量批次 | `RECEIVED` | 已接收第三方测量回传,待平台提交审核。 |
| 测量批次 | `SUBMITTED` | 已提交测量审核。 |
| 测量批次 | `APPROVED` | 测量审核通过。 |
| 测量批次 | `REJECTED` | 测量审核驳回。 |
| 重量识别 | `PENDING_CONFIRM` | OCR 已识别有效数值,等待人工确认。 |
| 重量识别 | `NEEDS_MANUAL` | OCR 无效或不完整,必须人工录入或修正。 |
| 重量识别 | `CONFIRMED` | 重量已确认,可参与计费。 |
| 测量审核、费用审核 | `NOT_SUBMITTED` | 尚未提交审核。 |
| 测量审核、费用审核 | `PENDING` | 等待审核。 |
| 测量审核、费用审核 | `APPROVED` | 审核通过。 |
| 测量审核、费用审核 | `REJECTED` | 审核驳回。 |
| 费用状态 | `UNPRICED` | 尚未计费。 |
| 费用状态 | `CALCULATED` | 已计算费用,等待确认。 |
| 费用状态 | `CONFIRMED` | 费用已确认,可发送给客户。 |
| 费用状态 | `SENT` | 费用明细已发送给客户。 |
| 客户通知状态 | `NOT_CREATED` | 尚未创建通知任务。 |
| 客户通知状态 | `PENDING` | 已创建待处理通知。当前代码未发现发送完成后的独立状态更新。 |
### 第三方回传和幂等记录
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 第三方测量调用 | `PROCESSING` | 请求正在处理。 |
| 第三方测量调用 | `ACCEPTED` | 回传被接受,包含幂等重放成功。 |
| 第三方测量调用 | `REJECTED` | 回传校验或处理失败。 |
| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 |
| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 |
## 三期智能装柜和 PDA
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 装柜计划 | `EDITING` | 编辑中,可维护货柜信息,并可增删未拣货明细。 |
| 装柜计划 | `LOADING` | 装柜中,已生成 PDA 拣货任务;未拣货明细仍可追加、修改或删除。 |
| 装柜计划 | `COMPLETED` | PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 |
| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 |
| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 |
| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 |
| PDA 拣货明细 | `PICKING` | 已拣部分数量,仍可继续拣货。 |
| PDA 拣货明细 | `PICKED` | 计划箱数已全部拣完。 |
| PDA 拣货明细 | `PARTIAL` | 只拣到部分箱数,剩余数量需要生成关联子入库单。 |
| PDA 拣货明细 | `SKIPPED` | 本次跳过,入库单回退为已入库并可重新参与智能装柜。 |
| 预装清单 | `PENDING` | 预装草稿,尚未开始确认。 |
| 预装清单 | `CONFIRMING` | 正在确认预装明细。 |
| 预装清单 | `CONFIRMED` | 预装已确认,可进入装柜作业。 |
| 新版拆单批次 | `PREPARED` | 已准备待应用。 |
| 新版拆单批次 | `APPLIED` | 已应用并生成关联子入库单。 |
| 新版拆单批次 | `CANCELLED` | 已取消,不能再次应用。 |
| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 |
| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 |
装柜计划只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。
## 文件、导出和审计
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 导出任务 | `PENDING` | 等待执行。 |
| 导出任务 | `RUNNING` | 正在生成文件。 |
| 导出任务 | `SUCCEEDED` | 文件已生成,可下载。 |
| 导出任务 | `FAILED` | 生成失败,可按规则重试。 |
| 导出任务 | `EXPIRED` | 文件已过期。 |
| 导出消息 | `PENDING` | 等待投递。 |
| 导出消息 | `CLAIMED` | 已被工作线程领取。 |
| 导出消息 | `SENT` | 已成功投递。 |
| 导出消息 | `QUARANTINED` | 异常隔离,等待人工或补偿处理。 |
| 审计消息 | `PENDING` | 等待发送。 |
| 审计消息 | `SENDING` | 正在发送。 |
| 审计消息 | `SENT` | 已发送成功。 |
| 审计消息 | `DEAD` | 多次失败,进入死信补偿。 |
## 第三方能力和 OCR
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 第三方能力 | `DISABLED` | 能力未启用。 |
| 第三方能力 | `NOT_CONFIGURED` | 已启用但密钥或配置不完整。 |
| 第三方能力 | `READY` | 已启用且配置完整。 |
| 单图 OCR | `RECOGNIZED` | 已识别到有效重量。 |
| 单图 OCR | `NO_WEIGHT_FOUND` | 已处理图片但未识别到可用重量。 |
| 单图 OCR | `FAILED` | 图片处理、供应商调用或解析失败。 |
+1
View File
@@ -22,6 +22,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -8,10 +8,10 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan({
"com.baiyue.firstleg.mapper",
"com.baiyue.firstleg.loading.mapper",
"com.baiyue.firstleg.split.mapper"
@MapperScan(basePackages = {
"com.baiyue.firstleg.mapper",
"com.baiyue.firstleg.split.mapper",
"com.baiyue.firstleg.loading.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
@@ -26,7 +26,15 @@ public interface OrderCargoClient {
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
String cargoRemark) {
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
}
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
public interface WarehouseMasterDataClient {
@GetMapping("/{tenantId}/{warehouseId}/active")
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long warehouseId);
record Detail(Warehouse warehouse) {
}
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String status) {
}
}
@@ -25,6 +25,11 @@ public interface WarehousePalletLocationClient {
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
@PostMapping("/release-selected")
ApiResponse<List<SelectiveReleaseResult>> releaseSelected(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody SelectiveReleaseRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
@@ -33,10 +38,16 @@ public interface WarehousePalletLocationClient {
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record SelectiveReleaseRequest(long tenantId, String sourceService, long businessId,
long operatorId, String reason, int totalPackageCount,
List<Long> locationIds, Integer releaseLocationCount) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
record SelectiveReleaseResult(long reservationId, long occupancyId, int allocationNo,
long palletId, long locationId, String locationCode,
int releasedPackageCount) {}
}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Tag(name = "货柜基础资料") @Validated @RestController
@RequestMapping("/api/platform/v1/containers")
public class PlatformContainerController {
@Resource private ContainerService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody SaveRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request));
}
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅无装柜计划或计划为EDITING时允许")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
}
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
description="修改货柜集装箱号和航次")
@Operation(summary="修改集装箱号和航次", description="LOADING仅开放此白名单;COMPLETED全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
}
}
@@ -51,7 +51,7 @@ public class PlatformInboundMeasurementReviewController {
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
// 统一订单主线要求测量审核后直接进入 WAIT_SPLIT,不把计费、库位或其他资料
// 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
return ApiResponse.success(null);
}
@@ -0,0 +1,92 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg")
public class PlatformLoadingListController {
@Resource
private LoadingListService service;
@Operation(summary = "分页查询预装清单")
@GetMapping("/loading-plans")
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
public ApiResponse<PageResult<PreloadSummary>> preloadPage(
@AuthenticationPrincipal AuthenticatedUser user, @Valid PreloadQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadPage(query));
}
@Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。")
@GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.preloadDetail(planId, warehouseId));
}
@Operation(summary = "修改装柜运输字段", description = "仅装柜中可修改集装箱号与航次。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "分页查询完成装柜清单", description = "只读取 COMPLETED 状态的装柜计划。")
@GetMapping("/loading-lists")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<PageResult<ConfirmedSummary>> confirmedPage(
@AuthenticationPrincipal AuthenticatedUser user, @Valid ConfirmedQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedPage(query));
}
@Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。")
@GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** PDA 按集装箱号执行装柜拣货。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg/pda-loading")
public class PlatformLoadingPdaController {
@Resource
private LoadingPickService service;
@GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String containerNo,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo, warehouseId));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/pick")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody PickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody PartialPickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.partial(taskId, itemId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/skip")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Task> skip(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long itemId,
@Valid @RequestBody SkipRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.skip(taskId, itemId, request, user.userId(), user.username()));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService;
import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List;
@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading")
public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service;
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(warehouseId));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));}
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
}
@@ -4,9 +4,13 @@ import java.util.List;
/** 入库列表页签及其流程状态映射。 */
public enum InboundListTab {
/** 待测量:包含待测量、测量中和测量审核驳回的入库单。 */
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
/** 待审核:测量数据已经提交,等待审核结论。 */
WAIT_REVIEW(List.of("MEASURE_PENDING")),
/** 待发送费用:等待计费、费用确认或正在完成入库。 */
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
/** 已入库:费用已发送且入库完成。 */
INBOUNDED(List.of("INBOUNDED"));
private final List<String> processStatuses;
@@ -4,10 +4,35 @@ import java.util.Set;
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
public final class InboundProcessStatus {
/** 等待录入或接收测量数据。 */
public static final String WAIT_MEASURE = "WAIT_MEASURE";
/** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
public static final String MEASURING = "MEASURING";
/** 测量已提交,等待平台审核。 */
public static final String MEASURE_PENDING = "MEASURE_PENDING";
/** 测量审核被驳回,等待修正或异常放行。 */
public static final String MEASURE_REJECTED = "MEASURE_REJECTED";
/** 测量已通过或异常放行,等待计费。 */
public static final String WAIT_PRICING = "WAIT_PRICING";
/** 已生成费用,等待费用确认和发送。 */
public static final String FEE_CONFIRMING = "FEE_CONFIRMING";
/** 费用已确认,正在执行托盘、库位占用和入库收口。 */
public static final String INBOUNDING = "INBOUNDING";
/** 已入库完成,费用已发送给客户。 */
public static final String INBOUNDED = "INBOUNDED";
/** 已进入装柜作业,PDA 可执行拣货。 */
public static final String LOADING = "LOADING";
/** 装柜完成,等待派送完成。 */
public static final String LOADED = "LOADED";
/** 已完成派送。 */
public static final String DELIVERED = "DELIVERED";
/** 已作废,不再允许继续处理。 */
public static final String VOIDED = "VOIDED";
public static final Set<String> VALUES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED",
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED"
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED,
LOADING, LOADED, DELIVERED, VOIDED
);
private InboundProcessStatus() {
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.domain;
/** 智能装柜状态,只允许沿此方向推进。 */
public enum SmartLoadingStatus {
/** 编辑中:可以维护货柜信息并增删未拣货明细。 */
EDITING,
/** 装柜中:已生成 PDA 拣货任务,仍可追加或调整未拣货明细。 */
LOADING,
/** 装柜完成:PDA 拣货完成后自动完成,计划、明细和货柜信息只读。 */
COMPLETED;
/** 装柜完成前可维护集装箱号和航次。 */
public boolean allowsTransportUpdate() { return this != COMPLETED; }
/** 编辑中和装柜中均允许维护尚未拣货的明细。 */
public boolean mutable() { return this != COMPLETED; }
public boolean canAdvanceTo(SmartLoadingStatus next) {
return next != null && next.ordinal() == ordinal() + 1;
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 平台级货柜基础资料请求模型。 */
public final class ContainerDtos {
private ContainerDtos() {}
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
public record Query(
@Size(max = 64) String containerNo,
@Size(max = 64) String cabinetNo,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String keyword,
@Positive Long warehouseId,
@Size(max = 32) String containerType,
@Positive Long shippingCompanyId,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 64) String destinationWarehouseCode,
SmartLoadingStatus status,
LocalDateTime estimatedSailingFrom,
LocalDateTime estimatedSailingTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest(
@Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotBlank @Size(max = 32) String containerType,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 64) String destinationWarehouseCode,
@Size(max = 128) String destinationWarehouseName,
@DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@Size(max = 128) String terminalTruckingCompany,
LocalDateTime loadingAt,
LocalDateTime estimatedSailingAt,
LocalDateTime actualSailingAt,
LocalDateTime estimatedPortArrivalAt,
LocalDateTime actualPortArrivalAt,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt
) {}
@Schema(name = "ContainerFullUpdateRequest", description = "智能装柜 EDITING 阶段完整编辑")
public record FullUpdateRequest(
@NotNull @PositiveOrZero Integer version,
@Valid @NotNull SaveRequest data
) {}
@Schema(name = "ContainerTransportUpdateRequest",
description = "LOADING 阶段只允许修改集装箱号和航次")
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @PositiveOrZero Integer version
) {}
}
@@ -35,6 +35,7 @@ public final class InboundMeasurementRequests {
}
public record ItemRequest(
@NotNull @Positive Long inboundDetailId,
@NotBlank @Size(max = 32) String itemNo,
@NotBlank @Size(max = 64) String boxSpecification,
@NotNull @Positive @Max(1000000) Integer packageCount,
@@ -47,6 +47,7 @@ public final class InboundMeasurementResponses {
public record Item(
Long id,
Long inboundDetailId,
String itemNo,
String boxSpecification,
Integer packageCount,
@@ -0,0 +1,167 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 同一装柜计划聚合的预装清单和已确认装柜清单接口模型。 */
public final class LoadingListDtos {
private LoadingListDtos() {
}
public record PreloadQuery(
@Size(max = 128) String keyword,
String status,
@NotNull @Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public PreloadQuery {
page = page == 0 ? 1 : page;
size = size == 0 ? 20 : size;
}
}
public record ConfirmedQuery(
@Size(max = 128) String keyword,
@NotNull @Min(1) Long warehouseId,
LocalDateTime createdFrom,
LocalDateTime createdTo,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public ConfirmedQuery {
page = page == 0 ? 1 : page;
size = size == 0 ? 20 : size;
}
}
public record TransportUpdateRequest(
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
) {
}
public record AdvanceRequest(
@NotNull @Min(0) Integer expectedVersion,
@Size(max = 500) String operationReason
) {
}
public record Customer(Long accountId, String customerName) {
}
public record PreloadSummary(
Long planId,
String preloadNo,
String containerNo,
String internalContainerNo,
List<Customer> customers,
Long warehouseId,
String warehouseName,
String vesselName,
String voyageNo,
String containerType,
String status,
long skuCount,
long totalPackageCount,
BigDecimal totalVolumeCbm,
BigDecimal totalGrossWeightKg,
LocalDateTime createdAt,
Integer version
) {
public PreloadSummary {
customers = customers == null ? List.of() : List.copyOf(customers);
}
}
public record ConfirmedSummary(
Long planId,
String loadingListNo,
Long warehouseId,
String warehouseName,
String containerNo,
String internalContainerNo,
String status,
long totalPackageCount,
BigDecimal totalGrossWeightKg,
BigDecimal totalNetWeightKg,
BigDecimal totalVolumeCbm,
long totalQuantity,
String remark,
Long createdBy,
String createdByName,
LocalDateTime createdAt,
Integer version
) {
}
public record Item(
Long itemId,
Long accountId,
String customerName,
Long inboundId,
Long inboundDetailId,
String inboundCode,
String skuCode,
String destinationWarehouse,
String chineseName,
String englishName,
String goodsImageUrl,
int packageCount,
int palletCount,
BigDecimal grossWeightKg,
BigDecimal netWeightKg,
BigDecimal volumeCbm,
BigDecimal dimensionalWeightKg,
long productQuantity,
int unitsPerPackage,
String fbaTrackingNo,
String shipmentNo,
String shippingMark,
String internalRemark,
String customerRemark,
String warehouseRemark,
int loadSequence,
LocalDateTime frozenAt
) {
}
public record History(
Long historyId,
Long itemId,
String actionType,
String fromStatus,
String toStatus,
String beforeSnapshot,
String afterSnapshot,
Long operatorId,
String operatorName,
String operationReason,
LocalDateTime occurredAt
) {
}
public record PreloadDetail(PreloadSummary summary, List<Item> items, List<History> histories) {
public PreloadDetail {
items = items == null ? List.of() : List.copyOf(items);
histories = histories == null ? List.of() : List.copyOf(histories);
}
}
public record ConfirmedDetail(ConfirmedSummary summary, List<Item> items, List<History> histories) {
public ConfirmedDetail {
items = items == null ? List.of() : List.copyOf(items);
histories = histories == null ? List.of() : List.copyOf(histories);
}
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** PDA 装柜拣货接口模型。 */
public final class LoadingPickDtos {
private LoadingPickDtos() {
}
public record PickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer pickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Integer finalPickedPackageCount,
@JsonAlias("reservationIds") List<@Min(1) Long> locationIds,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId
) {
}
/** 跳拣只能发生在尚未拣出任何箱数的 PDA 明细上。 */
public record SkipRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(0) Integer version,
@NotBlank @Size(max = 128) String operationId,
@Size(max = 500) String reason
) {
}
public record PickItem(Long id, Long loadingPlanItemId, Long inboundId, Long inboundDetailId,
Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) {
}
public record Task(Long id, Long loadingPlanId, String containerNo, String taskStatus,
Integer version, List<PickItem> items) {
}
}
@@ -28,6 +28,7 @@ public record MeasurementBatchCommand(
List<Item> items
) {
public record Item(
Long inboundDetailId,
String itemNo,
String boxSpecification,
int packageCount,
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 平台级货柜预订资料;预装状态由关联装柜计划聚合。 */
@Getter @Setter
@TableName("fl_container_booking")
public class ContainerBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String billOfLadingNo;
private String vesselName;
private String voyageNo;
private String containerType;
private Long shippingCompanyId;
private String shippingCompanyName;
private String departurePortCode;
private String departurePortName;
private String destinationPortCode;
private String destinationPortName;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal capacityCbm;
private BigDecimal maxWeightKg;
private String terminalTruckingCompany;
private LocalDateTime loadingAt;
private LocalDateTime estimatedSailingAt;
private LocalDateTime actualSailingAt;
private LocalDateTime estimatedPortArrivalAt;
private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private LocalDateTime actualWarehouseArrivalAt;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
@TableField(exist = false) private Long planId;
@TableField(exist = false) private String planStatus;
}
@@ -26,6 +26,7 @@ public class InboundPalletAllocationEntity {
private BigDecimal palletCapacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private Integer pickedPackageCount;
private Long locationId;
private String locationCode;
private String reservationNo;
@@ -0,0 +1,25 @@
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;
/** PDA 拣货操作幂等历史。 */
@Getter
@Setter
@TableName("fl_loading_pick_history")
public class LoadingPickHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long pickItemId;
private String operationId;
private String requestHash;
private String actionType;
private Integer pickedPackageCount;
private String operationReason;
private Long operatorId;
private String operatorName;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.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.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 装柜拣货明细。 */
@Getter
@Setter
@TableName("fl_loading_pick_item")
public class LoadingPickItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long loadingPlanItemId;
private Long inboundId;
private Long inboundDetailId;
private Integer plannedPackageCount;
private Integer pickedPackageCount;
private String itemStatus;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.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.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** PDA 装柜拣货任务。 */
@Getter
@Setter
@TableName("fl_loading_pick_task")
public class LoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long loadingPlanId;
private String containerNo;
private String taskStatus;
private Long startedBy;
private String startedByName;
private LocalDateTime startedAt;
private Long completedBy;
private String completedByName;
private LocalDateTime completedAt;
@Version
private Integer version;
}
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan")
public class LoadingPlanEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus;
private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt;
private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy;
private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName;
private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan_history")
public class LoadingPlanHistoryEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long loadingPlanItemId; private String operationId;
private String actionType; private String fromStatus; private String toStatus; private String beforeSnapshot; private String afterSnapshot;
private Long operatorId; private String operatorName; private String operationReason; private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_loading_plan_item")
public class LoadingPlanItemEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long loadingPlanId; private Long accountId; private String customerName;
private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn;
private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton;
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
@TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt;
private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
}
@@ -6,10 +6,15 @@ import java.util.Set;
* 装柜证据类型。照片语义尚待产品确认,当前只固定四个步骤和一个铅封证据槽位。
*/
public final class LoadingEvidenceType {
/** 装柜过程第 1 步照片。 */
public static final String PHOTO_STEP_1 = "PHOTO_STEP_1";
/** 装柜过程第 2 步照片。 */
public static final String PHOTO_STEP_2 = "PHOTO_STEP_2";
/** 装柜过程第 3 步照片。 */
public static final String PHOTO_STEP_3 = "PHOTO_STEP_3";
/** 装柜过程第 4 步照片。 */
public static final String PHOTO_STEP_4 = "PHOTO_STEP_4";
/** 柜门铅封或封条证据。 */
public static final String SEAL = "SEAL";
public static final Set<String> VALUES = Set.of(PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL);
@@ -1,8 +1,11 @@
package com.baiyue.firstleg.loading.domain;
public final class PickingStatus {
/** PDA 拣货尚未开始。 */
public static final String PENDING = "PENDING";
/** PDA 正在执行拣货。 */
public static final String IN_PROGRESS = "IN_PROGRESS";
/** PDA 拣货已完成。 */
public static final String COMPLETED = "COMPLETED";
private PickingStatus() {}
}
@@ -7,7 +7,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
public interface InboundLoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
@@ -7,7 +7,7 @@ import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -19,7 +19,7 @@ public class LoadingRepository {
@Resource private LoadingInboundMapper inbounds;
@Resource private LoadingEvidenceMapper evidences;
@Resource private LoadingOperationMapper operations;
@Resource private LoadingPickTaskMapper picks;
@Resource private InboundLoadingPickTaskMapper picks;
public LoadingInboundMapper.Snapshot findInbound(long tenantId, long inboundId) {
return inbounds.find(tenantId, inboundId);
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Select("""
<script>
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
AND (#{query.warehouseId} IS NULL OR b.warehouse_id = #{query.warehouseId})
AND (#{query.containerType} IS NULL OR b.container_type = #{query.containerType})
AND (#{query.shippingCompanyId} IS NULL OR b.shipping_company_id = #{query.shippingCompanyId})
AND (#{query.departurePortCode} IS NULL OR b.departure_port_code = #{query.departurePortCode})
AND (#{query.destinationPortCode} IS NULL OR b.destination_port_code = #{query.destinationPortCode})
AND (#{query.destinationWarehouseCode} IS NULL OR b.destination_warehouse_code = #{query.destinationWarehouseCode})
AND (#{query.status} IS NULL OR COALESCE(p.plan_status, 'EDITING') = #{query.status})
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at &gt;= #{query.estimatedSailingFrom})
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo})
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.vessel_name LIKE CONCAT('%', #{query.keyword}, '%')
OR b.voyage_no LIKE CONCAT('%', #{query.keyword}, '%'))
ORDER BY b.updated_at DESC, b.id DESC
</script>
""")
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
@Select("""
SELECT b.*, p.id AS plan_id, COALESCE(p.plan_status, 'EDITING') AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE b.id = #{id}
""")
ContainerBookingEntity selectBooking(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
warehouse_id=#{value.warehouseId}, warehouse_name=#{value.warehouseName},
bill_of_lading_no=#{value.billOfLadingNo}, vessel_name=#{value.vesselName}, voyage_no=#{value.voyageNo},
container_type=#{value.containerType}, shipping_company_id=#{value.shippingCompanyId},
shipping_company_name=#{value.shippingCompanyName}, departure_port_code=#{value.departurePortCode},
departure_port_name=#{value.departurePortName}, destination_port_code=#{value.destinationPortCode},
destination_port_name=#{value.destinationPortName}, destination_warehouse_code=#{value.destinationWarehouseCode},
destination_warehouse_name=#{value.destinationWarehouseName}, capacity_cbm=#{value.capacityCbm},
max_weight_kg=#{value.maxWeightKg}, terminal_trucking_company=#{value.terminalTruckingCompany},
loading_at=#{value.loadingAt}, estimated_sailing_at=#{value.estimatedSailingAt},
actual_sailing_at=#{value.actualSailingAt}, estimated_port_arrival_at=#{value.estimatedPortArrivalAt},
actual_port_arrival_at=#{value.actualPortArrivalAt},
estimated_warehouse_arrival_at=#{value.estimatedWarehouseArrivalAt},
actual_warehouse_arrival_at=#{value.actualWarehouseArrivalAt}, updated_by=#{operatorId},
updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{value.id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status&lt;&gt;'EDITING')
""")
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND version=#{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status='COMPLETED')
""")
int updateTransport(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED',
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
@@ -3,7 +3,38 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
@Select("""
SELECT *
FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId}
AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED'
ORDER BY allocation_no ASC
FOR UPDATE
""")
java.util.List<InboundPalletAllocationEntity> selectConfirmedForUpdate(
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("""
UPDATE fl_inbound_pallet_allocation
SET picked_package_count = picked_package_count + #{count},
allocation_status = CASE
WHEN picked_package_count + #{count} = allocated_package_count THEN 'RELEASED'
ELSE 'CONFIRMED'
END,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND inbound_id = #{inboundId}
AND allocation_status = 'CONFIRMED'
AND picked_package_count + #{count} <= allocated_package_count
""")
int consume(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("count") int count);
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** PDA部分拣货结算的客户归属锁定查询。 */
@Mapper
public interface InboundSplitSettlementMapper {
@Select("""
SELECT id, tenant_id AS tenantId, inbound_code AS inboundCode, inbound_role AS inboundRole,
process_status AS processStatus, version
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND id = #{inboundId} AND deleted = 0
FOR UPDATE
""")
ParentRow lockParent(@Param("accountId") long accountId, @Param("inboundId") long inboundId);
@Select("""
SELECT id
FROM fl_inbound_order
WHERE tenant_id = #{accountId} AND parent_inbound_id = #{parentInboundId}
AND inbound_role = 'CHILD' AND deleted = 0
LIMIT 1
FOR UPDATE
""")
Long childId(@Param("accountId") long accountId, @Param("parentInboundId") long parentInboundId);
@Select({"<script>", """
SELECT item.id AS measurementItemId, measurement.id AS measurementId,
item.inbound_detail_id AS inboundDetailId, item.package_count AS packageCount
FROM fl_inbound_measurement measurement
JOIN fl_inbound_measurement_item item ON item.measurement_id = measurement.id
WHERE measurement.tenant_id = #{accountId} AND measurement.inbound_id = #{inboundId}
AND measurement.status = 'APPROVED' AND item.package_count > 0
AND measurement.id = (
SELECT MAX(latest.id) FROM fl_inbound_measurement latest
WHERE latest.tenant_id = #{accountId} AND latest.inbound_id = #{inboundId}
AND latest.status = 'APPROVED')
AND item.inbound_detail_id IN
""", "<foreach collection='detailIds' item='detailId' open='(' separator=',' close=')'>",
"#{detailId}", "</foreach>", "FOR UPDATE", "</script>"})
List<MeasurementItemRow> lockLatestApprovedItems(@Param("accountId") long accountId,
@Param("inboundId") long inboundId,
@Param("detailIds") List<Long> detailIds);
record ParentRow(Long id, Long tenantId, String inboundCode, String inboundRole,
String processStatus, Integer version) {
}
record MeasurementItemRow(Long measurementItemId, Long measurementId, Long inboundDetailId,
Integer packageCount) {
}
}
@@ -0,0 +1,287 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */
@Mapper
public interface LoadingListQueryMapper {
String AGGREGATE_COLUMNS = """
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type,
COUNT(DISTINCT NULLIF(i.sku_code, '')) AS sku_count,
COALESCE(SUM(i.package_count), 0) AS total_package_count,
COALESCE(SUM(i.gross_weight_kg), 0) AS total_gross_weight_kg,
COALESCE(SUM(i.net_weight_kg), 0) AS total_net_weight_kg,
COALESCE(SUM(i.volume_cbm), 0) AS total_volume_cbm,
COALESCE(SUM(i.product_quantity), 0) AS total_quantity
""";
String AGGREGATE_GROUP = """
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type
""";
@Select("""
<script>
SELECT
""" + AGGREGATE_COLUMNS + """
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.plan_status = 'LOADING'
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
<if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
""" + AGGREGATE_GROUP + """
ORDER BY p.created_at DESC, p.id DESC
</script>
""")
Page<AggregateRow> selectPreloadPage(Page<?> page, @Param("query") PreloadQuery query);
@Select("""
<script>
SELECT
""" + AGGREGATE_COLUMNS + """
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.plan_status = 'COMPLETED'
<if test="query.keyword != null and query.keyword != ''">
AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%'))
</if>
<if test="query.warehouseId != null">AND b.warehouse_id = #{query.warehouseId}</if>
<if test="query.createdFrom != null">AND p.created_at &gt;= #{query.createdFrom}</if>
<if test="query.createdTo != null">AND p.created_at &lt;= #{query.createdTo}</if>
GROUP BY
""" + AGGREGATE_GROUP + """
ORDER BY p.created_at DESC, p.id DESC
</script>
""")
Page<AggregateRow> selectConfirmedPage(Page<?> page, @Param("query") ConfirmedQuery query);
@Select("""
SELECT
""" + AGGREGATE_COLUMNS + """
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE p.id = #{planId}
GROUP BY
""" + AGGREGATE_GROUP)
AggregateRow selectAggregate(@Param("planId") long planId);
@Select("""
SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id,
inbound_code, sku_code, destination_warehouse_name, product_name_zh,
product_name_en, product_image_url, package_count, pallet_count,
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
load_sequence, frozen_at
FROM fl_loading_plan_item
WHERE loading_plan_id = #{planId} AND deleted = 0
ORDER BY load_sequence, id
""")
List<ItemRow> selectItems(@Param("planId") long planId);
@Select("""
<script>
SELECT DISTINCT loading_plan_id AS plan_id, account_id, customer_name
FROM fl_loading_plan_item
WHERE deleted = 0 AND loading_plan_id IN
<foreach collection="planIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY loading_plan_id, account_id
</script>
""")
List<CustomerRow> selectCustomers(@Param("planIds") List<Long> planIds);
@Select("""
SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id,
action_type, from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at AS occurred_at
FROM fl_loading_plan_history
WHERE loading_plan_id = #{planId}
ORDER BY created_at DESC, id DESC
""")
List<HistoryRow> selectHistories(@Param("planId") long planId);
@Select("""
SELECT id AS history_id, loading_plan_item_id AS item_id, operation_id,
action_type, from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at AS occurred_at
FROM fl_loading_plan_history WHERE operation_id = #{operationId}
""")
HistoryRow selectHistoryByOperation(@Param("operationId") String operationId);
@Update("""
UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = 'LOADING'
AND version = #{expectedVersion}
""")
int touchEditable(@Param("planId") long planId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking b
JOIN fl_loading_plan p ON p.container_booking_id = b.id
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status = 'LOADING'
""")
int updateTransport(@Param("planId") long planId, @Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan
SET plan_status = #{toStatus},
preload_completed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId}
ELSE preload_completed_by END,
preload_completed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName}
ELSE preload_completed_by_name END,
preload_completed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3)
ELSE preload_completed_at END,
confirmed_by = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorId} ELSE confirmed_by END,
confirmed_by_name = CASE WHEN #{toStatus} = 'COMPLETED' THEN #{operatorName}
ELSE confirmed_by_name END,
confirmed_at = CASE WHEN #{toStatus} = 'COMPLETED' THEN CURRENT_TIMESTAMP(3)
ELSE confirmed_at END,
updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{planId} AND plan_status = #{fromStatus} AND version = #{expectedVersion}
""")
int advance(@Param("planId") long planId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_loading_plan_history
(id, loading_plan_id, loading_plan_item_id, operation_id, action_type,
from_status, to_status, before_snapshot, after_snapshot,
operator_id, operator_name, operation_reason, created_at)
VALUES
(#{id}, #{planId}, NULL, #{operationId}, #{actionType},
#{fromStatus}, #{toStatus}, #{beforeSnapshot}, #{afterSnapshot},
#{operatorId}, #{operatorName}, #{operationReason}, CURRENT_TIMESTAMP(3))
""")
int insertHistory(HistoryWrite value);
@Getter
@Setter
class AggregateRow {
private Long planId;
private String planNo;
private Long containerBookingId;
private String planStatus;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Integer version;
private String containerNo;
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String vesselName;
private String voyageNo;
private String containerType;
private Long skuCount;
private Long totalPackageCount;
private BigDecimal totalGrossWeightKg;
private BigDecimal totalNetWeightKg;
private BigDecimal totalVolumeCbm;
private Long totalQuantity;
}
@Getter
@Setter
class ItemRow {
private Long itemId;
private Long accountId;
private String customerName;
private Long inboundId;
private Long inboundDetailId;
private String inboundCode;
private String skuCode;
private String destinationWarehouseName;
private String productNameZh;
private String productNameEn;
private String productImageUrl;
private Integer packageCount;
private Integer palletCount;
private BigDecimal grossWeightKg;
private BigDecimal netWeightKg;
private BigDecimal volumeCbm;
private BigDecimal dimensionalWeightKg;
private Long productQuantity;
private Integer unitsPerCarton;
private String fbaTrackingNo;
private String shipmentNo;
private String shippingMark;
private String internalRemark;
private String customerRemark;
private String warehouseRemark;
private Integer loadSequence;
private LocalDateTime frozenAt;
}
@Getter
@Setter
class HistoryRow {
private Long historyId;
private Long itemId;
private String operationId;
private String actionType;
private String fromStatus;
private String toStatus;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
private String operationReason;
private LocalDateTime occurredAt;
}
@Getter
@Setter
class CustomerRow {
private Long planId;
private Long accountId;
private String customerName;
}
record HistoryWrite(long id, long planId, String operationId, String actionType,
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
}
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface LoadingPickHistoryMapper extends BaseMapper<LoadingPickHistoryEntity> {
}
@@ -0,0 +1,111 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE pick_item.pick_task_id = #{taskId} AND booking.warehouse_id = #{warehouseId}
AND plan_item.deleted = 0
ORDER BY pick_item.id ASC
""")
java.util.List<LoadingPickItemEntity> selectTaskItemsWithAccount(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pick_item.*
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE pick_item.pick_task_id = #{taskId} AND pick_item.id = #{itemId}
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0
""")
LoadingPickItemEntity selectTaskItemWithAccount(@Param("taskId") long taskId,
@Param("itemId") long itemId,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET item_status = CASE WHEN picked_package_count + #{count} = planned_package_count
THEN 'PICKED' ELSE 'PICKING' END,
picked_package_count = picked_package_count + #{count},
version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND picked_package_count + #{count} <= planned_package_count
""")
int pick(@Param("id") long id, @Param("count") int count, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET picked_package_count = #{finalCount}, item_status = 'PARTIAL', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count &lt; #{finalCount}
AND #{finalCount} &lt; planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount, @Param("version") int version,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_pick_item
SET item_status = 'SKIPPED', version = version + 1
WHERE id = #{id} AND version = #{version}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_plan plan ON plan.id = plan_item.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND booking.warehouse_id = #{warehouseId} AND plan_item.deleted = 0)
AND item_status = 'PENDING' AND picked_package_count = 0
""")
int skip(@Param("id") long id, @Param("version") int version, @Param("warehouseId") long warehouseId);
@Select("""
SELECT COUNT(*)
FROM fl_loading_pick_item
WHERE pick_task_id = #{taskId}
AND item_status NOT IN ('PICKED', 'PARTIAL', 'SKIPPED')
""")
long unresolvedCount(@Param("taskId") long taskId);
/**
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
*/
@Select("""
SELECT COUNT(*)
FROM fl_loading_plan_item plan_item
WHERE plan_item.loading_plan_id = #{planId}
AND plan_item.deleted = 0
AND NOT EXISTS (
SELECT 1
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND pick_item.picked_package_count = plan_item.package_count
)
""")
long unfinishedCount(@Param("planId") long planId);
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
@Select("""
SELECT DISTINCT task.*
FROM fl_loading_pick_task task
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE task.container_no = #{containerNo} AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
@Param("warehouseId") long warehouseId);
}
@@ -0,0 +1,3 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface LoadingPlanHistoryMapper extends BaseMapper<LoadingPlanHistoryEntity> {}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface LoadingPlanItemMapper extends BaseMapper<LoadingPlanItemEntity> {
@Update("""
UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName},
removed_at=CURRENT_TIMESTAMP(3),updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{itemId} AND loading_plan_id=#{planId} AND deleted=0 AND frozen_at IS NULL
AND version=#{expectedVersion}
""")
int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface LoadingPlanMapper extends BaseMapper<LoadingPlanEntity> {
@Update("""
UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion}
""")
int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan SET plan_status='LOADING',push_idempotency_key=#{key},
preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3),
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{planId} AND plan_status='EDITING' AND version=#{expectedVersion}
""")
int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -0,0 +1,69 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
@Mapper public interface SmartLoadingCandidateMapper {
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
+COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
""")
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='INBOUNDED'
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@Repository
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
public PageResult<ContainerBookingEntity> page(Query query) {
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
}
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getContainerNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public boolean cabinetNoExists(String no, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getCabinetNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
}
}
@@ -209,7 +209,7 @@ public class FbaPricingRepository {
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if (!"WAIT_SPLIT".equals(processStatus)) {
if (!"INBOUNDED".equals(processStatus)) {
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
}
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
@@ -229,7 +229,7 @@ public class FbaPricingRepository {
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "INBOUNDED"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -21,8 +22,7 @@ public class InboundPalletAllocationRepository {
@Transactional
public void replace(long tenantId, long inboundId, long warehouseId, long warehouseResourceTenantId,
BigDecimal capacity,
BatchResult result) {
BigDecimal capacity, int totalPackageCount, BatchResult result) {
Map<Long, String> reservationNos = mapper.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId))
@@ -32,7 +32,11 @@ public class InboundPalletAllocationRepository {
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
LocalDateTime now = LocalDateTime.now();
result.allocations().forEach(source -> {
int allocationCount = result.allocations().size();
int perAllocation = allocationCount == 0 ? 0
: (int) Math.ceil((double) totalPackageCount / allocationCount);
int remaining = totalPackageCount;
for (var source : result.allocations()) {
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
@@ -43,6 +47,10 @@ public class InboundPalletAllocationRepository {
value.setPalletCode(source.palletCode());
value.setPalletCapacityCbm(capacity);
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
int allocatedPackages = Math.min(perAllocation, remaining);
value.setAllocatedPackageCount(allocatedPackages);
value.setPickedPackageCount(0);
remaining -= allocatedPackages;
value.setLocationId(source.locationId());
value.setLocationCode(source.locationCode());
// 仓储确认响应只返回 reservationId;沿用预占阶段的预占单号快照。
@@ -52,7 +60,17 @@ public class InboundPalletAllocationRepository {
value.setCreatedAt(now);
value.setUpdatedAt(now);
mapper.insert(value);
});
}
}
public List<InboundPalletAllocationEntity> lockConfirmed(long tenantId, long inboundId) {
return mapper.selectConfirmedForUpdate(tenantId, inboundId);
}
public void consume(long tenantId, long inboundId, long allocationId, int packageCount) {
if (mapper.consume(allocationId, tenantId, inboundId, packageCount) != 1) {
throw new IllegalStateException("库位箱数已被并发操作修改");
}
}
private static String normalize(String state) {
@@ -0,0 +1,150 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.Customer;
import com.baiyue.firstleg.dto.LoadingListDtos.History;
import com.baiyue.firstleg.dto.LoadingListDtos.Item;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryWrite;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Repository
public class LoadingListRepository {
@Resource
private LoadingListQueryMapper mapper;
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
Page<AggregateRow> page = mapper.selectPreloadPage(new Page<>(query.page(), query.size()), query);
Map<Long, List<Customer>> customers = customerMap(page.getRecords());
List<PreloadSummary> records = page.getRecords().stream()
.map(row -> preload(row, customers.get(row.getPlanId())))
.toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
Page<AggregateRow> page = mapper.selectConfirmedPage(new Page<>(query.page(), query.size()), query);
List<ConfirmedSummary> records = page.getRecords().stream().map(this::confirmed).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public AggregateRow aggregate(long planId) {
return mapper.selectAggregate(planId);
}
public PreloadDetail preloadDetail(AggregateRow row) {
List<ItemRow> items = mapper.selectItems(row.getPlanId());
List<Customer> customers = items.stream()
.collect(Collectors.toMap(ItemRow::getAccountId, ItemRow::getCustomerName,
(left, right) -> left, java.util.LinkedHashMap::new))
.entrySet().stream().map(entry -> new Customer(entry.getKey(), entry.getValue())).toList();
return new PreloadDetail(preload(row, customers), items(items), histories(row.getPlanId()));
}
public ConfirmedDetail confirmedDetail(AggregateRow row) {
return new ConfirmedDetail(confirmed(row), items(mapper.selectItems(row.getPlanId())),
histories(row.getPlanId()));
}
public HistoryRow history(String operationId) {
return mapper.selectHistoryByOperation(operationId);
}
public int updateTransport(long planId, int expectedVersion, String containerNo, String voyageNo,
long operatorId, String operatorName) {
if (mapper.touchEditable(planId, expectedVersion, operatorId, operatorName) != 1) {
return 0;
}
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public int advance(long planId, String fromStatus, String toStatus, int expectedVersion,
long operatorId, String operatorName) {
return mapper.advance(planId, fromStatus, toStatus, expectedVersion, operatorId, operatorName);
}
public void appendHistory(long planId, String operationId, String actionType,
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName,
operationReason));
}
private Map<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
List<Long> ids = rows.stream().map(AggregateRow::getPlanId).toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.selectCustomers(ids).stream().collect(Collectors.groupingBy(
CustomerRow::getPlanId, java.util.LinkedHashMap::new,
Collectors.mapping(row -> new Customer(row.getAccountId(), row.getCustomerName()),
Collectors.toList())));
}
private PreloadSummary preload(AggregateRow row, List<Customer> customers) {
return new PreloadSummary(row.getPlanId(), row.getPlanNo(), row.getContainerNo(), row.getCabinetNo(),
customers, row.getWarehouseId(), row.getWarehouseName(), row.getVesselName(), row.getVoyageNo(),
row.getContainerType(), row.getPlanStatus(), value(row.getSkuCount()),
value(row.getTotalPackageCount()), decimal(row.getTotalVolumeCbm()),
decimal(row.getTotalGrossWeightKg()), row.getCreatedAt(), row.getVersion());
}
private ConfirmedSummary confirmed(AggregateRow row) {
return new ConfirmedSummary(row.getPlanId(), row.getPlanNo(), row.getWarehouseId(), row.getWarehouseName(),
row.getContainerNo(), row.getCabinetNo(), row.getPlanStatus(), value(row.getTotalPackageCount()),
decimal(row.getTotalGrossWeightKg()), decimal(row.getTotalNetWeightKg()),
decimal(row.getTotalVolumeCbm()), value(row.getTotalQuantity()), row.getRemark(),
row.getCreatedBy(), row.getCreatedByName(), row.getCreatedAt(), row.getVersion());
}
private List<Item> items(List<ItemRow> rows) {
return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(),
row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()),
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(),
integer(row.getLoadSequence()), row.getFrozenAt())).toList();
}
private List<History> histories(long planId) {
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
row.getOccurredAt())).toList();
}
private static long value(Long value) {
return value == null ? 0 : value;
}
private static int integer(Integer value) {
return value == null ? 0 : value;
}
private static BigDecimal decimal(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
}
@@ -0,0 +1,114 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.LoadingPickHistoryMapper;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** PDA 拣货任务持久化边界。 */
@Repository
public class LoadingPickRepository {
@Resource
private LoadingPickTaskMapper tasks;
@Resource
private LoadingPickItemMapper items;
@Resource
private LoadingPickHistoryMapper histories;
public LoadingPickTaskEntity task(long taskId) {
return tasks.selectById(taskId);
}
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
}
public LoadingPickTaskEntity taskByContainer(String containerNo) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getContainerNo, containerNo)
.eq(LoadingPickTaskEntity::getTaskStatus, "LOADING"));
}
public LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) {
return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId);
}
public LoadingPickItemEntity item(long taskId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.eq(LoadingPickItemEntity::getId, itemId));
}
public List<LoadingPickItemEntity> items(long taskId) {
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickItemEntity::getId));
}
public List<LoadingPickItemEntity> items(long taskId, long warehouseId) {
return items.selectTaskItemsWithAccount(taskId, warehouseId);
}
public LoadingPickItemEntity item(long taskId, long itemId, long warehouseId) {
return items.selectTaskItemWithAccount(taskId, itemId, warehouseId);
}
public void insert(LoadingPickTaskEntity task) {
tasks.insert(task);
}
public void insert(LoadingPickItemEntity item) {
items.insert(item);
}
public LoadingPickItemEntity itemByPlanItem(long planItemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getLoadingPlanItemId, planItemId));
}
public int pick(long itemId, int count, int version, long warehouseId) {
return items.pick(itemId, count, version, warehouseId);
}
public int partial(long itemId, int finalCount, int version, long warehouseId) {
return items.partial(itemId, finalCount, version, warehouseId);
}
public int skip(long itemId, int version, long warehouseId) {
return items.skip(itemId, version, warehouseId);
}
public boolean allTaskItemsResolved(long taskId) {
return items.unresolvedCount(taskId) == 0;
}
public boolean allPlanItemsPicked(long planId) {
return items.unfinishedCount(planId) == 0;
}
public LoadingPickHistoryEntity history(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPickHistoryEntity>()
.eq(LoadingPickHistoryEntity::getOperationId, operationId));
}
public void insert(LoadingPickHistoryEntity history) {
histories.insert(history);
}
public void update(LoadingPickItemEntity item) {
items.updateById(item);
}
public void deleteItem(long itemId) {
items.deleteById(itemId);
}
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.List;
/** 智能装柜聚合的真实 MySQL 持久化边界。 */
@Repository
@ConditionalOnProperty(name="firstleg.smart-loading.repository.enabled", havingValue="true", matchIfMissing=true)
public class SmartLoadingRepository {
@Resource private LoadingPlanMapper plans;
@Resource private LoadingPlanItemMapper items;
@Resource private LoadingPlanHistoryMapper histories;
@Resource private ContainerBookingMapper bookings;
@Resource private SmartLoadingCandidateMapper candidates;
public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public Candidate candidate(long warehouseId, long inboundId, long detailId) {
return candidates.candidate(warehouseId, inboundId, detailId);
}
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
.eq(LoadingPlanEntity::getContainerBookingId, bookingId));
}
public ContainerBookingEntity booking(long bookingId) { return bookings.selectById(bookingId); }
public List<LoadingPlanItemEntity> items(long planId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getDeleted, false)
.orderByAsc(LoadingPlanItemEntity::getLoadSequence).orderByAsc(LoadingPlanItemEntity::getId));
}
public LoadingPlanItemEntity item(long planId, long itemId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getId, itemId).eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanItemEntity activeItem(long planId, long accountId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getAccountId, accountId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanHistoryEntity operation(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
.eq(LoadingPlanHistoryEntity::getOperationId, operationId));
}
public void insert(LoadingPlanEntity value) { plans.insert(value); }
public void insert(LoadingPlanItemEntity value) { items.insert(value); }
public void insert(LoadingPlanHistoryEntity value) { histories.insert(value); }
public int update(LoadingPlanItemEntity value) { return items.updateById(value); }
public int touch(long planId, int version, long operatorId, String operatorName) {
return plans.touchEditing(planId, version, operatorId, operatorName);
}
public int remove(long planId, long itemId, int version, long operatorId, String operatorName) {
return items.removeEditing(planId, itemId, version, operatorId, operatorName);
}
public int push(long planId, String key, int version, long operatorId, String operatorName) {
return plans.pushPreload(planId, key, version, operatorId, operatorName);
}
}
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
import com.baiyue.firstleg.split.mapper.PreloadPlanMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
@Repository
public class UnifiedInboundReadRepository {
@Resource private PreloadPlanMapper preloads;
@Resource private LoadingPickTaskMapper picks;
@Resource private InboundLoadingPickTaskMapper picks;
@Resource private LoadingEvidenceMapper evidences;
@Resource private InboundStatusHistoryMapper histories;
@Resource private InboundTransportSnapshotMapper transports;
@@ -0,0 +1,169 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class ContainerService {
@Resource private ContainerRepository repository;
@Resource private WarehouseMasterDataClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
return repository.page(normalize(query));
}
public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
validateSchedule(request);
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId());
copy(value, request);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
value.setPlanStatus(SmartLoadingStatus.EDITING.name());
return value;
}
@Transactional
public ContainerBookingEntity updateDraft(long id, long operatorId, String operatorName,
FullUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) != SmartLoadingStatus.EDITING) {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
}
validateSchedule(request.data());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
try {
if (repository.updateDraft(current, request.version(), operatorId, operatorName) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
} catch (DuplicateKeyException ex) { throw duplicate(); }
return required(id);
}
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
TransportUpdateRequest request) {
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
String containerNo = trim(request.containerNo());
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
return required(id);
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private void ensureUnique(String containerNo, String cabinetNo, Long excludedId) {
if (repository.containerNoExists(containerNo, excludedId)) throw conflict("集装箱号已存在");
if (repository.cabinetNoExists(cabinetNo, excludedId)) throw conflict("内部柜号已存在");
}
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
internalToken, publicWarehouseTenantId, warehouseId);
if (response == null || !response.successful() || response.data() == null
|| response.data().warehouse() == null) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id())
|| !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status())) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
if (!"DOMESTIC".equals(warehouse.warehouseType()) || warehouse.warehouseName() == null
|| warehouse.warehouseName().isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "虚拟柜只能选择启用中的国内中转仓");
}
return warehouse;
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
}
}
private static void requireVersion(ContainerBookingEntity value, Integer version) {
if (!version.equals(value.getVersion())) throw conflict("货柜已被其他人修改,请刷新后重试");
}
private static SmartLoadingStatus status(ContainerBookingEntity value) {
try { return SmartLoadingStatus.valueOf(value.getPlanStatus()); }
catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); }
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName()));
v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName()));
v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode()));
v.setDestinationWarehouseName(trim(r.destinationWarehouseName()));
v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg());
v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt());
v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt());
v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt());
v.setEstimatedWarehouseArrivalAt(r.estimatedWarehouseArrivalAt());
v.setActualWarehouseArrivalAt(r.actualWarehouseArrivalAt());
}
private static void validateSchedule(SaveRequest r) {
validateRange(r.estimatedSailingAt(), r.estimatedPortArrivalAt(), "预计船期和预计到港时间");
validateRange(r.estimatedPortArrivalAt(), r.estimatedWarehouseArrivalAt(), "预计到港和预计到库时间");
validateRange(r.actualSailingAt(), r.actualPortArrivalAt(), "实际船期和实际到港时间");
validateRange(r.actualPortArrivalAt(), r.actualWarehouseArrivalAt(), "实际到港和实际到库时间");
}
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
if (from != null && to != null && to.isBefore(from))
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
}
private static Query normalize(Query q) {
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
q.estimatedSailingTo(), q.page(), q.size());
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim(); return result.isEmpty() ? null : result;
}
private static BizException duplicate() { return conflict("集装箱号或内部柜号已存在"); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
@@ -81,9 +81,9 @@ public class FbaPricingService {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus())
? "WAIT_SPLIT" : "FEE_CONFIRMING";
return response(inbound, fee, estimate.unitPrice(), nextStatus);
String nextStatus = "INBOUNDED".equals(inbound.getProcessStatus())
? "INBOUNDED" : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
}
@Transactional
@@ -223,7 +223,7 @@ public class FbaPricingService {
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus())
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDED").contains(inbound.getProcessStatus())
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
throw conflict("当前入库单状态不允许费用试算");
}
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
@@ -32,9 +33,13 @@ import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@Service
public class InboundMeasurementService {
/** 测量批次已提交,等待测量审核。 */
private static final String STATUS_SUBMITTED = "SUBMITTED";
/** OCR 或人工修正的重量已确认,可参与计费。 */
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
/** OCR 已识别到有效重量,等待人工确认。 */
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
@@ -47,9 +52,11 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
private MeasurementImageClient imageClient;
@Value("${baiyue.security.internal-service-token}")
private OrderCargoClient orderCargoClient;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
@Resource
private MeasurementImageClient imageClient;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -66,13 +73,14 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items());
MeasurementBatchCommand command = new MeasurementBatchCommand(
tenantId, inboundId, request.measurementId(), request.version(),
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
LocalDateTime.now(), null, request.originalDocumentNo(),
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.remark())).toList());
@@ -354,7 +362,7 @@ public class InboundMeasurementService {
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
return new InboundMeasurementResponses.Item(
item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
@@ -397,6 +405,27 @@ public class InboundMeasurementService {
return inbound;
}
private void validateInboundDetails(long tenantId, InboundOrderEntity inbound,
List<InboundMeasurementRequests.ItemRequest> items) {
if (inbound.getOrderId() == null || inbound.getOrderId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未关联有效订单,不能保存测量明细");
}
for (InboundMeasurementRequests.ItemRequest item : items) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(
internalToken, tenantId, inbound.getOrderId(), item.inboundDetailId());
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "订单货物明细校验失败" : response.message();
throw new BizException(ResultCode.of(response == null
? ResultCode.SERVICE_UNAVAILABLE.code() : response.code()), message);
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法校验入库明细");
}
}
}
private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) {
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
if (measurement == null) {
@@ -0,0 +1,242 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@Service
public class LoadingListService {
private static final Set<String> LOADING_STATUSES = Set.of("LOADING");
@Resource
private LoadingListRepository repository;
@Resource
private LoadingPickRepository pickRepository;
@Resource
private ObjectMapper objectMapper;
public PageResult<PreloadSummary> preloadPage(PreloadQuery query) {
validateRange(query.createdFrom(), query.createdTo());
String status = trim(query.status());
if (StringUtils.hasText(status) && !LOADING_STATUSES.contains(status)) {
throw badRequest("装柜清单状态只能是 LOADING");
}
return repository.preloadPage(new PreloadQuery(trim(query.keyword()), status,
query.warehouseId(), query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PageResult<ConfirmedSummary> confirmedPage(ConfirmedQuery query) {
validateRange(query.createdFrom(), query.createdTo());
return repository.confirmedPage(new ConfirmedQuery(trim(query.keyword()), query.warehouseId(),
query.createdFrom(), query.createdTo(), query.page(), query.size()));
}
public PreloadDetail preloadDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw notFound("装柜中清单不存在");
}
return repository.preloadDetail(row);
}
public ConfirmedDetail confirmedDetail(long planId, long warehouseId) {
AggregateRow row = requirePlan(planId);
requireWarehouse(row, warehouseId);
if (!"COMPLETED".equals(row.getPlanStatus())) {
throw notFound("装柜清单不存在");
}
return repository.confirmedDetail(row);
}
@Transactional
@BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改预装集装箱号与航次")
public PreloadDetail updateTransport(long planId, long warehouseId, long operatorId, String operatorName,
String idempotencyKey, TransportUpdateRequest request) {
String containerNo = nullableTrim(request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + planId + '|' + request.expectedVersion()
+ '|' + containerNo + '|' + voyageNo + '|' + trim(request.operationReason()));
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
requireSameRequest(replay, requestHash);
return repository.preloadDetail(before);
}
requireLoadingMutable(before);
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
json(values("requestHash", requestHash, "status", before.getPlanStatus(),
"containerNo", containerNo, "voyageNo", voyageNo,
"version", request.expectedVersion() + 1)), operatorId, trim(operatorName),
trim(request.operationReason()));
return repository.preloadDetail(requirePlan(planId));
}
/**
* 最后一箱 PDA 拣货完成后自动调用。服务与数据库触发器均以计划全部有效明细为口径复核。
*/
@Transactional
@BusinessAudit(event = "PDA_COMPLETE_LOADING", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "PDA拣货完成后自动生成装柜清单")
public void finishFromPda(long planId, long pickTaskId, long operatorId, String operatorName) {
String operationId = "PDA-COMPLETE-" + planId + '-' + pickTaskId;
if (repository.history(operationId) != null) {
return;
}
if (!pickRepository.allPlanItemsPicked(planId)) {
throw conflict("计划仍存在未完成PDA拣货的箱数,不能生成装柜清单");
}
AggregateRow before = requirePlan(planId);
if ("COMPLETED".equals(before.getPlanStatus())) {
return;
}
if (!"LOADING".equals(before.getPlanStatus())) {
throw conflict("当前状态为 " + before.getPlanStatus() + ",不能由PDA完成装柜");
}
if (repository.advance(planId, "LOADING", "COMPLETED", before.getVersion(),
operatorId, trim(operatorName)) != 1) {
AggregateRow current = requirePlan(planId);
if ("COMPLETED".equals(current.getPlanStatus())) {
return;
}
throw conflict("装柜计划已被其他操作修改,请刷新后重试");
}
repository.appendHistory(planId, operationId, "PDA_COMPLETE_LOADING",
"LOADING", "COMPLETED", json(snapshot(before)),
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"全部箱数已完成PDA拣货");
}
private AggregateRow requirePlan(long planId) {
AggregateRow row = repository.aggregate(planId);
if (row == null) {
throw notFound("装柜计划不存在");
}
return row;
}
private static void requireWarehouse(AggregateRow row, long warehouseId) {
if (warehouseId <= 0) {
throw badRequest("warehouseId必须大于0");
}
if (!java.util.Objects.equals(row.getWarehouseId(), warehouseId)) {
throw notFound("装柜计划不属于当前仓库");
}
}
private static void requireLoadingMutable(AggregateRow row) {
if (!LOADING_STATUSES.contains(row.getPlanStatus())) {
throw conflict("仅装柜中可修改集装箱号与航次");
}
}
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
if (from != null && to != null && from.isAfter(to)) {
throw badRequest("创建开始时间不能晚于结束时间");
}
}
private String operationId(String operation, long planId, String key) {
String normalized = trim(key);
if (!StringUtils.hasText(normalized)) {
throw badRequest("必须提供 Idempotency-Key");
}
if (normalized.length() > 128) {
throw badRequest("Idempotency-Key 长度不能超过128");
}
return "PL-" + sha256(operation + '|' + planId + '|' + normalized);
}
private static void requireSameRequest(HistoryRow replay, String requestHash) {
String snapshot = replay.getAfterSnapshot();
if (snapshot == null || !snapshot.contains("\"requestHash\":\"" + requestHash + "\"")) {
throw conflict("Idempotency-Key 已用于不同请求");
}
}
private Map<String, Object> snapshot(AggregateRow row) {
return values("status", row.getPlanStatus(), "containerNo", row.getContainerNo(),
"voyageNo", row.getVoyageNo(), "version", row.getVersion());
}
private static Map<String, Object> values(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
result.put((String) values[index], values[index + 1]);
}
return result;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法生成装柜审计快照", exception);
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
private static String nullableTrim(String value) {
String trimmed = trim(value);
return StringUtils.hasText(trimmed) ? trimmed : null;
}
private static String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException badRequest(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
private static BizException notFound(String message) {
return new BizException(ResultCode.NOT_FOUND, message);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,270 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Resource
private LoadingListService loadingListService;
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) {
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
task.setContainerNo(containerNo);
task.setTaskStatus("LOADING");
task.setStartedBy(operatorId);
task.setStartedByName(operatorName);
task.setVersion(0);
try {
repository.insert(task);
} catch (DuplicateKeyException exception) {
task = repository.taskByPlan(planId);
}
}
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续同步明细");
}
for (LoadingPlanItemEntity planItem : planItems) {
syncItem(task.getId(), planItem);
}
}
@Transactional
public void sync(long planId, LoadingPlanItemEntity planItem) {
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task != null && "LOADING".equals(task.getTaskStatus())) {
syncItem(task.getId(), planItem);
}
}
public Task task(String containerNo, long warehouseId) {
if (!StringUtils.hasText(containerNo)) {
throw new BizException(ResultCode.BAD_REQUEST, "集装箱号不能为空");
}
if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
LoadingPickTaskEntity task = repository.taskByContainer(containerNo.trim(), warehouseId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
}
return view(task, warehouseId);
}
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId 已用于不同拣货请求");
}
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
LoadingPickItemEntity item = repository.item(taskId, itemId, request.warehouseId());
if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
}
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化");
}
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setActionType("PICK");
history.setPickedPackageCount(request.pickedPackageCount());
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
}
return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount());
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
String operationId = request.operationId().trim();
String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + reason);
LoadingPickHistoryEntity replay = repository.history(operationId);
if (replay != null) {
assertReplay(replay, taskId, itemId, requestHash);
return view(requiredTask(taskId), request.warehouseId());
}
requiredLoadingTask(taskId);
requireWarehouseItem(taskId, itemId, request.warehouseId());
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
operatorId, operatorName);
return view(requiredTask(taskId), request.warehouseId());
}
@Transactional
public void remove(long planItemId) {
LoadingPickItemEntity item = repository.itemByPlanItem(planItemId);
if (item == null) {
return;
}
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
throw conflict("已有拣货记录的明细不能删除");
}
repository.deleteItem(item.getId());
}
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId());
if (item == null) {
item = new LoadingPickItemEntity();
item.setPickTaskId(taskId);
item.setLoadingPlanItemId(planItem.getId());
item.setInboundId(planItem.getInboundId());
item.setInboundDetailId(planItem.getInboundDetailId());
item.setPlannedPackageCount(planItem.getPackageCount());
item.setPickedPackageCount(0);
item.setItemStatus("PENDING");
item.setVersion(0);
repository.insert(item);
return;
}
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
throw conflict("已拣货箱数不能低于计划箱数");
}
item.setPlannedPackageCount(planItem.getPackageCount());
item.setItemStatus(item.getPickedPackageCount().equals(planItem.getPackageCount()) ? "PICKED" : "PICKING");
repository.update(item);
}
private LoadingPickTaskEntity requiredTask(long taskId) {
LoadingPickTaskEntity task = repository.task(taskId);
if (task == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
}
return task;
}
private LoadingPickTaskEntity requiredLoadingTask(long taskId) {
LoadingPickTaskEntity task = requiredTask(taskId);
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务已完成,不能继续拣货");
}
return task;
}
private void requireWarehouseItem(long taskId, long itemId, long warehouseId) {
if (repository.item(taskId, itemId, warehouseId) == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前仓库");
}
}
private Task view(LoadingPickTaskEntity task, long warehouseId) {
List<PickItem> items = repository.items(task.getId(), warehouseId).stream()
.map(item -> new PickItem(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), item.getPlannedPackageCount(), item.getPickedPackageCount(),
item.getItemStatus(), item.getVersion())).toList();
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerNo(), task.getTaskStatus(),
task.getVersion(), items);
}
private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
String actionType, int pickedPackageCount, String reason,
long operatorId, String operatorName) {
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(taskId);
history.setPickItemId(itemId);
history.setOperationId(operationId);
history.setRequestHash(requestHash);
history.setActionType(actionType);
history.setPickedPackageCount(pickedPackageCount);
history.setOperationReason(reason);
history.setOperatorId(operatorId);
history.setOperatorName(operatorName);
repository.insert(history);
}
private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId,
String requestHash) {
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|| !replay.getRequestHash().equals(requestHash)) {
throw conflict("operationId已用于不同拣货请求");
}
}
private static String hash(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("JVM 不支持 SHA-256", exception);
}
}
private static String trim(String value) {
return value == null ? null : value.trim();
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -26,9 +26,13 @@ import java.util.Set;
*/
@Service
public class MeasurementBatchService {
/** 后台人工录入的测量来源。 */
static final String SOURCE_MANUAL = "MANUAL";
/** 第三方设备或开放接口回传的测量来源。 */
static final String SOURCE_THIRD_PARTY = "THIRD_PARTY";
/** 测量草稿,可继续编辑。 */
static final String STATUS_DRAFT = "DRAFT";
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
@@ -136,6 +140,7 @@ public class MeasurementBatchService {
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setInboundDetailId(source.inboundDetailId());
item.setItemNo(source.itemNo());
item.setBoxSpecification(source.boxSpecification());
item.setPackageCount(source.packageCount());
@@ -45,15 +45,16 @@ public class MeasurementLocationOccupationService {
properties.getDefaultPalletCapacityCbm(), "STANDARD",
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
"测量完成后库位预占失败");
int packageCount = requirePackageCount(order);
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), reserved);
properties.getDefaultPalletCapacityCbm(), packageCount, reserved);
try {
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
internalToken, new WarehousePalletLocationClient.ConfirmRequest(resourceTenantId, warehouseId,
SOURCE_SERVICE, inboundId, operatorId, volume,
properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败");
allocations.replace(tenantId, inboundId, warehouseId, resourceTenantId,
properties.getDefaultPalletCapacityCbm(), occupied);
properties.getDefaultPalletCapacityCbm(), packageCount, occupied);
} catch (RuntimeException failure) {
try {
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
@@ -85,6 +86,13 @@ public class MeasurementLocationOccupationService {
return order.getActualVolumeCbm();
}
private static int requirePackageCount(InboundOrderEntity order) {
if (order.getTotalPackageCount() == null || order.getTotalPackageCount() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效总箱数");
}
return order.getTotalPackageCount();
}
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
try { return call.invoke(); }
catch (FeignException exception) {
@@ -20,7 +20,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 WAIT_SPLIT;
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED;
* 计费、库位和其他资料不作为本版本的强制门禁。
*/
@Service
@@ -91,7 +91,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
approved ? "WAIT_SPLIT" : "MEASURE_REJECTED",
approved ? "INBOUNDED" : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
idempotency.complete(context, inboundId);
}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** PDA任务结束时的纯结算规则,不包含数据库写入。 */
final class PdaPickSettlementPlan {
private PdaPickSettlementPlan() {
}
static Resolution resolve(List<LoadingPickItemEntity> items) {
if (items == null || items.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "PDA任务没有可结算的拣货明细");
}
for (LoadingPickItemEntity item : items) {
if (item == null || !isFinal(item.getItemStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA任务仍存在未结束的拣货明细");
}
}
Map<Long, List<SplitLine>> partialByInbound = items.stream()
.filter(item -> "PARTIAL".equals(item.getItemStatus()))
.map(PdaPickSettlementPlan::splitLine)
.collect(Collectors.groupingBy(SplitLine::inboundId));
List<Long> skippedPlanItemIds = items.stream()
.filter(item -> "SKIPPED".equals(item.getItemStatus()))
.map(LoadingPickItemEntity::getLoadingPlanItemId)
.toList();
return new Resolution(partialByInbound, skippedPlanItemIds);
}
private static SplitLine splitLine(LoadingPickItemEntity item) {
int planned = requireCount(item.getPlannedPackageCount(), "计划箱数");
int picked = requireCount(item.getPickedPackageCount(), "实拣箱数");
if (picked <= 0 || picked >= planned || item.getInboundId() == null
|| item.getInboundDetailId() == null || item.getId() == null
|| item.getLoadingPlanItemId() == null) {
throw new BizException(ResultCode.CONFLICT, "部分拣货结算数据不完整或箱数不合法");
}
return new SplitLine(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
item.getInboundDetailId(), picked, planned - picked);
}
private static int requireCount(Integer value, String field) {
if (value == null || value < 0) {
throw new BizException(ResultCode.CONFLICT, field + "不合法");
}
return value;
}
private static boolean isFinal(String status) {
return "PICKED".equals(status) || "PARTIAL".equals(status) || "SKIPPED".equals(status);
}
record Resolution(Map<Long, List<SplitLine>> partialByInbound, List<Long> skippedPlanItemIds) {
}
record SplitLine(long pickItemId, long loadingPlanItemId, long inboundId, long inboundDetailId,
int pickedPackageCount, int remainingPackageCount) {
}
}
@@ -0,0 +1,379 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.*;
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */
@Service
public class SmartLoadingService {
@Resource private SmartLoadingRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private LoadingPickService loadingPickService;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
public List<Candidate> candidates(long warehouseId) {
if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
}
return repository.candidates(warehouseId);
}
public Plan draftByBooking(long bookingId) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity plan = repository.planByBooking(bookingId);
return draftView(plan, booking);
}
public Plan detail(long planId) {
LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
return view(plan, requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN",
description = "创建智能装柜计划", captureBefore = false)
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0);
try {
repository.insert(plan);
} catch (DuplicateKeyException exception) {
LoadingPlanEntity concurrent = repository.planByBooking(bookingId);
if (concurrent != null) return draftView(concurrent, booking);
throw conflict("装柜计划创建冲突,请刷新后重试");
}
history(plan.getId(), null, "CREATE_PLAN", null, "EDITING", UUID.randomUUID().toString(), operatorId, operatorName);
return view(plan, booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
+ '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_ITEM", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount());
if (total > availableIncludingCurrent) throw validation("累计分配箱数超过可用箱数");
List<LoadingPlanItemEntity> lines = new ArrayList<>(repository.items(planId));
if (existing != null) lines.removeIf(x -> Objects.equals(x.getId(), existing.getId()));
checkRouteAndCapacity(booking, lines, candidate, total);
touch(plan, operatorId, operatorName);
LoadingPlanItemEntity changed;
if (existing == null) {
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1);
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
changed = item;
} else {
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total);
existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName);
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
changed = existing;
}
history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "itemId", changed.getId(),
"packageCount", changed.getPackageCount())), operatorId, operatorName);
loadingPickService.sync(planId, changed);
return view(repository.plan(planId), booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "执行智能装柜推荐")
public Plan recommend(long planId, RecommendRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("RECOMMEND|" + planId + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "SMART_RECOMMEND", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion());
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
for (Candidate candidate : candidates(booking.getWarehouseId())) {
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
int count = fittingBoxes(booking, repository.items(planId), candidate);
if (count > 0) {
plan = repository.plan(planId);
assign(planId, new AssignRequest(candidate.inboundId(), candidate.inboundDetailId(),
count, plan.getVersion(), "REC-" + sha256(operation + '|' + candidate.inboundId()
+ '|' + candidate.inboundDetailId())), operatorId, operatorName);
}
}
LoadingPlanEntity recommended = repository.plan(planId);
history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation,
json(values("version", request.expectedVersion())),
json(values("requestHash", requestHash, "version", recommended.getVersion())),
operatorId, operatorName);
return view(recommended, requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "调整智能装柜明细")
public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|'
+ request.packageCount() + '|' + request.expectedPlanVersion()
+ '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "ADJUST_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(booking, others, candidate, request.packageCount());
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
operatorId, operatorName);
loadingPickService.sync(planId, item);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "删除智能装柜明细")
public Plan delete(long planId, long itemId, DeleteRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("DELETE|" + planId + '|' + itemId + '|'
+ request.expectedPlanVersion() + '|' + request.expectedItemVersion());
Plan replay = replay(operation, planId, "REMOVE_ITEM", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion());
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "推送智能装柜计划到预装清单")
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
String key = trim(idempotencyKey);
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号");
if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空");
loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId));
try {
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
history(planId, null, "START_LOADING", "EDITING", "LOADING", key, operatorId, operatorName);
LoadingPlanEntity pushed = repository.plan(planId);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode());
i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count);
i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
}
private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) {
item.setSkuCode(cargo.skuNo());
item.setProductNameEn(cargo.cargoNameEn());
item.setUnitsPerCarton(cargo.unitsPerPackage());
item.setInternalRemark(cargo.cargoRemark());
int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0
? boxes : cargo.originalPackageCount();
if (cargo.unitsPerPackage() != null) {
item.setProductQuantity(Math.multiplyExact(cargo.unitsPerPackage(), boxes));
} else if (cargo.totalQuantity() != null) {
item.setProductQuantity(BigDecimal.valueOf(cargo.totalQuantity())
.multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact());
}
if (cargo.netWeightKg() != null) {
item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP));
}
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(internalToken,
candidate.accountId(), candidate.orderId(), candidate.inboundDetailId());
if (response == null) {
throw unavailable();
}
if (!response.successful()) {
throw new BizException(ResultCode.of(response.code()), response.message());
}
OrderCargoClient.CargoBinding cargo = response.data();
if (cargo == null || !Objects.equals(cargo.cargoId(), candidate.inboundDetailId())
|| !Objects.equals(cargo.orderId(), candidate.orderId())) {
throw conflict("订单货物明细返回与入库单不匹配");
}
return cargo;
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,int count){
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致");
BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count)));
if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置");
if (v.compareTo(b.getCapacityCbm())>0 || w.compareTo(b.getMaxWeightKg())>0) throw validation("超过货柜容积或重量上限");
}
private int fittingBoxes(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c){
if(b.getCapacityCbm()==null||b.getMaxWeightKg()==null)return 0;
BigDecimal usedV=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal usedW=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add);
BigDecimal leftV=b.getCapacityCbm().subtract(usedV),leftW=b.getMaxWeightKg().subtract(usedW);
int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue();
int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue();
return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW)));
}
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));}
private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) {
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
if (!"EDITING".equals(plan.getPlanStatus())) throw conflict("装柜计划已推送预装,请前往预装清单查看");
return view(plan, booking);
}
private LoadingPlanEntity editing(long id,int version){LoadingPlanEntity p=repository.plan(id);if(p==null)throw new BizException(ResultCode.NOT_FOUND,"装柜计划不存在");if(!Set.of("EDITING", "LOADING").contains(p.getPlanStatus())||!Objects.equals(p.getVersion(),version))throw conflict("计划已完成或版本冲突");return p;}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation);
if (history == null) return null;
if (!Objects.equals(history.getLoadingPlanId(), planId)
|| !Objects.equals(history.getActionType(), action)
|| itemId != null && !Objects.equals(history.getLoadingPlanItemId(), itemId)
|| history.getAfterSnapshot() == null
|| !history.getAfterSnapshot().contains("\"requestHash\":\"" + requestHash + "\"")) {
throw conflict("operationId 已用于不同请求");
}
LoadingPlanEntity plan = repository.plan(planId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
return view(plan, requireBooking(plan.getContainerBookingId()));
}
private void history(long planId, Long itemId, String action, String from, String to,
String operation, long operatorId, String operatorName) {
history(planId, itemId, action, from, to, operation, null, null, operatorId, operatorName);
}
private void history(long planId, Long itemId, String action, String from, String to,
String operation, String before, String after,
long operatorId, String operatorName) {
LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity();
history.setLoadingPlanId(planId); history.setLoadingPlanItemId(itemId);
history.setActionType(action); history.setFromStatus(from); history.setToStatus(to);
history.setOperationId(operation); history.setBeforeSnapshot(before); history.setAfterSnapshot(after);
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
history.setCreatedAt(LocalDateTime.now()); repository.insert(history);
}
private static String operation(String value) {
String normalized = trim(value);
if (!StringUtils.hasText(normalized)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 operationId");
if (normalized.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId 长度不能超过128");
return normalized;
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("无法生成智能装柜审计快照", exception);
}
}
private static Map<String, Object> values(Object... values) {
Map<String, Object> result = new LinkedHashMap<>();
for (int index = 0; index < values.length; index += 2) {
result.put((String) values[index], values[index + 1]);
}
return result;
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
}
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用");
}
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
}
@@ -24,11 +24,11 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/** Read-only adapter from internal inbound states to the seven product main statuses. */
/** Read-only adapter from internal inbound states to the product main statuses. */
@Service
public class UnifiedInboundQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
@Resource private InboundOrderRepository inbounds;
@@ -120,8 +120,8 @@ public class UnifiedInboundQueryService {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
case "INBOUNDED" -> "INBOUNDED";
// WAIT_SPLIT is retained only to normalize records created before status unification.
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
@@ -132,7 +132,7 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null
case "INBOUNDED" -> !List.of("INBOUNDED", "WAIT_SPLIT").contains(processStatus) ? null
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "LOADING" -> "COMPLETE_LOADING";
@@ -146,8 +146,8 @@ public class UnifiedInboundQueryService {
if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED");
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__");
};
}
@@ -4,8 +4,11 @@ import java.util.Set;
/** 预装清单子状态,固定为三态。 */
public final class PreloadStatus {
/** 预装清单草稿,尚未开始确认。 */
public static final String PENDING = "PENDING";
/** 正在确认预装明细。 */
public static final String CONFIRMING = "CONFIRMING";
/** 预装清单已确认,可进入装柜作业。 */
public static final String CONFIRMED = "CONFIRMED";
public static final Set<String> VALUES = Set.of(PENDING, CONFIRMING, CONFIRMED);
private PreloadStatus() {}
@@ -13,7 +13,7 @@ public interface SplitInboundMapper {
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
+ "AND process_status='INBOUNDED' AND version=#{expectedVersion}")
int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion);
@@ -42,7 +42,7 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, SMART_SPLIT, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
if (!"WAIT_SPLIT".equals(inbound.processStatus())) {
if (!"INBOUNDED".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以生成预装清单");
}
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
@@ -67,7 +67,7 @@ public class SplitPreloadService {
}
PreloadPlanResponse result = response(repository.findPlan(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, SMART_SPLIT, key,
new SplitActionResponse("WAIT_SPLIT", result));
new SplitActionResponse("INBOUNDED", result));
}
@Transactional
@@ -77,11 +77,11 @@ public class SplitPreloadService {
SplitActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, START_CONFIRMING, key);
if (replay != null) return replay;
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以提交预装清单确认");
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以提交预装清单确认");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMING.equals(plan.getStatus())) {
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(plan)));
new SplitActionResponse("INBOUNDED", response(plan)));
}
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|| repository.changePlanStatus(tenantId, inboundId, PreloadStatus.PENDING,
@@ -89,7 +89,7 @@ public class SplitPreloadService {
throw concurrent();
}
return complete(tenantId, inboundId, operatorId, START_CONFIRMING, key,
new SplitActionResponse("WAIT_SPLIT", response(repository.findPlan(tenantId, inboundId))));
new SplitActionResponse("INBOUNDED", response(repository.findPlan(tenantId, inboundId))));
}
@Transactional
@@ -103,7 +103,7 @@ public class SplitPreloadService {
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
}
requireStatus(inbound, "WAIT_SPLIT", "仅待分柜订单可以确认预装清单");
requireStatus(inbound, "INBOUNDED", "仅待分柜订单可以确认预装清单");
PreloadPlanEntity plan = requirePlan(tenantId, inboundId);
if (PreloadStatus.CONFIRMED.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装清单已确认但订单状态尚未推进,请刷新重试");
@@ -115,7 +115,7 @@ public class SplitPreloadService {
if (repository.beginLoading(tenantId, inboundId, operatorId, inbound.version()) != 1) {
throw concurrent();
}
addHistory(tenantId, inboundId, "WAIT_SPLIT", "LOADING", CONFIRM_PRELOAD,
addHistory(tenantId, inboundId, "INBOUNDED", "LOADING", CONFIRM_PRELOAD,
operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, CONFIRM_PRELOAD, key,
new SplitActionResponse("LOADING", response(repository.findPlan(tenantId, inboundId))));
@@ -33,6 +33,8 @@ baiyue:
url: ${ORDER_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
warehouse:
url: ${WAREHOUSE_SERVICE_URL:}
print:
url: ${PRINT_SERVICE_URL:}
firstleg:
@@ -0,0 +1,585 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/*
* 三期主子入仓单结构迁移。
* 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。
* tenant_id 继续表示客户隔离键;数量统一使用 package_count。
*/
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_column(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_column(
'fl_inbound_order', 'inbound_role',
"VARCHAR(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单' AFTER inbound_code");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'parent_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '直属主入仓单ID;主单为空' AFTER inbound_role");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'root_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释' AFTER parent_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'split_sequence',
"INT UNSIGNED NULL COMMENT '同一主单下的子单顺序号' AFTER root_inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'split_status',
"VARCHAR(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT' AFTER split_sequence");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'package_count',
"INT UNSIGNED NULL COMMENT '入仓单箱数;三期唯一数量口径' AFTER total_package_count");
CALL phase3_inbound_add_column(
'fl_inbound_order', 'master_order_id',
"BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER' AND deleted = 0 THEN order_id ELSE NULL END) STORED COMMENT '仅用于约束有效主单订单唯一性'");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_index(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND index_name = p_index_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_master_order',
'UNIQUE KEY `uk_inbound_master_order` (`tenant_id`, `master_order_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_order', 'uk_inbound_one_child',
'UNIQUE KEY `uk_inbound_one_child` (`tenant_id`, `parent_inbound_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_order', 'idx_inbound_root_role',
'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_drop_legacy_index()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_tenant_order'
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_tenant_order;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_parent_sequence'
) THEN
ALTER TABLE fl_inbound_order DROP INDEX uk_inbound_parent_sequence;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键',
expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本',
package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数',
status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED',
remark VARCHAR(500) NULL COMMENT '拆分原因或备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
applied_at DATETIME(3) NULL COMMENT '批次生效时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id),
UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key),
KEY idx_inbound_split_parent (tenant_id, parent_inbound_id, created_at, id),
CONSTRAINT chk_inbound_split_package CHECK (package_count > 0),
CONSTRAINT chk_inbound_split_status CHECK (status IN ('PREPARED', 'APPLIED', 'CANCELLED')),
CONSTRAINT fk_inbound_split_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='主入仓单拆分批次;数量口径仅为箱数';
CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号',
source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID',
source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID',
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id),
KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at),
CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0),
CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id),
CONSTRAINT fk_inbound_split_item_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_split_item_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_split_item_measurement FOREIGN KEY (source_measurement_id) REFERENCES fl_inbound_measurement (id),
CONSTRAINT fk_inbound_split_item_measure_item FOREIGN KEY (source_measurement_item_id) REFERENCES fl_inbound_measurement_item (id)
) ENGINE=InnoDB COMMENT='主子入仓单拆分数量台账';
CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号',
action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL',
before_snapshot JSON NULL COMMENT '操作前快照',
after_snapshot JSON NOT NULL COMMENT '操作后快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id),
KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id),
CONSTRAINT chk_inbound_split_action CHECK (action_type IN ('CREATE', 'APPLY', 'CANCEL')),
CONSTRAINT fk_inbound_split_history_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id)
) ENGINE=InnoDB COMMENT='不可变主子入仓单拆分审计历史';
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_column(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_column(
'fl_inbound_measurement', 'split_batch_id',
"BIGINT UNSIGNED NULL COMMENT '产生该测量批次的入仓拆分批次ID' AFTER inbound_id");
CALL phase3_inbound_add_column(
'fl_inbound_measurement', 'source_measurement_id',
"BIGINT UNSIGNED NULL COMMENT '子单继承或复核时的来源测量批次ID' AFTER split_batch_id");
CALL phase3_inbound_add_column(
'fl_inbound_measurement_item', 'split_item_id',
"BIGINT UNSIGNED NULL COMMENT '产生该子单测量明细的拆分台账ID' AFTER measurement_id");
CALL phase3_inbound_add_column(
'fl_inbound_measurement_item', 'source_measurement_item_id',
"BIGINT UNSIGNED NULL COMMENT '被拆分继承的来源测量箱规明细ID' AFTER split_item_id");
CALL phase3_inbound_add_column(
'fl_inbound_split_item', 'line_no',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id");
DROP PROCEDURE IF EXISTS phase3_inbound_add_column;
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_relation(
IN p_table_name VARCHAR(64),
IN p_constraint_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND table_name = p_table_name
AND constraint_name = p_constraint_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD CONSTRAINT `',
p_constraint_name, '` ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_relation(
'fl_inbound_order', 'fk_inbound_parent_order',
'FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_order', 'fk_inbound_root_order',
'FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement', 'fk_measure_split_batch',
'FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement', 'fk_measure_source_batch',
'FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement_item', 'fk_measure_item_split_item',
'FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`)');
CALL phase3_inbound_add_relation(
'fl_inbound_measurement_item', 'fk_measure_item_source_item',
'FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_relation;
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_add_index(
IN p_table_name VARCHAR(64),
IN p_index_name VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND index_name = p_index_name
) THEN
SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD ', p_definition);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_add_index(
'fl_inbound_measurement', 'idx_measure_split_source',
'KEY `idx_measure_split_source` (`tenant_id`, `split_batch_id`, `source_measurement_id`, `created_at`)');
CALL phase3_inbound_add_index(
'fl_inbound_measurement_item', 'idx_measure_item_split_source',
'KEY `idx_measure_item_split_source` (`tenant_id`, `split_item_id`, `source_measurement_item_id`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_line',
'UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`, `split_batch_id`, `line_no`)');
CALL phase3_inbound_add_index(
'fl_inbound_split_item', 'uk_inbound_split_batch_source_item',
'UNIQUE KEY `uk_inbound_split_batch_source_item` (`tenant_id`, `split_batch_id`, `source_measurement_item_id`)');
DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DELIMITER $$
CREATE PROCEDURE phase3_inbound_drop_split_legacy_index()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_child;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_split_item' AND index_name = 'uk_inbound_split_batch_child') THEN
ALTER TABLE fl_inbound_split_item DROP INDEX uk_inbound_split_batch_child;
END IF;
END$$
DELIMITER ;
CALL phase3_inbound_drop_split_legacy_index();
DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_order_parent_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_batch_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_item_before_delete;
DROP TRIGGER IF EXISTS trg_inbound_split_history_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_inbound_order_parent_before_insert
BEFORE INSERT ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NEW.inbound_role NOT IN ('MASTER', 'CHILD') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级非法';
END IF;
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.package_count IS NOT NULL AND NEW.package_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单箱数必须大于0';
END IF;
IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单';
END IF;
IF NEW.inbound_role = 'CHILD' THEN
IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL
OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数';
END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
END IF;
IF NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.deleted = 0
) OR NEW.root_inbound_id <> NEW.parent_inbound_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须绑定同一客户的有效主单';
END IF;
END IF;
END$$
CREATE TRIGGER trg_inbound_order_parent_before_update
BEFORE UPDATE ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NOT (OLD.inbound_role <=> NEW.inbound_role)
OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id)
OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id)
OR NOT (OLD.split_sequence <=> NEW.split_sequence) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单主子归属创建后不可修改';
END IF;
IF NEW.inbound_role = 'CHILD'
AND (NEW.package_count IS NULL OR NEW.package_count = 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单箱数必须大于0';
END IF;
IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法';
END IF;
IF NEW.inbound_role = 'CHILD' AND NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子仅两级,子入仓单不可继续拆分';
END IF;
IF NEW.inbound_role = 'MASTER'
AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT')
OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_item_before_insert
BEFORE INSERT ON fl_inbound_split_item
FOR EACH ROW
BEGIN
IF NOT EXISTS (
SELECT 1
FROM fl_inbound_split_batch batch
JOIN fl_inbound_order parent_order ON parent_order.id = batch.parent_inbound_id
JOIN fl_inbound_order child_order ON child_order.id = NEW.child_inbound_id
WHERE batch.id = NEW.split_batch_id
AND batch.tenant_id = NEW.tenant_id
AND batch.parent_inbound_id = NEW.parent_inbound_id
AND batch.status = 'PREPARED'
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND child_order.tenant_id = NEW.tenant_id
AND child_order.inbound_role = 'CHILD'
AND child_order.parent_inbound_id = NEW.parent_inbound_id
AND child_order.package_count >= NEW.package_count
AND child_order.deleted = 0
AND (NEW.source_measurement_item_id IS NULL OR EXISTS (
SELECT 1
FROM fl_inbound_measurement_item source_item
JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id
WHERE source_item.id = NEW.source_measurement_item_id
AND source_item.tenant_id = NEW.tenant_id
AND source_item.measurement_id = NEW.source_measurement_id
AND source_batch.inbound_id = NEW.parent_inbound_id
))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_batch_before_insert
BEFORE INSERT ON fl_inbound_split_batch
FOR EACH ROW
BEGIN
IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建';
END IF;
IF NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.deleted = 0
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_batch_before_update
BEFORE UPDATE ON fl_inbound_split_batch
FOR EACH ROW
BEGIN
IF OLD.status IN ('APPLIED', 'CANCELLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已结束拆分批次不可修改';
END IF;
IF NEW.status = 'APPLIED' THEN
IF NEW.applied_at IS NULL
OR NOT EXISTS (
SELECT 1 FROM fl_inbound_order parent_order
WHERE parent_order.id = NEW.parent_inbound_id
AND parent_order.tenant_id = NEW.tenant_id
AND parent_order.inbound_role = 'MASTER'
AND parent_order.package_count > 0
AND parent_order.split_status = 'PARTIALLY_SPLIT'
AND parent_order.version > NEW.expected_parent_version
) OR
(SELECT COALESCE(SUM(item.package_count), 0)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> NEW.package_count OR
(SELECT COUNT(DISTINCT item.child_inbound_id)
FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id) <> 1 OR
EXISTS (SELECT 1
FROM fl_inbound_split_item item
JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
GROUP BY child_order.id, child_order.package_count
HAVING child_order.package_count <> SUM(item.package_count)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入仓单或拆分明细箱数合计不一致';
END IF;
IF EXISTS (
SELECT 1
FROM fl_inbound_split_item split_item
JOIN fl_inbound_measurement_item source_item
ON source_item.id = split_item.source_measurement_item_id
WHERE split_item.tenant_id = NEW.tenant_id
AND split_item.split_batch_id = NEW.id
GROUP BY split_item.source_measurement_item_id, source_item.package_count
HAVING SUM(split_item.package_count) > source_item.package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数';
END IF;
ELSEIF NEW.status = 'CANCELLED' THEN
IF EXISTS (
SELECT 1 FROM fl_inbound_split_item item
WHERE item.tenant_id = NEW.tenant_id
AND item.split_batch_id = NEW.id
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有子单明细的拆分批次不可取消';
END IF;
ELSEIF NEW.status <> 'PREPARED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次状态流转非法';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_item_before_update
BEFORE UPDATE ON fl_inbound_split_item
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可修改';
END$$
CREATE TRIGGER trg_inbound_split_item_before_delete
BEFORE DELETE ON fl_inbound_split_item
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账不可删除';
END$$
CREATE TRIGGER trg_inbound_split_history_before_update
BEFORE UPDATE ON fl_inbound_split_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可修改';
END$$
CREATE TRIGGER trg_inbound_split_history_before_delete
BEFORE DELETE ON fl_inbound_split_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分历史不可删除';
END$$
DELIMITER ;
DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
DELIMITER $$
CREATE PROCEDURE phase3_assert_inbound_parent_child_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('fl_inbound_split_batch', 'fl_inbound_split_item',
'fl_inbound_split_history')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单拆分台账未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id',
'split_sequence', 'split_status', 'package_count',
'master_order_id')) <> 7 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence',
'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order',
'fk_measure_split_batch', 'fk_measure_source_batch',
'fk_measure_item_split_item', 'fk_measure_item_source_item')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单与测量关联约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN ('trg_inbound_order_parent_before_insert',
'trg_inbound_order_parent_before_update',
'trg_inbound_split_item_before_insert',
'trg_inbound_split_batch_before_insert',
'trg_inbound_split_batch_before_update',
'trg_inbound_split_item_before_update',
'trg_inbound_split_item_before_delete',
'trg_inbound_split_history_before_update',
'trg_inbound_split_history_before_delete')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单数据库保护触发器未完整创建';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_tenant_order'
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单';
END IF;
END$$
DELIMITER ;
CALL phase3_assert_inbound_parent_child_schema();
DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
@@ -0,0 +1,17 @@
-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。
-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。
USE baiyue_firstleg;
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED',
updated_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE process_status = 'WAIT_SPLIT';
UPDATE fl_inbound_status_history
SET from_status = 'INBOUNDED'
WHERE from_status = 'WAIT_SPLIT';
UPDATE fl_inbound_status_history
SET to_status = 'INBOUNDED'
WHERE to_status = 'WAIT_SPLIT';
@@ -0,0 +1,864 @@
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_firstleg;
-- 兼容已执行二期初始化的存量库:PDA 拣货需要累计每个托盘分配已拣箱数。
DROP PROCEDURE IF EXISTS phase3_add_allocation_picked_count;
DELIMITER $$
CREATE PROCEDURE phase3_add_allocation_picked_count()
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_pallet_allocation'
AND column_name = 'picked_package_count'
) THEN
ALTER TABLE fl_inbound_pallet_allocation
ADD COLUMN picked_package_count INT UNSIGNED NOT NULL DEFAULT 0
COMMENT '已拣货箱数' AFTER allocated_package_count;
END IF;
END$$
DELIMITER ;
CALL phase3_add_allocation_picked_count();
DROP PROCEDURE phase3_add_allocation_picked_count;
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
capacity_cbm DECIMAL(18,4) NULL COMMENT '货柜容量m3',
max_weight_kg DECIMAL(18,3) NULL COMMENT '货柜最大承重kg',
terminal_trucking_company VARCHAR(128) NULL COMMENT '码头拖车公司',
loading_at DATETIME(3) NULL COMMENT '计划或实际装柜时间',
estimated_sailing_at DATETIME(3) NULL COMMENT '预计开船时间',
actual_sailing_at DATETIME(3) NULL COMMENT '实际开船时间',
estimated_port_arrival_at DATETIME(3) NULL COMMENT '预计到港时间',
actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间',
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
KEY idx_container_booking_container (container_no),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
CREATE TABLE IF NOT EXISTS fl_loading_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED',
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认操作人ID',
confirmed_by_name VARCHAR(64) NULL COMMENT '确认操作人名称快照',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
remark VARCHAR(500) NULL COMMENT '平台装柜计划备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no),
UNIQUE KEY uk_loading_plan_booking (container_booking_id),
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
KEY idx_loading_plan_status (plan_status, updated_at, id),
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id),
CONSTRAINT chk_loading_plan_status
CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))
) ENGINE=InnoDB COMMENT='平台智能装柜计划与唯一预装状态源';
CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
account_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(64) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照',
product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '产品图片File对象ID',
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
net_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜净重kg',
dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜材积重kg',
volume_cbm DECIMAL(18,4) NULL COMMENT '本次装柜体积m3',
fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA追踪号快照',
shipment_no VARCHAR(128) NULL COMMENT '货件编号快照',
shipping_mark VARCHAR(500) NULL COMMENT '唛头快照',
load_sequence INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '装柜顺序',
position_snapshot JSON NULL COMMENT '智能装柜位置与摆放结果快照',
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id),
KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
CONSTRAINT chk_loading_item_package CHECK (package_count > 0),
CONSTRAINT chk_loading_item_product_quantity CHECK (product_quantity IS NULL OR product_quantity > 0),
CONSTRAINT chk_loading_item_units CHECK (units_per_carton IS NULL OR units_per_carton > 0),
CONSTRAINT chk_loading_item_pallet CHECK (pallet_count IS NULL OR pallet_count >= 0),
CONSTRAINT chk_loading_item_weight CHECK (
(gross_weight_kg IS NULL OR gross_weight_kg >= 0)
AND (net_weight_kg IS NULL OR net_weight_kg >= 0)
AND (dimensional_weight_kg IS NULL OR dimensional_weight_kg >= 0)
),
CONSTRAINT chk_loading_item_volume CHECK (volume_cbm IS NULL OR volume_cbm >= 0),
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL 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 UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
KEY idx_container_document_file (file_id),
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id),
CONSTRAINT chk_container_document_type
CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
action_type VARCHAR(32) NOT NULL
COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NULL COMMENT '变更后状态',
before_snapshot JSON NULL COMMENT '变更前快照',
after_snapshot JSON NULL COMMENT '变更后快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_history_operation (operation_id),
KEY idx_loading_history_plan (loading_plan_id, created_at, id),
KEY idx_loading_history_item (loading_plan_item_id, created_at, id),
CONSTRAINT fk_loading_history_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT fk_loading_history_item FOREIGN KEY (loading_plan_item_id)
REFERENCES fl_loading_plan_item (id)
) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
completed_by BIGINT UNSIGNED NULL COMMENT '完成装柜人',
completed_by_name VARCHAR(64) NULL COMMENT '完成装柜人名称',
completed_at DATETIME(3) NULL COMMENT '完成时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划明细ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '父入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id),
KEY idx_loading_pick_item_task (pick_task_id, item_status, id),
CONSTRAINT fk_loading_pick_item_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_pick_item_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0),
CONSTRAINT chk_loading_pick_item_count CHECK (picked_package_count <= planned_package_count),
CONSTRAINT chk_loading_pick_item_status CHECK (item_status IN ('PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货明细';
CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货操作历史ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_history_operation (operation_id),
KEY idx_loading_pick_history_item (pick_item_id, created_at),
CONSTRAINT fk_loading_pick_history_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_booking_before_update
BEFORE UPDATE ON fl_container_booking
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改';
END IF;
IF current_plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.warehouse_id <=> NEW.warehouse_id)
OR NOT (OLD.warehouse_name <=> NEW.warehouse_name)
OR NOT (OLD.bill_of_lading_no <=> NEW.bill_of_lading_no)
OR NOT (OLD.vessel_name <=> NEW.vessel_name)
OR NOT (OLD.container_type <=> NEW.container_type)
OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id)
OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name)
OR NOT (OLD.departure_port_code <=> NEW.departure_port_code)
OR NOT (OLD.departure_port_name <=> NEW.departure_port_name)
OR NOT (OLD.destination_port_code <=> NEW.destination_port_code)
OR NOT (OLD.destination_port_name <=> NEW.destination_port_name)
OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code)
OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name)
OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm)
OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg)
OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company)
OR NOT (OLD.loading_at <=> NEW.loading_at)
OR NOT (OLD.estimated_sailing_at <=> NEW.estimated_sailing_at)
OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at)
OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at)
OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at)
OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at)
OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at)
OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
OR NOT (OLD.created_at <=> NEW.created_at)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '装柜中仅允许修改集装箱号与航次';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_plan_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_plan_before_update
BEFORE UPDATE ON fl_loading_plan
FOR EACH ROW
BEGIN
IF OLD.plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成装柜计划全部只读';
END IF;
IF NOT (
(OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING'))
OR (OLD.plan_status = 'LOADING'
AND NEW.plan_status IN ('LOADING', 'COMPLETED'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜状态只允许单向推进';
END IF;
IF OLD.plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
OR NOT (OLD.remark <=> NEW.remark)
OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
OR NOT (OLD.created_at <=> NEW.created_at)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '装柜中计划仅允许乐观锁触碰或完成装柜';
END IF;
IF OLD.plan_status = NEW.plan_status
AND OLD.plan_status = 'LOADING' AND (
NOT (OLD.preload_completed_by <=> NEW.preload_completed_by)
OR NOT (OLD.preload_completed_by_name <=> NEW.preload_completed_by_name)
OR NOT (OLD.preload_completed_at <=> NEW.preload_completed_at)
OR NOT (OLD.confirmed_by <=> NEW.confirmed_by)
OR NOT (OLD.confirmed_by_name <=> NEW.confirmed_by_name)
OR NOT (OLD.confirmed_at <=> NEW.confirmed_at)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
END IF;
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
END IF;
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
LEFT JOIN fl_loading_pick_item pick_item
ON pick_item.loading_plan_item_id = item.id
LEFT JOIN fl_loading_pick_task pick_task
ON pick_task.id = pick_item.pick_task_id
AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND (pick_task.id IS NULL
OR pick_item.picked_package_count <> pick_item.planned_package_count)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
END IF;
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_plan_after_update;
DELIMITER $$
CREATE TRIGGER trg_loading_plan_after_update
AFTER UPDATE ON fl_loading_plan
FOR EACH ROW
BEGIN
IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
UPDATE fl_loading_plan_item
SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)),
frozen_by = COALESCE(frozen_by, NEW.updated_by),
updated_by = NEW.updated_by,
updated_by_name = NEW.updated_by_name,
version = version + 1
WHERE loading_plan_id = NEW.id AND deleted = 0;
UPDATE fl_loading_pick_task
SET task_status = 'COMPLETED', completed_by = NEW.updated_by,
completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE loading_plan_id = NEW.id AND task_status = 'LOADING';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_item_before_insert;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_insert
BEFORE INSERT ON fl_loading_plan_item
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE id = NEW.loading_plan_id;
IF current_plan_status IS NULL OR current_plan_status NOT IN ('EDITING', 'LOADING') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仅编辑中或装柜中的计划允许新增明细';
END IF;
IF NEW.deleted <> 0 OR NEW.frozen_at IS NOT NULL OR NEW.frozen_by IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新增装柜明细必须为未删除且未冻结状态';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_item_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_update
BEFORE UPDATE ON fl_loading_plan_item
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE id = OLD.loading_plan_id;
IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
IF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
AND NEW.deleted = 0
AND NEW.package_count = picked_package_count
) OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
OR NOT (NEW.order_id <=> OLD.order_id)
OR NOT (NEW.order_no <=> OLD.order_no)
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
OR NOT (NEW.sku_code <=> OLD.sku_code)
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数';
END IF;
END IF;
IF OLD.frozen_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结';
END IF;
IF OLD.deleted = 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改';
END IF;
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配';
END IF;
IF NEW.deleted = 1 THEN
IF NEW.removed_by IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人';
END IF;
SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3));
ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_task_before_update
BEFORE UPDATE ON fl_loading_pick_task
FOR EACH ROW
BEGIN
IF OLD.task_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改';
END IF;
IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_no <=> OLD.container_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务归属不可修改';
END IF;
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_item_before_update
BEFORE UPDATE ON fl_loading_pick_item
FOR EACH ROW
BEGIN
DECLARE current_task_status VARCHAR(16);
SELECT MAX(task_status) INTO current_task_status
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
IF current_task_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后PDA拣货明细不可修改';
END IF;
IF NEW.pick_task_id <> OLD.pick_task_id
OR NEW.loading_plan_item_id <> OLD.loading_plan_item_id
OR NEW.inbound_id <> OLD.inbound_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货明细归属不可修改';
END IF;
IF NEW.item_status = 'PARTIAL' AND NOT (
OLD.item_status IN ('PENDING', 'PICKING')
AND OLD.picked_package_count < NEW.picked_package_count
AND NEW.picked_package_count < OLD.planned_package_count
AND NEW.planned_package_count = OLD.planned_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货必须在计划箱数内增加实际拣出箱数';
END IF;
IF NEW.item_status = 'SKIPPED' AND NOT (
OLD.item_status = 'PENDING'
AND OLD.picked_package_count = 0
AND NEW.picked_package_count = 0
AND NEW.planned_package_count = OLD.planned_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '跳拣仅允许零拣货的待拣明细';
END IF;
IF OLD.item_status = 'PARTIAL' AND NEW.item_status = 'PICKED' AND NOT (
NEW.picked_package_count = OLD.picked_package_count
AND NEW.planned_package_count = OLD.picked_package_count
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分拣货收敛后计划箱数必须等于实际拣出箱数';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_item_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_item_before_delete
BEFORE DELETE ON fl_loading_pick_item
FOR EACH ROW
BEGIN
DECLARE current_task_status VARCHAR(16);
SELECT MAX(task_status) INTO current_task_status
FROM fl_loading_pick_task WHERE id = OLD.pick_task_id;
IF current_task_status = 'COMPLETED' OR OLD.picked_package_count > 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已拣货或已完成的PDA明细不可删除';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_history_before_update
BEFORE UPDATE ON fl_loading_pick_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可修改';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_pick_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_history_before_delete
BEFORE DELETE ON fl_loading_pick_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货历史不可删除';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_item_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_item_before_delete
BEFORE DELETE ON fl_loading_plan_item
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细只允许推送前逻辑删除';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_insert;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_insert
BEFORE INSERT ON fl_container_document
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_update
BEFORE UPDATE ON fl_container_document
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_container_document_before_delete;
DELIMITER $$
CREATE TRIGGER trg_container_document_before_delete
BEFORE DELETE ON fl_container_document
FOR EACH ROW
BEGIN
DECLARE current_plan_status VARCHAR(24);
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可删除';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_history_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_history_before_update
BEFORE UPDATE ON fl_loading_plan_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可修改';
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_loading_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_loading_history_before_delete
BEFORE DELETE ON fl_loading_plan_history
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜操作历史不可删除';
END$$
DELIMITER ;
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102090, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:container:list',
'货柜', '/first-leg/containers', 'GET', 40, 'ACTIVE', 1, 0),
(5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view',
'查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0),
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create',
'建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit',
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
'查看预装详情', NULL, 'GET', 20, 'ACTIVE', 1, 0),
(5102103, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:edit',
'编辑预装运输字段', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102104, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:complete',
'完成预装', NULL, 'POST', 40, 'ACTIVE', 1, 0),
(5102105, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:confirm',
'确认预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
(5102106, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:loading-list:list',
'装柜清单', '/first-leg/loading-list', 'GET', 70, 'ACTIVE', 1, 0),
(5102108, 0, 'PLATFORM', 'BUTTON', 5102106, 'platform:firstleg:loading-list:view',
'查看装柜详情', NULL, 'GET', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE',
built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, role.tenant_id
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')
WHERE role.tenant_id = 0
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
'fl_container_document', 'fl_loading_plan_history',
'fl_loading_pick_task', 'fl_loading_pick_item',
'fl_loading_pick_history')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
END IF;
IF EXISTS (SELECT 1 FROM fl_loading_plan
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'baiyue_firstleg'
AND constraint_name = 'chk_loading_plan_status'
AND check_clause LIKE '%''EDITING''%'
AND check_clause LIKE '%''LOADING''%'
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_loading_pick_item'
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
'inbound_detail_id', 'planned_package_count',
'picked_package_count', 'item_status', 'version')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
AND index_name IN ('uk_loading_pick_task_plan',
'uk_loading_pick_item_plan_item',
'uk_loading_pick_history_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = 'baiyue_firstleg'
AND trigger_name IN ('trg_container_booking_before_update',
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
'trg_container_document_before_update', 'trg_container_document_before_delete',
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
'trg_loading_pick_task_before_update',
'trg_loading_pick_item_before_update',
'trg_loading_pick_item_before_delete',
'trg_loading_pick_history_before_update',
'trg_loading_pick_history_before_delete')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
END IF;
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 32 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_smart_loading_schema();
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
@@ -37,3 +37,236 @@ CREATE TABLE IF NOT EXISTS fl_split_operation (
KEY idx_split_operation_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='分柜操作幂等与审计索引';
/*
* 部分装柜拆单契约:
* 1. parent_inbound_id 为空的是主单,非空的是唯一二级子单;
* 2. 锁定阶段只记录箱数,完成阶段绑定由剩余箱数生成的子单;
* 3. 子单不能再作为父单,锁定数量创建后不可修改。
*/
DROP PROCEDURE IF EXISTS phase3_split_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_split_add_column(
IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = p_table_name AND column_name = p_column_name
) THEN
SET @phase3_split_ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
DEALLOCATE PREPARE phase3_split_stmt;
END IF;
END$$
DELIMITER ;
CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id',
'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id');
CALL phase3_split_add_column('fl_inbound_order', 'split_level',
'TINYINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN parent_inbound_id IS NULL THEN 0 ELSE 1 END) STORED COMMENT ''拆单层级:0主单、1二级子单'' AFTER parent_inbound_id');
CALL phase3_split_add_column('fl_inbound_order', 'split_status',
'VARCHAR(24) NOT NULL DEFAULT ''NOT_SPLIT'' COMMENT ''NOT_SPLIT/PARTIALLY_SPLIT/FULLY_SPLIT'' AFTER split_level');
DROP PROCEDURE IF EXISTS phase3_split_add_column;
SET @phase3_split_index_exists = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND index_name = 'uk_inbound_one_secondary'
);
SET @phase3_split_ddl = IF(@phase3_split_index_exists = 0,
'ALTER TABLE fl_inbound_order ADD UNIQUE KEY uk_inbound_one_secondary (tenant_id, parent_inbound_id)',
'SELECT 1');
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
DEALLOCATE PREPARE phase3_split_stmt;
SET @phase3_split_fk_exists = (
SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND constraint_name = 'fk_inbound_parent' AND constraint_type = 'FOREIGN KEY'
);
SET @phase3_split_ddl = IF(@phase3_split_fk_exists = 0,
'ALTER TABLE fl_inbound_order ADD CONSTRAINT fk_inbound_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id)',
'SELECT 1');
PREPARE phase3_split_stmt FROM @phase3_split_ddl;
EXECUTE phase3_split_stmt;
DEALLOCATE PREPARE phase3_split_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键',
source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照',
locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数',
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (
source_package_count - locked_package_count
) STORED COMMENT '生成二级子单的剩余箱数',
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED',
operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
completed_by BIGINT UNSIGNED NULL,
completed_by_name VARCHAR(64) NULL,
completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id),
UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id),
UNIQUE KEY uk_inbound_split_operation (operation_id),
KEY idx_inbound_split_status (tenant_id, parent_inbound_id, status),
CONSTRAINT fk_inbound_split_detail_parent FOREIGN KEY (parent_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_split_detail_child FOREIGN KEY (child_inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT chk_inbound_split_partial CHECK (
source_package_count > 1 AND locked_package_count > 0
AND locked_package_count < source_package_count
),
CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')),
CONSTRAINT chk_inbound_split_completion CHECK (
(status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL)
OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL)
)
) ENGINE=InnoDB COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约';
DROP TRIGGER IF EXISTS trg_inbound_split_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_insert;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_update;
DROP TRIGGER IF EXISTS trg_inbound_split_detail_before_delete;
DELIMITER $$
CREATE TRIGGER trg_inbound_split_before_insert
BEFORE INSERT ON fl_inbound_order
FOR EACH ROW
BEGIN
DECLARE v_parent_tenant BIGINT UNSIGNED;
DECLARE v_parent_parent BIGINT UNSIGNED;
IF NEW.parent_inbound_id IS NOT NULL THEN
SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
IF v_parent_tenant <> NEW.tenant_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单与主单客户归属不一致';
END IF;
IF v_parent_parent IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
END IF;
IF NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单拆单状态必须为NOT_SPLIT';
END IF;
END IF;
END$$
CREATE TRIGGER trg_inbound_split_before_update
BEFORE UPDATE ON fl_inbound_order
FOR EACH ROW
BEGIN
IF NOT (NEW.parent_inbound_id <=> OLD.parent_inbound_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入库单主子关系创建后不可修改';
END IF;
IF OLD.parent_inbound_id IS NOT NULL AND NEW.split_status <> 'NOT_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二级子入库单不能继续拆分';
END IF;
IF OLD.parent_inbound_id IS NULL
AND NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入库单拆单状态非法';
END IF;
IF OLD.split_status = 'FULLY_SPLIT' AND NEW.split_status <> 'FULLY_SPLIT' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成拆单不能回退';
END IF;
IF NEW.split_status = 'FULLY_SPLIT' AND (
(SELECT COUNT(*) FROM fl_inbound_order child
WHERE child.tenant_id = NEW.tenant_id AND child.parent_inbound_id = NEW.id
AND child.deleted = 0) <> 1
OR (SELECT COUNT(*) FROM fl_inbound_split_detail detail
WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id) = 0
OR EXISTS (SELECT 1 FROM fl_inbound_split_detail detail
WHERE detail.tenant_id = NEW.tenant_id AND detail.parent_inbound_id = NEW.id
AND detail.status <> 'COMPLETED')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数二级子单及拆分明细未完成';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_insert
BEFORE INSERT ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
DECLARE v_parent_tenant BIGINT UNSIGNED;
DECLARE v_parent_parent BIGINT UNSIGNED;
SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent
FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0;
IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单';
END IF;
IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_update
BEFORE UPDATE ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
DECLARE v_child_tenant BIGINT UNSIGNED;
DECLARE v_child_parent BIGINT UNSIGNED;
IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id
OR NEW.inbound_detail_id <> OLD.inbound_detail_id
OR NEW.source_package_count <> OLD.source_package_count
OR NEW.locked_package_count <> OLD.locked_package_count
OR NEW.operation_id <> OLD.operation_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改';
END IF;
IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单';
END IF;
SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent
FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0;
IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单';
END IF;
END$$
CREATE TRIGGER trg_inbound_split_detail_before_delete
BEFORE DELETE ON fl_inbound_split_detail
FOR EACH ROW
BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定与拆单明细不可删除';
END$$
DELIMITER ;
DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
DELIMITER $$
CREATE PROCEDURE phase3_split_assert_contract()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND column_name IN ('parent_inbound_id', 'split_level', 'split_status')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:入库单主子关系字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_detail') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:部分锁定明细表未创建';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_one_secondary', 'uk_inbound_split_parent_detail',
'uk_inbound_split_child_detail', 'uk_inbound_split_operation')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:唯一子单或部分锁定幂等索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = DATABASE()
AND trigger_name IN ('trg_inbound_split_before_insert', 'trg_inbound_split_before_update',
'trg_inbound_split_detail_before_insert',
'trg_inbound_split_detail_before_update',
'trg_inbound_split_detail_before_delete')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆单迁移失败:主子层级或部分锁定触发器未完整创建';
END IF;
END$$
DELIMITER ;
CALL phase3_split_assert_contract();
DROP PROCEDURE IF EXISTS phase3_split_assert_contract;
@@ -137,7 +137,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
post:
summary: 确认最新费用快照
description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
description: 确认费用只更新费用状态;主流程已到 INBOUNDED 时保持不变。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send:
@@ -125,6 +125,90 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无分配权限}
/api/platform/v1/containers:
get:
summary: 分页查询平台货柜
description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: status, in: query, schema: {type: string, enum: [EDITING, LOADING, COMPLETED]}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
'403': {description: 无货柜查询权限}
post:
summary: 新增平台货柜
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 EDITING
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerSaveRequest'}
responses:
'200': {description: 成功或重复号、参数校验等业务失败}
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}:
get:
summary: 查询平台货柜详情
description: 需要 platform:firstleg:container:view 权限
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: 成功或货柜不存在}
'403': {description: 无货柜查询权限}
put:
summary: 草稿阶段完整编辑货柜
description: 需要 platform:firstleg:container:edit 权限;仅无装柜计划或计划为 EDITING 时允许
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerFullUpdateRequest'}
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/transport:
patch:
summary: 修改集装箱号和航次
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerTransportUpdateRequest'}
responses:
'200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限}
/api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}:
get:
summary: 按货柜恢复智能装柜草稿
description: 需要 platform:firstleg:smart-loading:list 权限;仅返回 EDITING 草稿
parameters:
- {name: bookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功、草稿不存在或计划已进入预装流程}
'403': {description: 无智能装柜查询权限}
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
get:
summary: 查询智能装柜草稿详情
description: 需要 platform:firstleg:smart-loading:list 权限;返回草稿及当前未删除明细
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功、计划不存在或计划已进入预装流程}
'403': {description: 无智能装柜查询权限}
/openapi/v1/inbound-measurements:
post:
summary: 提交一条第三方箱规测量结果和一张图片
@@ -416,6 +500,11 @@ components:
name: size
in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
ContainerBookingId:
name: id
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
CustomerDimensionConfigUpdateRequest:
type: object
@@ -526,6 +615,97 @@ components:
publicRemark: {type: string, nullable: true}
expressCompanyName: {type: string, nullable: true}
expressTrackingNo: {type: string, nullable: true}
ContainerBookingPageEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}}
page: {type: string, pattern: '^[1-9][0-9]*$'}
size: {type: string, pattern: '^[1-9][0-9]*$'}
total: {type: string, pattern: '^[0-9]+$'}
pages: {type: string, pattern: '^[0-9]+$'}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
bookingNo: {type: string, maxLength: 40}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
containerType: {type: string, maxLength: 32}
shippingCompanyId: {type: string, nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true}
departurePortName: {type: string, maxLength: 128, nullable: true}
destinationPortCode: {type: string, maxLength: 32, nullable: true}
destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true}
loadingAt: {type: string, format: date-time, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
actualSailingAt: {type: string, format: date-time, nullable: true}
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true}
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
planStatus: {type: string, enum: [EDITING, LOADING, COMPLETED]}
version: {type: integer, minimum: 0}
ContainerSaveRequest:
type: object
required: [cabinetNo, warehouseId, warehouseName, containerType]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
containerType: {type: string, maxLength: 32}
shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true}
departurePortName: {type: string, maxLength: 128, nullable: true}
destinationPortCode: {type: string, maxLength: 32, nullable: true}
destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true}
loadingAt: {type: string, format: date-time, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
actualSailingAt: {type: string, format: date-time, nullable: true}
estimatedPortArrivalAt: {type: string, format: date-time, nullable: true}
actualPortArrivalAt: {type: string, format: date-time, nullable: true}
estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
ContainerFullUpdateRequest:
type: object
required: [version, data]
properties:
version: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
ContainerTransportUpdateRequest:
type: object
required: [version]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
version: {type: integer, minimum: 0}
MerchantInboundBillingEnvelope:
type: object
properties:
@@ -0,0 +1,335 @@
openapi: 3.0.3
info:
title: 头程装柜与完成装柜清单接口
version: 1.0.0
description: 装柜计划仅允许 EDITING 到 LOADING 再到 COMPLETED 单向推进;EDITING 和 LOADING 均可维护未拣货明细,COMPLETED 后计划、明细与货柜运输信息永久只读,不提供回退或撤回接口。
paths:
/api/platform/v1/first-leg/loading-plans:
get:
summary: 分页查询预装清单
description: 仓库作业清单必须按仓库查询,返回该仓库全部客户的装柜计划;客户账号仅用于展示货物归属。
x-permission: platform:firstleg:preload:list
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'}
- name: status
in: query
description: 预装状态
schema: {type: string, enum: [LOADING]}
- {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
responses:
'200': {$ref: '#/components/responses/PreloadPageResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}:
get:
summary: 查询预装清单详情
description: 返回同一装柜计划的汇总、冻结明细和完整操作历史。
x-permission: platform:firstleg:preload:view
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/transport:
patch:
summary: 修改预装运输信息
description: 仅 LOADING 状态允许修改集装箱号与航次;装柜中可以维护未拣货明细。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/complete:
post:
summary: 完成预装
description: 仅允许将 LOADING 单向推进为 COMPLETED;完成前必须完成全部 PDA 拣货。
x-permission: platform:firstleg:preload:complete
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AdvanceRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-lists:
get:
summary: 分页查询装柜清单
description: 必须按仓库查询,只返回该仓库的 COMPLETED 装柜计划,包含全部客户单据。
x-permission: platform:firstleg:loading-list:list
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {$ref: '#/components/parameters/Keyword'}
- {$ref: '#/components/parameters/WarehouseId'}
- {$ref: '#/components/parameters/CreatedFrom'}
- {$ref: '#/components/parameters/CreatedTo'}
responses:
'200': {$ref: '#/components/responses/ConfirmedPageResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}:
get:
summary: 查询装柜清单详情
description: 返回已确认计划的汇总、冻结明细和操作历史,全部只读。
x-permission: platform:firstleg:loading-list:view
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {$ref: '#/components/responses/ConfirmedDetailResponse'}
components:
parameters:
PlanId:
name: planId
in: path
required: true
description: 装柜计划 ID
schema: {$ref: '#/components/schemas/LongId'}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: 写操作幂等键,同一业务操作重试时必须保持不变
schema: {type: string, minLength: 1, maxLength: 128}
Page:
name: page
in: query
description: 页码,从 1 开始
schema: {type: integer, format: int64, minimum: 1, default: 1}
Size:
name: size
in: query
description: 每页条数
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
Keyword:
name: keyword
in: query
description: 按预装单号、集装箱号或柜号模糊查询
schema: {type: string, maxLength: 128}
WarehouseId:
name: warehouseId
in: query
required: true
description: 仓库 ID;同一仓库内全部客户单据均可见
schema: {$ref: '#/components/schemas/LongId'}
CreatedFrom:
name: createdFrom
in: query
description: 创建时间起点,包含该时间
schema: {type: string, format: date-time}
CreatedTo:
name: createdTo
in: query
description: 创建时间终点,包含该时间
schema: {type: string, format: date-time}
responses:
PreloadPageResponse:
description: 预装清单分页查询成功或业务失败,按响应体 code 判断
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadPageEnvelope'}}}
PreloadDetailResponse:
description: 预装清单详情查询或操作成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/PreloadDetailEnvelope'}}}
ConfirmedPageResponse:
description: 装柜清单分页查询成功或业务失败,按响应体 code 判断
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedPageEnvelope'}}}
ConfirmedDetailResponse:
description: 装柜清单详情查询或确认成功,也可能返回业务失败
content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}}
schemas:
LongId:
type: string
pattern: '^[1-9][0-9]*$'
example: '1900000000000000001'
description: 长整型 ID,JSON 中使用字符串避免前端精度丢失
NullableLongId:
allOf: [{$ref: '#/components/schemas/LongId'}]
nullable: true
ApiEnvelope:
type: object
required: [code, message, timestamp]
properties:
code: {type: string, description: 业务结果码,仅字符串 200 表示成功, example: '200'}
message: {type: string, description: 结果说明}
requestId: {type: string, nullable: true, description: 请求标识}
traceId: {type: string, nullable: true, description: 链路标识}
timestamp: {type: string, format: date-time, description: 服务端响应时间}
PreloadPageEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/PreloadPage'}]
nullable: true
PreloadDetailEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/PreloadDetail'}]
nullable: true
ConfirmedPageEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/ConfirmedPage'}]
nullable: true
ConfirmedDetailEnvelope:
allOf:
- {$ref: '#/components/schemas/ApiEnvelope'}
- type: object
properties:
data:
allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}]
nullable: true
PreloadPage:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/PreloadSummary'}}
page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码}
size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数}
total: {type: string, pattern: '^[0-9]+$', description: 总记录数}
pages: {type: string, pattern: '^[0-9]+$', description: 总页数}
ConfirmedPage:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/ConfirmedSummary'}}
page: {type: string, pattern: '^[1-9][0-9]*$', description: 当前页码}
size: {type: string, pattern: '^[1-9][0-9]*$', description: 每页条数}
total: {type: string, pattern: '^[0-9]+$', description: 总记录数}
pages: {type: string, pattern: '^[0-9]+$', description: 总页数}
Customer:
type: object
required: [accountId, customerName]
properties:
accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称}
PreloadSummary:
type: object
required: [planId, preloadNo, customers, status, skuCount, totalPackageCount, totalVolumeCbm, totalGrossWeightKg, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
vesselName: {type: string, nullable: true, description: 船名}
voyageNo: {type: string, nullable: true, description: 航次}
containerType: {type: string, nullable: true, description: 货柜类型}
status: {type: string, enum: [LOADING], description: 装柜状态}
skuCount: {type: string, pattern: '^[0-9]+$', description: SKU 种类数}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
createdAt: {type: string, format: date-time, description: 创建时间}
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
ConfirmedSummary:
type: object
required: [planId, loadingListNo, status, totalPackageCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, totalQuantity, createdAt, version]
properties:
planId: {$ref: '#/components/schemas/LongId'}
loadingListNo: {type: string, description: 装柜清单号}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
containerNo: {type: string, nullable: true, description: 集装箱号}
internalContainerNo: {type: string, nullable: true, description: 内部柜号}
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
totalNetWeightKg: {type: number, description: 总净重,单位千克}
totalVolumeCbm: {type: number, description: 总体积,单位立方米}
totalQuantity: {type: string, pattern: '^[0-9]+$', description: 产品总数量}
remark: {type: string, nullable: true, description: 备注}
createdBy: {$ref: '#/components/schemas/NullableLongId'}
createdByName: {type: string, nullable: true, description: 创建人姓名}
createdAt: {type: string, format: date-time, description: 创建时间}
version: {type: integer, format: int32, minimum: 0, description: 乐观锁版本}
LoadingItem:
type: object
required: [itemId, accountId, inboundId, inboundDetailId, packageCount, palletCount, grossWeightKg, netWeightKg, volumeCbm, dimensionalWeightKg, productQuantity, unitsPerPackage, loadSequence]
properties:
itemId: {$ref: '#/components/schemas/LongId'}
accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称快照}
inboundId: {$ref: '#/components/schemas/LongId'}
inboundDetailId: {$ref: '#/components/schemas/LongId'}
inboundCode: {type: string, nullable: true, description: 入仓码}
skuCode: {type: string, nullable: true, description: SKU 编码快照}
destinationWarehouse: {type: string, nullable: true, description: 目的仓名称快照}
chineseName: {type: string, nullable: true, description: 中文品名快照}
englishName: {type: string, nullable: true, description: 英文品名快照}
goodsImageUrl: {type: string, nullable: true, description: 产品图片地址快照}
packageCount: {type: integer, format: int32, minimum: 0, description: 箱数}
palletCount: {type: integer, format: int32, minimum: 0, description: 托盘数}
grossWeightKg: {type: number, description: 毛重,单位千克}
netWeightKg: {type: number, description: 净重,单位千克}
volumeCbm: {type: number, description: 体积,单位立方米}
dimensionalWeightKg: {type: number, description: 材积重,单位千克}
productQuantity: {type: string, pattern: '^[0-9]+$', description: 产品数量}
unitsPerPackage: {type: integer, format: int32, minimum: 0, description: 每箱数量}
fbaTrackingNo: {type: string, nullable: true, description: FBA 跟踪编号}
shipmentNo: {type: string, nullable: true, description: 货件号}
shippingMark: {type: string, nullable: true, description: 唛头}
internalRemark: {type: string, nullable: true, description: 内部备注}
customerRemark: {type: string, nullable: true, description: 客户备注}
warehouseRemark: {type: string, nullable: true, description: 仓库备注}
loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序}
frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间}
OperationHistory:
type: object
required: [historyId, actionType, operatorId, occurredAt]
properties:
historyId: {$ref: '#/components/schemas/LongId'}
itemId: {$ref: '#/components/schemas/NullableLongId'}
actionType: {type: string, description: 操作类型}
fromStatus: {type: string, nullable: true, description: 操作前状态}
toStatus: {type: string, nullable: true, description: 操作后状态}
beforeSnapshot: {type: string, nullable: true, description: 操作前快照}
afterSnapshot: {type: string, nullable: true, description: 操作后快照}
operatorId: {$ref: '#/components/schemas/LongId'}
operatorName: {type: string, nullable: true, description: 操作人姓名}
operationReason: {type: string, nullable: true, description: 操作原因}
occurredAt: {type: string, format: date-time, description: 操作时间}
PreloadDetail:
type: object
required: [summary, items, histories]
properties:
summary: {$ref: '#/components/schemas/PreloadSummary'}
items: {type: array, description: 装柜中明细,可维护未拣货项, items: {$ref: '#/components/schemas/LoadingItem'}}
histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}}
ConfirmedDetail:
type: object
required: [summary, items, histories]
properties:
summary: {$ref: '#/components/schemas/ConfirmedSummary'}
items: {type: array, description: 已完成且永久只读的装柜明细, items: {$ref: '#/components/schemas/LoadingItem'}}
histories: {type: array, description: 计划操作历史, items: {$ref: '#/components/schemas/OperationHistory'}}
TransportUpdateRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
containerNo: {type: string, maxLength: 64, nullable: true, description: 集装箱号}
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
AdvanceRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
@@ -0,0 +1,51 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("货柜基础资料接口契约")
class ContainerControllerContractTest {
@Test
@DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() {
assertPermission("page", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:view");
assertPermission("create", "platform:firstleg:container:create");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
}
@Test
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
void documentsContainerContract() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
String section = openapi.substring(openapi.indexOf(" /api/platform/v1/containers:"),
openapi.indexOf(" /openapi/v1/inbound-measurements:"));
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("platform:firstleg:container:list")
.contains("EDITING")
.contains("LOADING")
.contains("COMPLETED")
.doesNotContain("tenantId")
.doesNotContain("accountId");
}
private static void assertPermission(String methodName, String authority) {
Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -48,7 +48,7 @@ class FbaPricingContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/fees/send:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
.contains("Idempotency-Key")
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("主流程已到 INBOUNDED,也可独立试算且不回退主流程状态")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 INBOUNDED 状态")
.doesNotContain("应收")
@@ -54,7 +54,7 @@ class InternalInboundOrderReferenceControllerTest {
@Test
void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() {
UnifiedInboundQuery request = new UnifiedInboundQuery(
10001L, null, null, "WAIT_SPLIT", null, null, null,
10001L, null, null, "INBOUNDED", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -0,0 +1,121 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.service.LoadingListService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("预装与装柜清单控制器契约")
class LoadingListControllerContractTest {
@Test
void permissionsMatch() throws Exception {
authority("preloadPage", "platform:firstleg:preload:list", AuthenticatedUser.class, PreloadQuery.class);
authority("preloadDetail", "platform:firstleg:preload:view", AuthenticatedUser.class, long.class, long.class);
authority("updateTransport", "platform:firstleg:preload:edit", AuthenticatedUser.class,
long.class, long.class, String.class, TransportUpdateRequest.class);
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class);
}
@Test
void writesAudited() throws Exception {
audit("updateTransport", "PRELOAD_TRANSPORT_UPDATE", long.class, long.class, long.class, String.class,
String.class, TransportUpdateRequest.class);
}
@Test
void openApiComplete() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(yaml)
.contains("EDITING 到 LOADING 再到 COMPLETED")
.contains("Idempotency-Key")
.contains("expectedVersion")
.contains("createdFrom")
.contains("createdTo")
.contains("platform:firstleg:loading-list:view")
.contains("PreloadPageEnvelope")
.contains("ConfirmedPageEnvelope")
.contains("PreloadSummary")
.contains("ConfirmedSummary")
.contains("LoadingItem")
.contains("OperationHistory")
.contains("customers")
.contains("productQuantity")
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
.doesNotContain("/items/{itemId}");
}
@Test
void doesNotExposeManualCompletion() {
assertThat(PlatformLoadingListController.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("complete"));
}
@Test
void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
assertThat(document.get("openapi")).isEqualTo("3.0.3");
assertThat((Map<?, ?>) document.get("paths")).hasSize(6);
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "AdvanceRequest");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount");
assertThat(properties(schemas, "LoadingItem")).containsKeys(
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
assertThat(properties(schemas, "OperationHistory")).containsKeys(
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
"containerNo", "voyageNo", "expectedVersion", "operationReason");
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schemaName) {
return stringMap(stringMap(schemas.get(schemaName)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
private static void authority(String name, String permission, Class<?>... parameterTypes) throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod(name, parameterTypes);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
private static void audit(String name, String event, Class<?>... parameterTypes) throws Exception {
Method method = LoadingListService.class.getDeclaredMethod(name, parameterTypes);
BusinessAudit audit = method.getAnnotation(BusinessAudit.class);
assertThat(audit.event()).isEqualTo(event);
assertThat(audit.resourceType()).isEqualTo("LOADING_PLAN");
assertThat(audit.resourceId()).isEqualTo("#planId");
}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformSmartLoadingControllerContractTest {
@Test void draftQueriesUseListPermission() throws Exception {
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest {
@Test
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
void mapsFinalBookingContract() throws Exception {
assertThat(ContainerBookingEntity.class.getAnnotation(TableName.class).value())
.isEqualTo("fl_container_booking");
String select = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("selectBooking", long.class).getAnnotation(Select.class).value());
assertThat(select)
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("plan_status")
.doesNotContain("tenant_id")
.doesNotContain("account_id");
}
@Test
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
void guardsUpdatesInSql() {
String allSql = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.map(method -> method.getAnnotation(Update.class))
.filter(annotation -> annotation != null)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + "\n" + right);
assertThat(allSql)
.contains("capacity_cbm")
.contains("p.plan_status&lt;&gt;'EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("version=#{expectedVersion}");
}
}
@@ -0,0 +1,121 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("主子入仓单SQL契约")
class InboundParentChildSqlContractTest {
private final String sql = loadSql();
@Test
void definesParentChildRelation() {
assertThat(sql)
.contains("'fl_inbound_order', 'inbound_role'")
.contains("'fl_inbound_order', 'parent_inbound_id'")
.contains("'fl_inbound_order', 'root_inbound_id'")
.contains("'fl_inbound_order', 'package_count'")
.contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'")
.contains("DROP INDEX uk_inbound_tenant_order")
.contains("uk_inbound_master_order")
.contains("uk_inbound_one_child")
.contains("DROP INDEX uk_inbound_parent_sequence");
}
@Test
void definesSplitLedger() {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_batch")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_item")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_history")
.contains("line_no INT UNSIGNED NOT NULL")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("package_count INT UNSIGNED NOT NULL")
.doesNotContain("quantity INT", "item_count INT", "carton_count INT");
}
@org.junit.jupiter.api.Disabled("旧拆分口径已由一主单一子单的批次汇总校验取代")
@Test
void protectsOwnershipAndTotals() {
assertThat(sql)
.contains("子入仓单必须绑定同一客户的有效主单")
.contains("parent_order.package_count > 0")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("parent_order.version > NEW.expected_parent_version")
.contains("主单可用箱数未归零或子单箱数合计不一致")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("子单承接箱数超过来源测量箱规箱数")
.contains("child_order.package_count >= NEW.package_count")
.contains("拆分数量台账不可修改")
.contains("拆分数量台账不可删除")
.contains("拆分历史不可修改")
.contains("拆分历史不可删除");
}
@Test
void validatesOneChildAndMultiSkuTotals() {
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("COUNT(DISTINCT item.child_inbound_id)")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'");
}
@Test
void tracesMeasurements() {
assertThat(sql)
.contains("'fl_inbound_measurement', 'split_batch_id'")
.contains("'fl_inbound_measurement', 'source_measurement_id'")
.contains("'fl_inbound_measurement_item', 'split_item_id'")
.contains("'fl_inbound_measurement_item', 'source_measurement_item_id'")
.contains("source_measurement_id BIGINT UNSIGNED NULL")
.contains("fk_inbound_split_item_measurement")
.contains("fk_measure_split_batch")
.contains("fk_measure_source_batch")
.contains("fk_measure_item_split_item")
.contains("fk_measure_item_source_item")
.contains("idx_measure_split_source")
.contains("idx_measure_item_split_source");
}
@Test
void syncsUnifiedMigration() throws Exception {
String module = normalize(sql).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(unified)
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql")
.contains(module);
}
private static String loadSql() {
try {
return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private static Path unifiedPath() {
Path working = Path.of("").toAbsolutePath();
Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return path;
}
private static String normalize(String value) {
return value.replace("\r\n", "\n").replace("\uFEFF", "");
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InboundStatusUnificationSqlContractTest {
@Test
void syncsStatusUnificationMigrationToUnifiedDeploymentScript() throws Exception {
String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql")
.getContentAsString(StandardCharsets.UTF_8)).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(unified)
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql")
.contains(module);
}
private static Path unifiedPath() {
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (Files.exists(path)) {
return path;
}
return Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
private static String normalize(String value) {
return value.replace("\r\n", "\n").replace("\uFEFF", "");
}
}
@@ -0,0 +1,93 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("清单持久化SQL契约")
class LoadingListQueryMapperContractTest {
@Test
void annotationScriptsShouldParseAsMyBatisStatements() {
Configuration configuration = new Configuration();
configuration.addMapper(LoadingListQueryMapper.class);
assertThat(configuration.hasStatement(
LoadingListQueryMapper.class.getName() + ".selectPreloadPage")).isTrue();
assertThat(configuration.hasStatement(
LoadingListQueryMapper.class.getName() + ".advance")).isTrue();
}
@Test
void bothListsShouldProjectTheSameLoadingPlanAggregate() throws Exception {
String preload = sql("selectPreloadPage", Select.class);
String confirmed = sql("selectConfirmedPage", Select.class);
assertThat(preload)
.contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i")
.contains("p.plan_status = 'LOADING'")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("PRELOAD_PUSHED");
assertThat(confirmed)
.contains("FROM fl_loading_plan p")
.contains("p.plan_status = 'COMPLETED'")
.contains("b.warehouse_id = #{query.warehouseId}")
.doesNotContain("query.accountId")
.doesNotContain("fl_loading_list");
}
@Test
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
String transport = sql("updateTransport", Update.class);
String advance = sql("advance", Update.class);
assertThat(touch)
.contains("plan_status = 'LOADING'")
.contains("version = #{expectedVersion}")
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item");
assertThat(advance)
.contains("plan_status = #{fromStatus}")
.contains("plan_status = #{toStatus}")
.contains("version = #{expectedVersion}")
.doesNotContain("ROLLED_BACK");
}
@Test
void everyWriteShouldAppendAnImmutableHistoryOperation() throws Exception {
String insert = sql("insertHistory", Insert.class);
assertThat(insert)
.contains("INSERT INTO fl_loading_plan_history")
.contains("operation_id")
.contains("before_snapshot")
.contains("after_snapshot")
.contains("operator_id");
}
private static String sql(String methodName, Class<?> annotationType) throws Exception {
Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
if (annotationType == Select.class) {
return String.join("\n", method.getAnnotation(Select.class).value());
}
if (annotationType == Update.class) {
return String.join("\n", method.getAnnotation(Update.class).value());
}
return String.join("\n", method.getAnnotation(Insert.class).value());
}
}
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingPickItemMapperContractTest {
@Test
void completionCheckCoversAllActivePlanItems() throws Exception {
Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class);
String sql = method.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("plan_item.loading_plan_id = #{planId}")
.contains("plan_item.deleted = 0")
.contains("pick_task.loading_plan_id = #{planId}")
.contains("pick_task.task_status = 'LOADING'")
.contains("pick_item.picked_package_count = plan_item.package_count");
}
@Test
void terminalStatesAreDefinedInSchema() throws Exception {
String sql = new org.springframework.core.io.ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
assertThat(sql)
.contains("'PENDING', 'PICKING', 'PICKED', 'PARTIAL', 'SKIPPED'")
.contains("action_type VARCHAR(24) NOT NULL DEFAULT 'PICK'")
.contains("operation_reason VARCHAR(500) NULL");
}
@Test
void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
assertThat(pick.getAnnotation(Update.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}")
.contains("plan_item.deleted = 0");
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSplitSettlementSqlContractTest {
@Test
void protectsOneChildAndPartialSettlement() throws Exception {
String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("uk_inbound_one_child")
.contains("uk_inbound_split_batch_line")
.contains("uk_inbound_split_batch_source_item")
.contains("child_order.package_count <> SUM(item.package_count)")
.contains("parent_order.split_status = 'PARTIALLY_SPLIT'")
.contains("SUM(split_item.package_count) > source_item.package_count")
.contains("CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0)");
}
@Test
void protectsPdaTerminalStates() throws Exception {
String sql = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("'PICKED', 'PARTIAL', 'SKIPPED'")
.contains("NEW.item_status = 'PARTIAL'")
.contains("NEW.item_status = 'SKIPPED'")
.contains("NEW.picked_package_count = OLD.picked_package_count");
}
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingCandidateMapperContractTest {
@Test
void selectsLatestApprovedBatch() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class);
String sql = String.join(" ", list.getAnnotation(Select.class).value());
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='INBOUNDED'")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
}
@Test
void detailUsesSameBatchRule() throws Exception {
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"candidate", long.class, long.class, long.class);
String sql = String.join(" ", detail.getAnnotation(Select.class).value());
assertThat(sql)
.contains("o.destination_warehouse_id destinationWarehouseId")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='INBOUNDED'")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
}
@@ -0,0 +1,177 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("智能装柜SQL契约")
class SmartLoadingSqlContractTest {
private static final Pattern PERMISSION_PATTERN = Pattern.compile("'((?:platform:firstleg:)[^']+)'");
private static final Set<String> PHASE3_PERMISSIONS = Set.of(
"platform:firstleg:container:list", "platform:firstleg:container:view",
"platform:firstleg:container:create", "platform:firstleg:container:edit",
"platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create",
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
"platform:firstleg:smart-loading:push", "platform:firstleg:preload:list",
"platform:firstleg:preload:view", "platform:firstleg:preload:edit",
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
@Test
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
.contains("CREATE TABLE IF NOT EXISTS fl_container_document")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_history")
.contains("account_id BIGINT UNSIGNED NOT NULL")
.contains("customer_name VARCHAR(128) NOT NULL")
.contains("warehouse_id BIGINT UNSIGNED NOT NULL")
.contains("cabinet_no VARCHAR(64) NOT NULL")
.contains("bill_of_lading_no VARCHAR(128)")
.contains("destination_warehouse_name VARCHAR(128)")
.contains("product_name_zh VARCHAR(255)")
.contains("product_name_en VARCHAR(255)")
.contains("product_image_file_id BIGINT UNSIGNED")
.contains("pallet_count INT UNSIGNED")
.contains("net_weight_kg DECIMAL(18,3)")
.contains("dimensional_weight_kg DECIMAL(18,3)")
.contains("product_quantity INT UNSIGNED")
.contains("units_per_carton INT UNSIGNED")
.contains("fba_tracking_no VARCHAR(128)")
.contains("shipment_no VARCHAR(128)")
.contains("shipping_mark VARCHAR(500)")
.contains("internal_remark VARCHAR(1000)")
.contains("customer_remark VARCHAR(1000)")
.contains("warehouse_remark VARCHAR(1000)");
}
@Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
.contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)")
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
.contains("UNIQUE KEY uk_container_booking_no (booking_no)")
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
.contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@Test
@DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色")
void shouldProvidePlatformPermissions() throws Exception {
assertThat(migration())
.contains("platform:firstleg:container:view")
.contains("platform:firstleg:smart-loading:push")
.contains("platform:firstleg:preload:confirm")
.contains("platform:firstleg:loading-list:view")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
.contains("智能装柜迁移失败:平台权限资源未完整创建")
.contains("智能装柜迁移失败:平台角色授权未完整写入");
}
@Test
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权")
void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration());
assertPermissionContract(unifiedMigration());
assertThat(unifiedMigration()).contains(migration().trim());
}
@Test
@DisplayName("计划状态只允许编辑中、装柜中和已完成单向推进")
void shouldEnforceOneWayStatusAndPermanentFreeze() throws Exception {
assertThat(migration())
.contains("CHECK (plan_status IN ('EDITING', 'LOADING', 'COMPLETED'))")
.contains("OLD.plan_status = 'EDITING' AND NEW.plan_status IN ('EDITING', 'LOADING')")
.contains("NEW.plan_status IN ('LOADING', 'COMPLETED')")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("LEFT JOIN fl_loading_pick_item pick_item")
.contains("pick_item.picked_package_count <> pick_item.planned_package_count")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
.contains("装柜完成后的明细永久冻结")
.contains("装柜中仅允许修改集装箱号与航次")
.contains("装柜完成后货柜全部只读,不允许修改")
.doesNotContain("preload_status")
.doesNotContain("PRELOAD_PUSHED")
.doesNotContain("PRELOADING")
.doesNotContain("PRELOAD_COMPLETED")
.doesNotContain("CONFIRMED")
.doesNotContain("ROLLED_BACK")
.doesNotContain("WITHDRAWN");
}
@Test
@DisplayName("PDA三表、唯一索引、向上取整和不可变审计契约完整")
void shouldProvidePdaContract() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
.contains("UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id)")
.contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)")
.contains("上层拆分按业务规则向上取整")
.contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)")
.contains("trg_loading_pick_task_before_update")
.contains("trg_loading_pick_item_before_update")
.contains("trg_loading_pick_item_before_delete")
.contains("trg_loading_pick_history_before_update")
.contains("trg_loading_pick_history_before_delete")
.contains("PDA唯一索引缺失")
.contains(")) <> 16 THEN");
}
private static String migration() throws Exception {
return new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
private static String unifiedMigration() throws Exception {
Path working = Path.of("").toAbsolutePath();
Path file = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(file)) {
file = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return Files.readString(file, StandardCharsets.UTF_8);
}
private static void assertPermissionContract(String sql) {
int message = sql.indexOf("智能装柜迁移失败:平台角色授权未完整写入");
assertThat(message).isGreaterThan(0);
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO iam_role_permission", message);
int grantEnd = sql.indexOf(';', grantStart);
assertThat(permissions(sql.substring(grantStart, grantEnd))).isEqualTo(PHASE3_PERMISSIONS);
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
String assertion = sql.substring(assertionStart, message);
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
assertThat(assertion).contains("<> 32 THEN");
}
private static Set<String> permissions(String sql) {
Matcher matcher = PERMISSION_PATTERN.matcher(sql);
return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet());
}
}
@@ -0,0 +1,205 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料服务")
@SpringJUnitConfig(ContainerService.class)
class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
}
@Test
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
void createsContainerBooking() {
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001"));
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
}
@Test
@DisplayName("智能装柜草稿阶段允许完整编辑")
void updatesAllFieldsWhileEditing() {
ContainerBookingEntity current = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW")));
assertThat(current.getCabinetNo()).isEqualTo("柜-NEW");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("进入预装柜中后拒绝完整编辑")
void rejectsFullUpdateAfterPreloadPush() {
when(repository.find(11L)).thenReturn(booking("LOADING", 2));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("只能修改集装箱号和航次");
verify(repository, never()).updateDraft(any(), any(Integer.class),
any(Long.class), any(String.class));
}
@Test
@DisplayName("预装柜中和预装柜完成只更新集装箱号与航次")
void updatesTransportWhitelistAfterPush() {
for (String status : new String[] {"LOADING"}) {
ContainerBookingEntity current = booking(status, 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
}
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
}
@Test
@DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() {
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("全部只读");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test
@DisplayName("旧版本编辑请求触发乐观锁冲突")
void rejectsStaleVersion() {
when(repository.find(11L)).thenReturn(booking("EDITING", 3));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request("柜-NEW"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
}
@Test
@DisplayName("新增时校验重复集装箱号和内部柜号")
void rejectsDuplicateNumbers() {
when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
void replacesRequestedWarehouseName() {
service.create(9L, "admin", request("柜-001"));
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓");
}
@Test
@DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜")
void rejectsUnavailableWarehouseService() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
.isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用");
verify(repository, never()).insert(any());
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE")));
}
private static ContainerBookingEntity booking(String status, int version) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(11L);
value.setContainerNo("MSCU1234567");
value.setCabinetNo("柜-001");
value.setPlanStatus(status);
value.setVersion(version);
return value;
}
private static SaveRequest request(String cabinetNo) {
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null);
}
}
@@ -113,7 +113,9 @@ class FbaPricingServiceTest {
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("INBOUNDED");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
@@ -121,8 +123,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG",
new BigDecimal("167.000"));
}
@@ -130,7 +132,7 @@ class FbaPricingServiceTest {
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("INBOUNDED");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -147,7 +149,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@@ -184,7 +186,7 @@ class FbaPricingServiceTest {
void returnsOriginalSnapshotForRepeatedCalculation() {
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setProcessStatus("INBOUNDED");
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
@@ -194,8 +196,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
assertThat(response.feeId()).isEqualTo(501L);
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(priceBookEstimator, never()).estimate(any());
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository, never()).matchRules(any(), any());
verify(repository, never()).insertFee(any());
}
@@ -2,8 +2,11 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
@@ -31,6 +34,7 @@ import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -44,6 +48,7 @@ import static org.mockito.Mockito.when;
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private MeasurementImageClient imageClient;
@Resource private InboundMeasurementService service;
@@ -480,7 +485,7 @@ class InboundMeasurementServiceTest {
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
InboundOrderEntity inbound = inbound("INBOUNDED");
inbound.setMeasureReviewStatus("APPROVED");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
@@ -498,13 +503,40 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.processStatus()).isEqualTo("INBOUNDED");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
verify(repository, never()).updateMeasurement(any());
verify(repository).updateInbound(inbound);
}
@Test
void savingDraftValidatesCargoAndPersistsInboundDetailBinding() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
inbound.setOrderId(80L);
InboundMeasurementEntity saved = new InboundMeasurementEntity();
saved.setId(30L);
saved.setVersion(0);
saved.setStatus("DRAFT");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(orderCargoClient.detail(eq(""), eq(1L), eq(80L), eq(80001L))).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(80001L, 80L, 1, "Test cargo",
2, new BigDecimal("6.500"), new BigDecimal("0.0480"))));
when(batchService.saveBatch(any())).thenReturn(saved);
InboundMeasurementResponses.DraftSaved result = service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null, null, null, null,
List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("3.250"), null, null, null, null, null, null))));
assertThat(result.measurementId()).isEqualTo(30L);
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
verify(batchService).saveBatch(command.capture());
assertThat(command.getValue().items().get(0).inboundDetailId()).isEqualTo(80001L);
verify(orderCargoClient).detail("", 1L, 80L, 80001L);
}
private InboundOrderEntity inbound(String status) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
@@ -0,0 +1,188 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow;
import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("预装与装柜清单状态服务")
@SpringJUnitConfig(classes = {LoadingListService.class, LoadingListServiceTest.Config.class})
class LoadingListServiceTest {
@MockBean
private LoadingListRepository repository;
@MockBean
private LoadingPickRepository pickRepository;
@Resource
private LoadingListService service;
@TestConfiguration
static class Config {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
@Test
void shouldRejectEditingStatusFromPreloadList() {
PreloadQuery query = new PreloadQuery(null, "EDITING", 100L, null, null, 1, 20);
assertThatThrownBy(() -> service.preloadPage(query))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.BAD_REQUEST);
}
@Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("LOADING", 3);
before.setContainerNo("OLD");
before.setVoyageNo("001E");
AggregateRow after = row("LOADING", 4);
after.setContainerNo("CSLU1234567");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(" CSLU1234567 ", "023E", 3, "补录箱号"));
verify(repository).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L),
eq("operator"), eq("补录箱号"));
}
@Test
void shouldRejectTransportUpdateAfterConfirmation() {
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(row("COMPLETED", 5));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest("CSLU1234567", "023E", 5, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString());
}
@Test
void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2);
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(repository.advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator"))
.thenReturn(1);
service.finishFromPda(10L, 21L, 9L, "operator");
verify(repository).advance(10L, "LOADING", "COMPLETED", 2, 9L, "operator");
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("全部箱数已完成PDA拣货"));
}
@Test
void shouldRejectAutomaticCompletionWhenPdaItemsRemain() {
when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false);
assertThatThrownBy(() -> service.finishFromPda(10L, 21L, 9L, "operator"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).advance(eq(10L), anyString(), anyString(),
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
}
@Test
void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("LOADING", 3);
AggregateRow after = row("LOADING", 4);
after.setContainerNo("CSLU1234567");
after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
ArgumentCaptor<String> snapshot = ArgumentCaptor.forClass(String.class);
TransportUpdateRequest request = new TransportUpdateRequest("CSLU1234567", "023E", 3, null);
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), snapshot.capture(), eq(9L),
eq("operator"), eq(null));
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot(snapshot.getValue());
when(repository.history(anyString())).thenReturn(replay);
when(repository.aggregate(10L)).thenReturn(after);
service.updateTransport(10L, 100L, 9L, "operator", "key-1", request);
verify(repository, times(1)).updateTransport(10L, 3, "CSLU1234567", "023E", 9L, "operator");
}
@Test
void confirmedPageAlwaysUsesRepositoryConfirmedProjection() {
ConfirmedQuery query = new ConfirmedQuery(" plan ", 100L, null, null, 1, 20);
when(repository.confirmedPage(org.mockito.ArgumentMatchers.any())).thenReturn(
new PageResult<>(List.of(), 1, 20, 0, 0));
service.confirmedPage(query);
verify(repository).confirmedPage(org.mockito.ArgumentMatchers.argThat(value ->
"plan".equals(value.keyword())));
}
@Test
void rejectsPlanOutsideRequestedWarehouse() {
when(repository.aggregate(10L)).thenReturn(row("LOADING", 1));
assertThatThrownBy(() -> service.preloadDetail(10L, 200L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
row.setPlanNo("PL-10");
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setVersion(version);
return row;
}
}
@@ -0,0 +1,85 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingPickService.class)
class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private LoadingListService loadingListService;
@Resource
private LoadingPickService service;
@Test
void keepsPlanLoadingWhenItemsRemain() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.history("pick-1")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(false);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-1"), 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@Test
void completesPlanWhenLastItemIsPicked() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.history("pick-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-2"), 9L, "operator");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
private static LoadingPickTaskEntity task() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);
task.setLoadingPlanId(10L);
task.setContainerNo("CSLU1234567");
task.setTaskStatus("LOADING");
task.setVersion(0);
return task;
}
private static LoadingPickItemEntity item() {
LoadingPickItemEntity item = new LoadingPickItemEntity();
item.setId(30L);
item.setPickTaskId(20L);
item.setLoadingPlanItemId(40L);
item.setInboundId(50L);
item.setInboundDetailId(60L);
item.setPlannedPackageCount(2);
item.setPickedPackageCount(0);
item.setItemStatus("PENDING");
item.setVersion(0);
return item;
}
}
@@ -79,6 +79,7 @@ class MeasurementBatchServiceTest {
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500");
}
@@ -131,7 +132,7 @@ class MeasurementBatchServiceTest {
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item(
"001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), unitWeight, 500L, null,
new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注");
}
@@ -72,6 +72,7 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any());
}
@@ -102,6 +103,7 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(PLATFORM_RESOURCE_TENANT),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.200000")),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.any());
}
@@ -126,7 +128,7 @@ class MeasurementLocationOccupationServiceTest {
assertThat(release.getValue().tenantId()).isEqualTo(PLATFORM_RESOURCE_TENANT);
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations).replace(BUSINESS_TENANT, INBOUND, WAREHOUSE, PLATFORM_RESOURCE_TENANT,
new BigDecimal("1.200000"), batch("RESERVED"));
new BigDecimal("1.200000"), 20, batch("RESERVED"));
}
private static InboundProperties properties() {
@@ -150,6 +152,7 @@ class MeasurementLocationOccupationServiceTest {
value.setWarehouseId(WAREHOUSE);
value.setInboundCode("RK-001");
value.setActualVolumeCbm(new BigDecimal("1.0000"));
value.setTotalPackageCount(20);
return value;
}
@@ -77,7 +77,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED");
}
@Test
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PdaPickSettlementPlanTest {
@Test
void groupsPartialItemsByInbound() {
var resolution = PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 6, "PARTIAL"),
item(2L, 12L, 101L, 1002L, 8, 3, "PARTIAL"),
item(3L, 13L, 102L, 2001L, 4, 0, "SKIPPED"),
item(4L, 14L, 103L, 3001L, 5, 5, "PICKED")));
assertThat(resolution.partialByInbound()).containsOnlyKeys(101L);
assertThat(resolution.partialByInbound().get(101L))
.extracting(PdaPickSettlementPlan.SplitLine::remainingPackageCount)
.containsExactlyInAnyOrder(4, 5);
assertThat(resolution.skippedPlanItemIds()).containsExactly(13L);
}
@Test
void rejectsUnfinishedItem() {
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 2, "PICKING"))))
.hasMessageContaining("未结束");
}
@Test
void rejectsInvalidPartialCount() {
assertThatThrownBy(() -> PdaPickSettlementPlan.resolve(List.of(
item(1L, 11L, 101L, 1001L, 10, 10, "PARTIAL"))))
.hasMessageContaining("不完整或箱数不合法");
}
private static LoadingPickItemEntity item(long id, long planItemId, long inboundId, long detailId,
int planned, int picked, String status) {
LoadingPickItemEntity item = new LoadingPickItemEntity();
item.setId(id);
item.setLoadingPlanItemId(planItemId);
item.setInboundId(inboundId);
item.setInboundDetailId(detailId);
item.setPlannedPackageCount(planned);
item.setPickedPackageCount(picked);
item.setItemStatus(status);
return item;
}
}

Some files were not shown because too many files have changed in this diff Show More