Test #1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1556
-273
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 称重、费用试算、费用确认、入库完成。
|
||||
@@ -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` | 图片处理、供应商调用或解析失败。 |
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
+63
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+92
@@ -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));
|
||||
}
|
||||
}
|
||||
+71
@@ -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()));
|
||||
}
|
||||
}
|
||||
+29
@@ -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() {}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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} "
|
||||
+2
-2
@@ -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 >= #{query.estimatedSailingFrom})
|
||||
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at <= #{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<>'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 >= #{query.createdFrom}</if>
|
||||
<if test="query.createdTo != null">AND p.created_at <= #{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 >= #{query.createdFrom}</if>
|
||||
<if test="query.createdTo != null">AND p.created_at <= #{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 < #{finalCount}
|
||||
AND #{finalCount} < 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"));
|
||||
}
|
||||
|
||||
+22
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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());
|
||||
|
||||
+10
-2
@@ -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: 操作原因}
|
||||
+51
@@ -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("应收")
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
+121
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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+"')");}
|
||||
}
|
||||
+46
@@ -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<>'EDITING'")
|
||||
.contains("p.plan_status='COMPLETED'")
|
||||
.contains("version=#{expectedVersion}");
|
||||
}
|
||||
}
|
||||
+121
@@ -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", "");
|
||||
}
|
||||
}
|
||||
+35
@@ -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", "");
|
||||
}
|
||||
}
|
||||
+93
@@ -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());
|
||||
}
|
||||
}
|
||||
+49
@@ -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");
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
+177
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+34
-2
@@ -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"), "备注");
|
||||
}
|
||||
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user