Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
|
||||
| 任务关键词 | 首选服务 | 先看入口 |
|
||||
|---|---|---|
|
||||
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
||||
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
|
||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||
@@ -27,7 +27,7 @@
|
||||
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
|
||||
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
|
||||
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 |
|
||||
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。首张称重确认分配整票块,测量提交只复用;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
|
||||
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
|
||||
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
|
||||
@@ -36,8 +36,9 @@
|
||||
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 |
|
||||
| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 |
|
||||
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
|
||||
| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 |
|
||||
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 客户资金账户、充值、授信与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 Finance `FinanceWalletService` 拥有唯一 CNY 账本;平台/客户入口分别见 `PlatformFinanceWalletController`、`ClientFinanceWalletController`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 |
|
||||
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 |
|
||||
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
|
||||
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||
@@ -50,8 +51,8 @@
|
||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只分页返回仍有入仓码待生成有效独立计划的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
|
||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
|
||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
|
||||
@@ -112,6 +113,7 @@
|
||||
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
|
||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
|
||||
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||
|
||||
@@ -18019,6 +18019,107 @@ DELIMITER ;
|
||||
CALL assert_wallet_event_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_wallet_event_schema;
|
||||
|
||||
-- 目的港码头拖车、提柜与还空闭环(模块来源:phase3-terminal-drayage-schema.sql)
|
||||
USE baiyue_firstleg;
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
|
||||
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照',
|
||||
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1 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 '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
|
||||
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
|
||||
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL)
|
||||
AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL)
|
||||
AND (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
|
||||
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', action VARCHAR(32) NOT NULL COMMENT '业务动作',
|
||||
from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', to_status VARCHAR(32) NOT NULL COMMENT '操作后状态',
|
||||
from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', to_version INT UNSIGNED NOT NULL COMMENT '操作后版本',
|
||||
reason VARCHAR(500) NULL COMMENT '操作说明', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id),
|
||||
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
|
||||
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
|
||||
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
|
||||
('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '业务动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key),
|
||||
KEY idx_terminal_drayage_operation(container_booking_id,created_at),
|
||||
CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作';
|
||||
|
||||
-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。
|
||||
ALTER TABLE fl_terminal_drayage
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
|
||||
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
|
||||
MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
|
||||
MODIFY COLUMN fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照',
|
||||
MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
|
||||
MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
|
||||
MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
|
||||
MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
|
||||
MODIFY COLUMN created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
MODIFY COLUMN created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
MODIFY COLUMN updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
|
||||
MODIFY COLUMN updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照',
|
||||
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间';
|
||||
|
||||
ALTER TABLE fl_terminal_drayage_history
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
|
||||
MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
|
||||
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
|
||||
MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '业务动作',
|
||||
MODIFY COLUMN from_status VARCHAR(32) NOT NULL COMMENT '操作前状态',
|
||||
MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '操作后状态',
|
||||
MODIFY COLUMN from_version INT UNSIGNED NOT NULL COMMENT '操作前版本',
|
||||
MODIFY COLUMN to_version INT UNSIGNED NOT NULL COMMENT '操作后版本',
|
||||
MODIFY COLUMN reason VARCHAR(500) NULL COMMENT '操作说明',
|
||||
MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
MODIFY COLUMN operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间';
|
||||
|
||||
ALTER TABLE fl_terminal_drayage_operation
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID',
|
||||
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
|
||||
MODIFY COLUMN operation VARCHAR(32) NOT NULL COMMENT '业务动作',
|
||||
MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256',
|
||||
MODIFY COLUMN result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本',
|
||||
MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间';
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_terminal_drayage_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg'
|
||||
AND TABLE_NAME IN ('fl_terminal_drayage','fl_terminal_drayage_history','fl_terminal_drayage_operation')) <> 3 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_terminal_drayage' AND INDEX_NAME='uk_terminal_drayage_container' AND NON_UNIQUE=0) <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='baiyue_firstleg'
|
||||
AND TABLE_NAME='fl_terminal_drayage_operation' AND INDEX_NAME='uk_terminal_drayage_operation' AND NON_UNIQUE=0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车唯一约束不完整';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_terminal_drayage_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema;
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
@@ -219,6 +219,7 @@ SET @ddl = (SELECT IF(EXISTS (
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key'
|
||||
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash'
|
||||
@@ -517,6 +518,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||
ALTER TABLE ll_order
|
||||
MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||
@@ -1754,6 +1757,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_code VARCHAR(64) NULL COMMENT ''资金错误码'' AFTER wallet_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_message'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_message VARCHAR(500) NULL COMMENT ''资金错误说明'' AFTER wallet_error_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_command_event_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_command_event_id VARCHAR(64) NULL COMMENT ''当前资金指令事件ID'' AFTER wallet_error_message')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
|
||||
@@ -1769,3 +1777,177 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id),
|
||||
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
|
||||
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
|
||||
|
||||
|
||||
-- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_order' AND column_name='overseas_warehouse_code'),
|
||||
'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''',
|
||||
'ALTER TABLE ll_order ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_order' AND column_name='receiving_warehouse_version'),
|
||||
'ALTER TABLE ll_order MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本''',
|
||||
'ALTER TABLE ll_order ADD COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本'' AFTER overseas_warehouse_name'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_order
|
||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单默认洛杉矶仓',
|
||||
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照';
|
||||
|
||||
ALTER TABLE ll_inbound_plan
|
||||
MODIFY COLUMN overseas_warehouse_id BIGINT NULL COMMENT '从尾程订单继承的平台预分配海外接柜仓ID';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code'),
|
||||
'ALTER TABLE ll_receiving_container_session MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''',
|
||||
'ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_receiving_container_session
|
||||
MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID',
|
||||
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation (
|
||||
id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键',
|
||||
request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要',
|
||||
result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称',
|
||||
remark VARCHAR(500) NULL COMMENT '接柜仓修改备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key),
|
||||
KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录';
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_receiving_warehouse_assignment_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_receiving_warehouse_assignment_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_order' AND column_name IN ('overseas_warehouse_code','receiving_warehouse_version')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单海外接柜仓字段未完整部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA接柜会话仓库编码字段未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_receiving_warehouse_operation') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='柜级海外接柜仓修改幂等审计表未部署';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_receiving_warehouse_assignment_ready();
|
||||
DROP PROCEDURE assert_receiving_warehouse_assignment_ready;
|
||||
|
||||
SELECT 'PASS' AS migration_status, '海外接柜仓预分配与柜级修改结构已就绪' AS message;
|
||||
|
||||
-- 一件代发来源托盘与海外仓实物托盘分离,实物绑定不得覆盖来源身份。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inbound_plan_pallet' AND column_name='source_pallet_no'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''一件代发不可变来源托盘号快照'' AFTER pallet_no'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_inbound_plan_pallet
|
||||
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inbound_stock_receipt' AND column_name='source_pallet_no'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''一件代发不可变来源托盘号快照'' AFTER pallet_no'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
ALTER TABLE ll_inbound_stock_receipt
|
||||
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '海外仓实物托盘码';
|
||||
|
||||
-- 尾程单票人工计划、暂扣与人工展示节点。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_command_record (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作', operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
|
||||
result_ref VARCHAR(128) NULL COMMENT '结果业务引用', result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照',
|
||||
result_version INT NOT NULL COMMENT '命令成功后的订单版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key),
|
||||
KEY idx_ll_order_command_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录';
|
||||
CREATE TABLE IF NOT EXISTS ll_order_hold_history (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID',
|
||||
held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因',
|
||||
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), KEY idx_ll_order_hold_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史';
|
||||
CREATE TABLE IF NOT EXISTS ll_order_manual_node (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID',
|
||||
node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码', node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空',
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', order_version INT NOT NULL COMMENT '最近编辑后的订单版本',
|
||||
updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实';
|
||||
CREATE TABLE IF NOT EXISTS ll_order_manual_node_history (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID',
|
||||
nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', created_at DATETIME(3) NOT NULL COMMENT '操作时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key),
|
||||
KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_tail_order_commands_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_order' AND column_name IN
|
||||
('hold_chargeable','hold_reason','manual_node_override','version')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单人工计划、暂扣与人工节点字段未完整部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name IN ('ll_order_command_record','ll_order_hold_history','ll_order_manual_node','ll_order_manual_node_history')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令审计表未完整部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_inbound_plan' AND column_name IN
|
||||
('planned_arrival_date','pallet_type','assigned_flag','merge_required','urgent_flag','creation_mode','plan_remark')) <> 7 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程人工计划字段未完整部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_order_command_record' AND index_name='uk_ll_order_command_key' AND non_unique=0) <> 5
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_order_manual_node' AND index_name='uk_ll_order_manual_node' AND non_unique=0) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令幂等或人工节点唯一约束未完整部署';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_tail_order_commands_ready();
|
||||
DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready;
|
||||
|
||||
SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
|
||||
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` |
|
||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 |
|
||||
|
||||
@@ -16,7 +16,7 @@ const sliceRules = [
|
||||
['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/],
|
||||
['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
|
||||
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/],
|
||||
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
|
||||
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
|
||||
['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
|
||||
['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/],
|
||||
['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "目的港码头拖车")
|
||||
@RequestMapping("/api/platform/v1/terminal-drayages")
|
||||
public class PlatformTerminalDrayageController {
|
||||
private final TerminalDrayageService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<List<View>> views(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Size(min = 1, max = 200)
|
||||
List<@Positive Long> containerBookingIds) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.views(containerBookingIds));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.detail(containerBookingId));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerBookingId}/history")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.histories(containerBookingId));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/fleet")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@Operation(summary = "选择或更换目的港码头车队")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "选择目的港码头车队")
|
||||
public ApiResponse<View> selectFleet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody FleetSelectionRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/pickup-appointment")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@Operation(summary = "预约目的港提柜时间")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "预约目的港提柜")
|
||||
public ApiResponse<View> schedulePickup(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody PickupAppointmentRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/pickup")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@Operation(summary = "确认目的港实际提柜")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
|
||||
public ApiResponse<View> recordPickup(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualTimeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/empty-return")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@Operation(summary = "确认实际还空柜")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "确认实际还空柜")
|
||||
public ApiResponse<View> recordEmptyReturn(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualTimeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class TerminalDrayageDtos {
|
||||
private TerminalDrayageDtos() {}
|
||||
|
||||
public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull @Positive Long fleetSupplierId,
|
||||
@Size(max = 128) String fleetSupplierName,
|
||||
@Size(max = 500) String reason) {}
|
||||
|
||||
public record PickupAppointmentRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime pickupAppointmentAt,
|
||||
@Size(max = 500) String reason) {}
|
||||
|
||||
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime actualAt,
|
||||
@Size(max = 500) String reason) {}
|
||||
|
||||
public record Warning(String status, LocalDateTime deadlineAt,
|
||||
long overdueHours, long overdueDays) {}
|
||||
|
||||
public record View(Long containerBookingId, String containerNo,
|
||||
Long oceanBookingId, String status, Integer version,
|
||||
Long fleetSupplierId, String fleetSupplierName,
|
||||
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt,
|
||||
LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt,
|
||||
LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning,
|
||||
List<String> allowedActions, LocalDateTime updatedAt) {}
|
||||
|
||||
public record History(String action, String fromStatus, String toStatus,
|
||||
Integer fromVersion, Integer toVersion, String reason,
|
||||
Long operatorId, String operatorName, LocalDateTime createdAt) {}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_terminal_drayage")
|
||||
public class TerminalDrayageEntity {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long containerBookingId;
|
||||
private Long fleetSupplierId;
|
||||
private String fleetSupplierName;
|
||||
private LocalDateTime pickupAppointmentAt;
|
||||
private LocalDateTime actualPickupAt;
|
||||
private LocalDateTime estimatedEmptyReturnAt;
|
||||
private LocalDateTime actualEmptyReturnAt;
|
||||
private Integer version;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
private LocalDateTime createdAt;
|
||||
private Long updatedBy;
|
||||
private String updatedByName;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
|
||||
@Select("""
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt,
|
||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
||||
FROM fl_container_booking b
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
|
||||
SELECT q2.id FROM fl_carrier_schedule_query q2
|
||||
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
|
||||
WHERE b.id=#{containerBookingId} FOR UPDATE
|
||||
""")
|
||||
Context lockContext(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt,
|
||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
||||
FROM fl_container_booking b
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
|
||||
SELECT q2.id FROM fl_carrier_schedule_query q2
|
||||
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
|
||||
WHERE b.id=#{containerBookingId}
|
||||
""")
|
||||
Context context(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt,
|
||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
||||
FROM fl_container_booking b
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
|
||||
SELECT q2.id FROM fl_carrier_schedule_query q2
|
||||
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
ORDER BY b.id
|
||||
</script>
|
||||
""")
|
||||
List<Context> contexts(@Param("containerBookingIds") List<Long> containerBookingIds);
|
||||
|
||||
@Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId}")
|
||||
TerminalDrayageEntity find(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
|
||||
TerminalDrayageEntity lock(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT * FROM fl_terminal_drayage WHERE container_booking_id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<TerminalDrayageEntity> findAll(@Param("containerBookingIds") List<Long> containerBookingIds);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId},
|
||||
fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt},
|
||||
actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt},
|
||||
actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId},
|
||||
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}
|
||||
""")
|
||||
int updateAggregate(@Param("value") TerminalDrayageEntity value,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("""
|
||||
SELECT action,from_status AS fromStatus,to_status AS toStatus,from_version AS fromVersion,
|
||||
to_version AS toVersion,reason,operator_id AS operatorId,operator_name AS operatorName,created_at AS createdAt
|
||||
FROM fl_terminal_drayage_history WHERE container_booking_id=#{containerBookingId}
|
||||
ORDER BY created_at DESC,id DESC
|
||||
""")
|
||||
List<History> histories(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status,
|
||||
from_version,to_version,reason,operator_id,operator_name)
|
||||
VALUES(#{id},#{drayageId},#{containerBookingId},#{action},#{fromStatus},#{toStatus},#{fromVersion},
|
||||
#{toVersion},#{reason},#{operatorId},#{operatorName})
|
||||
""")
|
||||
int insertHistory(@Param("id") long id, @Param("drayageId") long drayageId,
|
||||
@Param("containerBookingId") long containerBookingId,
|
||||
@Param("action") String action, @Param("fromStatus") String fromStatus,
|
||||
@Param("toStatus") String toStatus, @Param("fromVersion") int fromVersion,
|
||||
@Param("toVersion") int toVersion, @Param("reason") String reason,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
@Select("""
|
||||
SELECT operation,request_hash AS requestHash,result_version AS resultVersion
|
||||
FROM fl_terminal_drayage_operation
|
||||
WHERE container_booking_id=#{containerBookingId} AND idempotency_key=#{key}
|
||||
""")
|
||||
Operation operation(@Param("containerBookingId") long containerBookingId, @Param("key") String key);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_terminal_drayage_operation(id,container_booking_id,operation,idempotency_key,request_hash,
|
||||
result_version,operator_id) VALUES(#{id},#{containerBookingId},#{operation},#{key},#{hash},
|
||||
#{resultVersion},#{operatorId})
|
||||
""")
|
||||
int insertOperation(@Param("id") long id, @Param("containerBookingId") long containerBookingId,
|
||||
@Param("operation") String operation, @Param("key") String key,
|
||||
@Param("hash") String hash, @Param("resultVersion") int resultVersion,
|
||||
@Param("operatorId") long operatorId);
|
||||
|
||||
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
|
||||
LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode,
|
||||
LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt,
|
||||
LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {}
|
||||
record Operation(String operation, String requestHash, Integer resultVersion) {}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning;
|
||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TerminalDrayageService {
|
||||
private static final int EMPTY_RETURN_WARNING_HOURS = 72;
|
||||
private final TerminalDrayageMapper mapper;
|
||||
private final WarehouseSupplierSnapshotService suppliers;
|
||||
private final CarrierScheduleQueryService carrierSchedules;
|
||||
private final ObjectMapper objectMapper;
|
||||
private Clock clock = Clock.systemDefaultZone();
|
||||
|
||||
public View detail(long containerBookingId) {
|
||||
return view(requiredContext(containerBookingId), mapper.find(containerBookingId));
|
||||
}
|
||||
|
||||
public List<View> views(List<Long> containerBookingIds) {
|
||||
if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of();
|
||||
List<Long> ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值");
|
||||
Map<Long, TerminalDrayageEntity> rows = new HashMap<>();
|
||||
mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value));
|
||||
return mapper.contexts(ids).stream().map(value -> view(value, rows.get(value.containerBookingId()))).toList();
|
||||
}
|
||||
|
||||
public List<History> histories(long containerBookingId) {
|
||||
requiredContext(containerBookingId);
|
||||
return mapper.histories(containerBookingId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
FleetSelectionRequest request) {
|
||||
String action = "SELECT_FLEET";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireArrived(context);
|
||||
TerminalDrayageEntity value = mapper.lock(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
|
||||
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
if (value == null) value = create(containerBookingId, operatorId, operatorName);
|
||||
value.setFleetSupplierId(fleet.supplierId());
|
||||
value.setFleetSupplierName(fleet.name());
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(context, value, action, key, request, fromStatus, fromVersion,
|
||||
request.reason(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
PickupAppointmentRequest request) {
|
||||
String action = "SCHEDULE_PICKUP";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireArrived(context);
|
||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
|
||||
if (context.actualArrivalNodeAt() != null && request.pickupAppointmentAt().isBefore(context.actualArrivalNodeAt())) {
|
||||
throw badRequest("预约提柜时间不能早于实际到港时间");
|
||||
}
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
value.setPickupAppointmentAt(request.pickupAppointmentAt());
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(context, value, action, key, request, fromStatus, fromVersion,
|
||||
request.reason(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
ActualTimeRequest request) {
|
||||
String action = "RECORD_PICKUP";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
|
||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) {
|
||||
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
||||
}
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
|
||||
if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
request.reason(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
value.setActualPickupAt(request.actualAt());
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
ActualTimeRequest request) {
|
||||
String action = "RECORD_EMPTY_RETURN";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
LocalDateTime pickupAt = actualPickupAt(context, value);
|
||||
if (pickupAt == null) throw conflict("实际提柜后才能确认还柜");
|
||||
if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
|
||||
LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt())
|
||||
? pickupAt : context.unloadingCompletedAt();
|
||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
||||
request.reason(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
value.setActualEmptyReturnAt(request.actualAt());
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
String action, String key, String reason, long operatorId, String operatorName) {
|
||||
if (context.queryId() == null || context.queryVersion() == null) {
|
||||
throw conflict("货柜尚未建立船节点跟踪任务");
|
||||
}
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
if (current != null && current.sequence() >= target.sequence()) return;
|
||||
carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName,
|
||||
"td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()),
|
||||
new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
||||
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
|
||||
}
|
||||
|
||||
private View finish(Context context, TerminalDrayageEntity value, String action, String key, Object request,
|
||||
String fromStatus, int fromVersion, String reason,
|
||||
long operatorId, String operatorName) {
|
||||
TerminalDrayageEntity stored = mapper.find(context.containerBookingId());
|
||||
Context currentContext = requiredContext(context.containerBookingId());
|
||||
String toStatus = status(currentContext, stored);
|
||||
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action,
|
||||
fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName);
|
||||
mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request),
|
||||
version(stored), operatorId);
|
||||
return view(currentContext, stored);
|
||||
}
|
||||
|
||||
private View replay(Context context, String action, String key, Object request) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符");
|
||||
Operation operation = mapper.operation(context.containerBookingId(), key.trim());
|
||||
if (operation == null) return null;
|
||||
if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) {
|
||||
throw conflict("Idempotency-Key已被不同的码头拖车操作占用");
|
||||
}
|
||||
return view(context, mapper.find(context.containerBookingId()));
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) {
|
||||
TerminalDrayageEntity value = new TerminalDrayageEntity();
|
||||
value.setId(IdWorker.getId()); value.setContainerBookingId(containerBookingId); value.setVersion(1);
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
|
||||
return value;
|
||||
}
|
||||
|
||||
private void persist(TerminalDrayageEntity value, int expectedVersion, long operatorId, String operatorName) {
|
||||
if (expectedVersion == 0) {
|
||||
mapper.insert(value);
|
||||
return;
|
||||
}
|
||||
if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent();
|
||||
}
|
||||
|
||||
private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) {
|
||||
if (context.unloadingCompletedAt() != null) {
|
||||
value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
|
||||
}
|
||||
}
|
||||
|
||||
private View view(Context context, TerminalDrayageEntity value) {
|
||||
String status = status(context, value);
|
||||
LocalDateTime deadline = context.unloadingCompletedAt() == null ? null
|
||||
: context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
|
||||
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
|
||||
String warningStatus;
|
||||
long overdueHours = 0;
|
||||
if (returnedAt != null) warningStatus = "RESOLVED";
|
||||
else if (deadline == null) warningStatus = "NOT_APPLICABLE";
|
||||
else if (LocalDateTime.now(clock).isAfter(deadline)) {
|
||||
warningStatus = "OVERDUE";
|
||||
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
|
||||
} else warningStatus = "PENDING";
|
||||
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
|
||||
return new View(context.containerBookingId(), context.containerNo(), context.oceanBookingId(), status,
|
||||
version(value), value == null ? null : value.getFleetSupplierId(),
|
||||
value == null ? null : value.getFleetSupplierName(),
|
||||
value == null ? null : value.getPickupAppointmentAt(),
|
||||
actualPickupAt(context, value), deadline, returnedAt,
|
||||
context.unloadingCompletedAt(), warning, allowedActions(context, value),
|
||||
value == null ? null : value.getUpdatedAt());
|
||||
}
|
||||
|
||||
private List<String> allowedActions(Context context, TerminalDrayageEntity value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
|
||||
&& actualPickupAt(context, value) == null) {
|
||||
result.add("SELECT_FLEET");
|
||||
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
|
||||
}
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
|
||||
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
|
||||
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
|
||||
if (value != null && context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null
|
||||
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private String status(Context context, TerminalDrayageEntity value) {
|
||||
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
|
||||
if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED";
|
||||
if (actualPickupAt(context, value) != null) return "PICKED_UP";
|
||||
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
|
||||
}
|
||||
|
||||
private void requireArrived(Context context) {
|
||||
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
|
||||
}
|
||||
|
||||
private void requireAtLeast(Context context, CarrierScheduleNode required, String message) {
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
|
||||
}
|
||||
|
||||
private CarrierScheduleNode node(String value) {
|
||||
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
|
||||
}
|
||||
|
||||
private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt();
|
||||
}
|
||||
|
||||
private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualEmptyReturnAt() != null
|
||||
? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt();
|
||||
}
|
||||
|
||||
private Context requiredContext(long id) {
|
||||
Context value = mapper.context(id);
|
||||
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
|
||||
return value;
|
||||
}
|
||||
|
||||
private Context requiredLockedContext(long id) {
|
||||
Context value = mapper.lockContext(id);
|
||||
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
|
||||
return value;
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity requiredAggregate(long id) {
|
||||
TerminalDrayageEntity value = mapper.lock(id);
|
||||
if (value == null) throw conflict("请先选择码头车队");
|
||||
return value;
|
||||
}
|
||||
|
||||
private void requireVersion(TerminalDrayageEntity value, int expectedVersion) {
|
||||
if (version(value) != expectedVersion) throw concurrent();
|
||||
}
|
||||
|
||||
private int version(TerminalDrayageEntity value) {
|
||||
return value == null || value.getVersion() == null ? 0 : value.getVersion();
|
||||
}
|
||||
|
||||
private String hash(Object request) {
|
||||
try {
|
||||
byte[] json = objectMapper.writeValueAsBytes(request);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("无法计算码头拖车请求摘要", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); }
|
||||
private static BizException notFound() { return new BizException(ResultCode.NOT_FOUND, "货柜不存在或不可用"); }
|
||||
|
||||
void setClock(Clock clock) { this.clock = clock; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
|
||||
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
|
||||
fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照',
|
||||
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
|
||||
actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
|
||||
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
|
||||
actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1 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 '更新时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
|
||||
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
|
||||
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
|
||||
CONSTRAINT chk_terminal_drayage_times CHECK(
|
||||
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
|
||||
(actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND
|
||||
(actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
|
||||
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
|
||||
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
|
||||
action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN',
|
||||
from_status VARCHAR(32) NOT NULL COMMENT '操作前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '操作后状态',
|
||||
from_version INT UNSIGNED NOT NULL COMMENT '操作前版本',
|
||||
to_version INT UNSIGNED NOT NULL COMMENT '操作后版本',
|
||||
reason VARCHAR(500) NULL COMMENT '操作说明',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY(id),
|
||||
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
|
||||
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
|
||||
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
|
||||
('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '业务动作',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256',
|
||||
result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key),
|
||||
KEY idx_terminal_drayage_operation(container_booking_id,created_at),
|
||||
CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
|
||||
) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作';
|
||||
@@ -0,0 +1,127 @@
|
||||
openapi: 3.0.3
|
||||
info: {title: Firstleg Terminal Drayage API, version: 1.0.0}
|
||||
paths:
|
||||
/api/platform/v1/terminal-drayages:
|
||||
get:
|
||||
summary: 批量查询货柜码头拖车及服务端报空预警
|
||||
x-permission: platform:firstleg:container:list
|
||||
parameters:
|
||||
- name: containerBookingIds
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: array, minItems: 1, maxItems: 200, items: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewsResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
||||
get:
|
||||
summary: 查询单柜码头拖车详情
|
||||
x-permission: platform:firstleg:container:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/history:
|
||||
get:
|
||||
summary: 查询单柜码头拖车操作历史
|
||||
x-permission: platform:firstleg:container:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/HistoryResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:
|
||||
post:
|
||||
summary: 到港后选择或更换码头车队
|
||||
x-permission: platform:firstleg:container:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:
|
||||
post:
|
||||
summary: 预约目的港提柜时间
|
||||
x-permission: platform:firstleg:container:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
|
||||
post:
|
||||
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
|
||||
x-permission: platform:firstleg:container:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||
post:
|
||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
x-permission: platform:firstleg:container:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
components:
|
||||
parameters:
|
||||
ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}}
|
||||
schemas:
|
||||
FleetSelectionRequest:
|
||||
type: object
|
||||
required: [expectedVersion, fleetSupplierId]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID}
|
||||
fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准}
|
||||
reason: {type: string, maxLength: 500, nullable: true}
|
||||
PickupAppointmentRequest:
|
||||
type: object
|
||||
required: [expectedVersion, pickupAppointmentAt]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
pickupAppointmentAt: {type: string, format: date-time}
|
||||
reason: {type: string, maxLength: 500, nullable: true}
|
||||
ActualTimeRequest:
|
||||
type: object
|
||||
required: [expectedVersion, actualAt]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
actualAt: {type: string, format: date-time}
|
||||
reason: {type: string, maxLength: 500, nullable: true}
|
||||
Warning:
|
||||
type: object
|
||||
required: [status, overdueHours, overdueDays]
|
||||
properties:
|
||||
status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]}
|
||||
deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时}
|
||||
overdueHours: {type: integer, format: int64, minimum: 0}
|
||||
overdueDays: {type: integer, format: int64, minimum: 0}
|
||||
TerminalDrayageView:
|
||||
type: object
|
||||
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions]
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
containerNo: {type: string, nullable: true}
|
||||
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
|
||||
version: {type: integer, minimum: 0}
|
||||
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
|
||||
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
History:
|
||||
type: object
|
||||
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
|
||||
properties:
|
||||
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
|
||||
fromStatus: {type: string}
|
||||
toStatus: {type: string}
|
||||
fromVersion: {type: integer, minimum: 0}
|
||||
toVersion: {type: integer, minimum: 1}
|
||||
reason: {type: string, nullable: true}
|
||||
operatorId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
operatorName: {type: string, nullable: true}
|
||||
createdAt: {type: string, format: date-time}
|
||||
ApiView: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/TerminalDrayageView'}}}
|
||||
ApiViews: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/TerminalDrayageView'}}}}
|
||||
ApiHistory: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/History'}}}}
|
||||
responses:
|
||||
ViewResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiView'}}}}
|
||||
ViewsResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiViews'}}}}
|
||||
HistoryResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiHistory'}}}}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageOpenApiContractTest {
|
||||
@Test
|
||||
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||
assertThat(yaml).contains(
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
|
||||
"platform:firstleg:container:list", "platform:firstleg:container:edit",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageSqlContractTest {
|
||||
@Test
|
||||
void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
|
||||
String mapper = Files.readString(Path.of(
|
||||
"src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java"));
|
||||
assertThat(sql).contains(
|
||||
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage (",
|
||||
"UNIQUE KEY uk_terminal_drayage_container(container_booking_id)",
|
||||
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (",
|
||||
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (",
|
||||
"UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)",
|
||||
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
|
||||
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception {
|
||||
String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(sql).contains(
|
||||
"ALTER TABLE fl_terminal_drayage",
|
||||
"ALTER TABLE fl_terminal_drayage_history",
|
||||
"ALTER TABLE fl_terminal_drayage_operation",
|
||||
"MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'");
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
|
||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TerminalDrayageServiceTest {
|
||||
private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0);
|
||||
private TerminalDrayageMapper mapper;
|
||||
private WarehouseSupplierSnapshotService suppliers;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(TerminalDrayageMapper.class);
|
||||
suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFleetSelectionBeforeActualDestinationArrival() {
|
||||
when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null));
|
||||
|
||||
assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1",
|
||||
new FleetSelectionRequest(0, 91L, null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("货柜实际到达目的港后才能安排码头车队");
|
||||
verify(suppliers, never()).trucking(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsActiveTruckingSupplierAndPersistsServerSnapshot() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.lock(11L)).thenReturn(null);
|
||||
when(mapper.operation(11L, "fleet-1")).thenReturn(null);
|
||||
when(suppliers.trucking(91L, "用户提示名")).thenReturn(
|
||||
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
|
||||
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
|
||||
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
|
||||
TerminalDrayageEntity value = invocation.getArgument(0);
|
||||
stored.set(value);
|
||||
return 1;
|
||||
});
|
||||
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
|
||||
|
||||
var result = service.selectFleet(11L, 7L, "admin", "fleet-1",
|
||||
new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜"));
|
||||
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
assertThat(result.fleetSupplierId()).isEqualTo(91L);
|
||||
assertThat(result.fleetSupplierName()).isEqualTo("服务端车队");
|
||||
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP");
|
||||
verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(),
|
||||
anyInt(), anyInt(), any(), anyLong(), anyString());
|
||||
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsPickupOnceAndAdvancesCanonicalNode() {
|
||||
Context before = context("PICKUP_READY", null);
|
||||
Context after = context("PICKED_UP", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(before);
|
||||
when(mapper.context(11L)).thenReturn(after);
|
||||
when(mapper.operation(11L, "pickup-1")).thenReturn(null);
|
||||
TerminalDrayageEntity row = row(2);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
|
||||
row.setVersion(3);
|
||||
return 1;
|
||||
});
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var result = service.recordPickup(11L, 7L, "admin", "pickup-1",
|
||||
new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜"));
|
||||
|
||||
assertThat(result.status()).isEqualTo("PICKED_UP");
|
||||
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
|
||||
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresOverseasUnloadBeforeEmptyReturn() {
|
||||
Context context = context("PICKED_UP", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.operation(11L, "return-1")).thenReturn(null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
|
||||
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
|
||||
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("海外卸柜收货完成后才能确认还柜");
|
||||
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void computesSeventyTwoHourWarningAndClearsItAfterReturn() {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0);
|
||||
Context context = context("PICKED_UP", unloadedAt);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var overdue = service.detail(11L);
|
||||
assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0));
|
||||
assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE");
|
||||
assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26);
|
||||
assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1);
|
||||
|
||||
row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0));
|
||||
assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED");
|
||||
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotOfferEmptyReturnWithoutExecutableDrayageAggregate() {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
|
||||
Context context = context("PICKED_UP", unloadedAt);
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(null);
|
||||
|
||||
var result = service.detail(11L);
|
||||
|
||||
assertThat(result.status()).isEqualTo("UNLOADED");
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1));
|
||||
|
||||
assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key",
|
||||
new FleetSelectionRequest(0, 91L, null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("Idempotency-Key已被不同的码头拖车操作占用");
|
||||
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
|
||||
verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
when(mapper.lockContext(11L)).thenReturn(context);
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.lock(11L)).thenReturn(null);
|
||||
when(suppliers.trucking(91L, null)).thenReturn(
|
||||
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
|
||||
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
|
||||
AtomicReference<Operation> operation = new AtomicReference<>();
|
||||
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
|
||||
stored.set(invocation.getArgument(0));
|
||||
return 1;
|
||||
});
|
||||
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
|
||||
when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get());
|
||||
when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()))
|
||||
.thenAnswer(invocation -> {
|
||||
operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5)));
|
||||
return 1;
|
||||
});
|
||||
FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null);
|
||||
|
||||
var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request);
|
||||
var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request);
|
||||
|
||||
assertThat(replay).isEqualTo(first);
|
||||
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
|
||||
verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
|
||||
verify(suppliers, times(1)).trucking(91L, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsEmptyReturnAndResolvesWarning() {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
|
||||
Context before = context("PICKED_UP", unloadedAt);
|
||||
Context after = context("EMPTY_RETURNED", unloadedAt);
|
||||
when(mapper.lockContext(11L)).thenReturn(before);
|
||||
when(mapper.context(11L)).thenReturn(after);
|
||||
when(mapper.operation(11L, "return-2")).thenReturn(null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.lock(11L)).thenReturn(row);
|
||||
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
|
||||
row.setVersion(4);
|
||||
return 1;
|
||||
});
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2",
|
||||
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜"));
|
||||
|
||||
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
||||
}
|
||||
|
||||
private Context context(String node, LocalDateTime unloadedAt) {
|
||||
int sequenceVersion = switch (node) {
|
||||
case "PICKED_UP" -> 5;
|
||||
case "PICKUP_READY" -> 4;
|
||||
case "ARRIVED_DESTINATION_PORT" -> 3;
|
||||
default -> 2;
|
||||
};
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
|
||||
31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT,
|
||||
"PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null,
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null);
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity row(int version) {
|
||||
TerminalDrayageEntity value = new TerminalDrayageEntity();
|
||||
value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version);
|
||||
value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"booking-customs-trucking->outbound",
|
||||
"booking-customs-trucking->pricebook-query",
|
||||
"booking-customs-trucking->shared-technical",
|
||||
"booking-customs-trucking->shipping-nodes",
|
||||
"booking-customs-trucking->split-loading",
|
||||
"customer-statistics->shared-technical",
|
||||
"finance-integration->inbound",
|
||||
|
||||
@@ -77,7 +77,7 @@ spring:
|
||||
- id: firstleg-service
|
||||
uri: lb://firstleg-service
|
||||
predicates:
|
||||
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
|
||||
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
|
||||
- id: finance-service
|
||||
uri: lb://finance-service
|
||||
predicates:
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ class FirstlegContainerRouteContractTest {
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/containers/**")
|
||||
.contains("/api/platform/v1/terminal-drayages/**")
|
||||
.contains("/api/platform/v1/loading-plans/**");
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@ public class MemberService {
|
||||
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
|
||||
user.setStatus(request.status()); repository.saveUser(user); repository.save(membership);
|
||||
securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status());
|
||||
securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT",
|
||||
"MEMBER_STATUS_CHANGED");
|
||||
} catch (RuntimeException localFailure) {
|
||||
try {
|
||||
authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus));
|
||||
|
||||
@@ -147,6 +147,8 @@ public class PlatformInternalUserService {
|
||||
repository.saveUser(user);
|
||||
repository.save(membership);
|
||||
securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status());
|
||||
securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM",
|
||||
"PLATFORM_INTERNAL_USER_STATUS_CHANGED");
|
||||
return response(membership, user, authAccount.username(), roleIds);
|
||||
} catch (RuntimeException localFailure) {
|
||||
compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure);
|
||||
|
||||
@@ -203,6 +203,8 @@ public class PlatformUserManagementService {
|
||||
members.save(membership);
|
||||
securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(),
|
||||
appCode(membership.getTenantId()), request.status());
|
||||
securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(),
|
||||
appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED");
|
||||
} catch (RuntimeException localFailure) {
|
||||
call(() -> authClient.status(internalToken, user.getAuthUserId(),
|
||||
new AuthAccountClient.StatusRequest(oldAccount.status())));
|
||||
|
||||
@@ -106,6 +106,8 @@ class MemberServiceTest {
|
||||
assertEquals("DISABLED", result.status());
|
||||
verify(repository).save(member);
|
||||
verify(repository).saveUser(user);
|
||||
verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED");
|
||||
verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED");
|
||||
}
|
||||
|
||||
@Test void shouldHandleCase05() {
|
||||
|
||||
@@ -142,6 +142,19 @@ class PlatformInternalUserServiceTest {
|
||||
verify(auth, never()).status(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusChangePublishesMatchingAuthorizationSnapshot() {
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
InternalUserResponse result = service.changeStatus(30L,
|
||||
new ChangeInternalUserStatusRequest("DISABLED"), 99L);
|
||||
|
||||
assertEquals("DISABLED", result.status());
|
||||
verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED");
|
||||
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM",
|
||||
"PLATFORM_INTERNAL_USER_STATUS_CHANGED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase05() {
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
@@ -224,6 +224,20 @@ class PlatformUserManagementServiceTest {
|
||||
verify(auth, never()).status(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusChangePublishesMatchingAuthorizationSnapshot() {
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE")));
|
||||
when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L));
|
||||
|
||||
UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L);
|
||||
|
||||
assertEquals("ACTIVE", result.status());
|
||||
verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE");
|
||||
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtInPlatformAdministratorCannotBeDisabled() {
|
||||
IamRole platformAdmin = new IamRole();
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
## 职责与边界
|
||||
|
||||
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。
|
||||
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。
|
||||
- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。
|
||||
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。
|
||||
- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。
|
||||
- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。
|
||||
|
||||
## 快速索引
|
||||
@@ -13,6 +13,7 @@
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
@@ -100,6 +101,7 @@
|
||||
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
|
||||
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
|
||||
- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。
|
||||
- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。
|
||||
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
|
||||
|
||||
+54
-7
@@ -7,12 +7,14 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressSummary;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderFee;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.service.LastlegOrderCommandService;
|
||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
@@ -45,6 +47,7 @@ public class LastlegOrderController {
|
||||
@Resource private LoadingCompletedConsumer consumer;
|
||||
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
|
||||
@Resource private LastlegOrderService orderService;
|
||||
@Resource private LastlegOrderCommandService commandService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
@Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer;
|
||||
|
||||
@@ -102,6 +105,20 @@ public class LastlegOrderController {
|
||||
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "修改整柜海外接柜仓",
|
||||
description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。")
|
||||
public ApiResponse<ContainerPlanProgressSummary> changeReceivingWarehouse(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String sourceContainerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) {
|
||||
platformScope(user, null);
|
||||
return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId,
|
||||
idempotencyKey, request, operatorName(user)));
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
@@ -123,17 +140,43 @@ public class LastlegOrderController {
|
||||
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user)));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/detained")
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<Void> setDetained(
|
||||
@Operation(summary = "暂扣或解除单票尾程订单",
|
||||
description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<LastlegOrderCommandDtos.HoldResponse> hold(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long id,
|
||||
@Valid @RequestBody LastlegOrderRequests.SetDetained request) {
|
||||
orderService.setDetained(platformScope(user, customerId), id, request,
|
||||
String.valueOf(user.userId()), operatorName(user));
|
||||
return ApiResponse.success(null);
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) {
|
||||
return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey,
|
||||
request, operatorId(user), operatorName(user)));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "保存单票人工节点覆盖",
|
||||
description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<LastlegOrderCommandDtos.ManualNodesResponse> saveManualNodes(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long id,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) {
|
||||
return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id,
|
||||
idempotencyKey, request, operatorId(user), operatorName(user)));
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "查询单票人工节点覆盖",
|
||||
description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
public ApiResponse<LastlegOrderCommandDtos.ManualNodesResponse> manualNodes(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long id) {
|
||||
return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required")
|
||||
@@ -213,4 +256,8 @@ public class LastlegOrderController {
|
||||
private String operatorName(AuthenticatedUser user) {
|
||||
return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username();
|
||||
}
|
||||
|
||||
private String operatorId(AuthenticatedUser user) {
|
||||
return user.userId() == null ? operatorName(user) : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -55,9 +55,9 @@ public class DeliveryTransferController {
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
|
||||
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
|
||||
@PathVariable String outboundNo) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.outboundDetail(outboundNo, query));
|
||||
return ApiResponse.success(service.outboundDetail(outboundNo));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/{outboundNo}/cancel")
|
||||
|
||||
+7
@@ -34,6 +34,13 @@ public class OutboundPdaController {
|
||||
return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status));
|
||||
}
|
||||
|
||||
@GetMapping("/resolve/{businessNo}")
|
||||
public ApiResponse<DeliveryResponses.PdaTaskResolution> resolve(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String businessNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.resolvePdaTask(String.valueOf(identity.overseasWarehouseId()), businessNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{taskNo}")
|
||||
public ApiResponse<DeliveryResponses.PdaTaskDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String taskNo) {
|
||||
|
||||
@@ -41,17 +41,6 @@ public final class DeliveryRequests {
|
||||
String driverName, String deliveryMethod,
|
||||
LocalDateTime plannedFrom, LocalDateTime plannedTo) {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
public Query(long page, long size, String documentNo, String warehouseId, String status,
|
||||
String dispatchNo, String waybillNo, String vehiclePlate, String driverName,
|
||||
String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) {
|
||||
this(page, size, documentNo, null, warehouseId, status, dispatchNo, waybillNo, vehiclePlate,
|
||||
driverName, deliveryMethod, plannedFrom, plannedTo);
|
||||
}
|
||||
}
|
||||
|
||||
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String destinationWarehouseCode) {
|
||||
public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
|
||||
public record OneClickOutbound(@NotBlank @Size(max=1000) String recipient,
|
||||
|
||||
+5
-1
@@ -40,8 +40,12 @@ public final class DeliveryResponses {
|
||||
public record DeliveryDetail(DeliveryRow order, List<DeliverySku> skus,
|
||||
List<DeliveryFeeRow> fees, List<DeliveryOperationRow> operations) {}
|
||||
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
|
||||
/** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */
|
||||
public record OutboundPalletRow(String palletNo, String inboundCode, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String skuCode, int packageCount) {}
|
||||
public record OutboundDetail(OutboundRow outbound, List<OutboundPalletRow> pallets, DeliveryRow delivery) {}
|
||||
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
|
||||
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
|
||||
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
|
||||
String skuCode, String inboundCode, long availablePackages,
|
||||
long pickedPackages) {}
|
||||
|
||||
+6
@@ -23,6 +23,12 @@ public interface OutboundPdaTaskMapper extends BaseMapper<OutboundPdaTask> {
|
||||
"AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1")
|
||||
OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId);
|
||||
|
||||
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
|
||||
"AND o.customer_id=t.customer_id WHERE t.customer_id=#{customerId} AND t.outbound_order_id=#{outboundOrderId} " +
|
||||
"AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1")
|
||||
OutboundPdaTask findByOutboundAndWarehouse(@Param("customerId") String customerId,
|
||||
@Param("outboundOrderId") Long outboundOrderId, @Param("warehouseId") String warehouseId);
|
||||
|
||||
@Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " +
|
||||
"AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200")
|
||||
|
||||
+55
-4
@@ -114,6 +114,7 @@ public class DeliveryTransferService {
|
||||
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
|
||||
throw conflict("Dispatch cannot be transferred to delivery in its current status");
|
||||
}
|
||||
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
|
||||
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
|
||||
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
|
||||
if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
|
||||
@@ -128,7 +129,7 @@ public class DeliveryTransferService {
|
||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId()));
|
||||
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
|
||||
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY");
|
||||
outbound.setLxCodes(items.stream().map(DispatchItem::getInboundCode).filter(StringUtils::hasText).map(code -> tailOrders.selectOne(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, code))).filter(Objects::nonNull).map(LastlegOrder::getLxCode).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")));
|
||||
outbound.setLxCodes(resolveLxCodes(customerId, items));
|
||||
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
@@ -408,6 +409,36 @@ public class DeliveryTransferService {
|
||||
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
|
||||
}
|
||||
|
||||
public DeliveryResponses.PdaTaskResolution resolvePdaTask(String warehouseId, String businessNo) {
|
||||
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
|
||||
String code = required(businessNo, "Outbound task, outbound or dispatch number is required");
|
||||
|
||||
OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(code, boundWarehouseId);
|
||||
if (task != null) {
|
||||
OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId);
|
||||
return outboundResolution(outbound, task);
|
||||
}
|
||||
|
||||
OutboundOrder outbound = outboundOrders.findGlobalByNo(code);
|
||||
if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
|
||||
if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) {
|
||||
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo());
|
||||
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
|
||||
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(),
|
||||
dispatch.getDispatchNo());
|
||||
}
|
||||
}
|
||||
task = pdaTasks.findByOutboundAndWarehouse(outbound.getCustomerId(), outbound.getId(), boundWarehouseId);
|
||||
if (task != null) return outboundResolution(outbound, task);
|
||||
}
|
||||
|
||||
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code);
|
||||
if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) {
|
||||
return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo());
|
||||
}
|
||||
throw notFound("Outbound or dispatch task not found in the bound warehouse");
|
||||
}
|
||||
|
||||
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
|
||||
OutboundPalletItem item) {
|
||||
if (item.getWarehousePalletId() == null) return List.of();
|
||||
@@ -447,6 +478,10 @@ public class DeliveryTransferService {
|
||||
}
|
||||
|
||||
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); }
|
||||
private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) {
|
||||
return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(),
|
||||
outbound.getDispatchNo());
|
||||
}
|
||||
private OutboundOrder requireOutbound(OutboundPdaTask task) {
|
||||
OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId());
|
||||
if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) {
|
||||
@@ -475,13 +510,14 @@ public class DeliveryTransferService {
|
||||
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
|
||||
}
|
||||
|
||||
public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo,
|
||||
DeliveryRequests.PalletQuery query) {
|
||||
public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo) {
|
||||
OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
String customerId = outbound.getCustomerId();
|
||||
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
|
||||
List<DeliveryResponses.OutboundPalletRow> pallets = outboundPallets
|
||||
.findByOutbound(customerId, outbound.getId()).stream().map(this::outboundPalletRow).toList();
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), pallets, deliveryRow(delivery));
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||
@@ -556,12 +592,27 @@ public class DeliveryTransferService {
|
||||
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
|
||||
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
||||
return new DeliveryResponses.OutboundPalletRow(item.getPalletNo(), item.getInboundCode(),
|
||||
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getSkuCode(),
|
||||
value(item.getPackageCount()));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
|
||||
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
|
||||
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
|
||||
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
|
||||
if (inboundCodes.isEmpty()) return "";
|
||||
return tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.in(LastlegOrder::getInboundCode, inboundCodes))
|
||||
.stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText)
|
||||
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining(","));
|
||||
}
|
||||
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) { List<Long> ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String required(String value, String message) {
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ public class DispatchPalletLock {
|
||||
private Long planPalletId;
|
||||
private Long warehousePalletId;
|
||||
private Long dispatchOrderId;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Long draftContainerId;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String draftToken;
|
||||
|
||||
+22
-2
@@ -36,7 +36,13 @@ public interface DispatchCandidateMapper {
|
||||
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
@@ -60,6 +66,10 @@ public interface DispatchCandidateMapper {
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
@@ -94,7 +104,13 @@ public interface DispatchCandidateMapper {
|
||||
"(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
"MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
@@ -112,6 +128,10 @@ public interface DispatchCandidateMapper {
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
|
||||
+8
@@ -7,8 +7,16 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
|
||||
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE draft_token=#{draftToken} " +
|
||||
"AND draft_container_id=#{draftContainerId} AND status='ACTIVE' " +
|
||||
"AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY locked_at,id")
|
||||
List<DispatchPalletLock> findActiveByDraftContainer(@Param("draftToken") String draftToken,
|
||||
@Param("draftContainerId") Long draftContainerId);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
|
||||
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
|
||||
DispatchPalletLock lockActive(@Param("customerId") String customerId,
|
||||
|
||||
+44
-4
@@ -22,6 +22,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -53,6 +55,7 @@ public class DispatchService {
|
||||
@Resource private DispatchCandidateMapper candidates;
|
||||
@Resource private DispatchPdaTaskMapper pdaTasks;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
|
||||
@@ -95,7 +98,8 @@ public class DispatchService {
|
||||
|
||||
private List<DispatchContainerResponse> containers(String token) {
|
||||
return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(),
|
||||
locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>().eq(DispatchPalletLock::getDraftContainerId, c.getId()).eq(DispatchPalletLock::getStatus, "ACTIVE")).stream().map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList();
|
||||
locks.findActiveByDraftContainer(token, c.getId()).stream()
|
||||
.map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -220,10 +224,17 @@ public class DispatchService {
|
||||
if (selected.size() != palletIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
|
||||
}
|
||||
assertNoDetainedOrders(customerId, selected);
|
||||
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.anyMatch(value -> value == null || value.isBlank());
|
||||
if (missingWarehouse) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"所选托盘中存在尚未关联海外仓的托盘");
|
||||
}
|
||||
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
|
||||
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
|
||||
.map(String::trim).collect(Collectors.toSet());
|
||||
if (warehouseIds.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘");
|
||||
}
|
||||
String draftToken = trimToNull(request.draftToken());
|
||||
for (DispatchPalletCandidate candidate : selected) {
|
||||
@@ -268,6 +279,7 @@ public class DispatchService {
|
||||
if (draftToken != null) {
|
||||
requireDraftOwner(lock, operatorId, draftToken);
|
||||
lock.setDispatchOrderId(dispatch.getId());
|
||||
lock.setDraftContainerId(null);
|
||||
lock.setDraftToken(null);
|
||||
lock.setExpiresAt(null);
|
||||
lock.setUpdatedAt(now);
|
||||
@@ -329,9 +341,37 @@ public class DispatchService {
|
||||
pdaTask.setCreatedAt(now);
|
||||
pdaTask.setUpdatedAt(now);
|
||||
pdaTasks.insert(pdaTask);
|
||||
if (draftToken != null) {
|
||||
draftContainers.delete(new LambdaQueryWrapper<DispatchDraftContainer>()
|
||||
.eq(DispatchDraftContainer::getDraftToken, draftToken));
|
||||
DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId));
|
||||
if (draft != null) {
|
||||
draft.setStatus("RELEASED");
|
||||
draft.setReleasedAt(now);
|
||||
draft.setUpdatedAt(now);
|
||||
drafts.updateById(draft);
|
||||
}
|
||||
}
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
}
|
||||
|
||||
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
|
||||
List<Long> orderIds = selected.stream().map(DispatchPalletCandidate::orderId)
|
||||
.filter(Objects::nonNull).distinct().sorted().toList();
|
||||
if (orderIds.isEmpty()) return;
|
||||
List<LastlegOrder> scoped = tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.in(LastlegOrder::getId, orderIds)
|
||||
.orderByAsc(LastlegOrder::getId)
|
||||
.last("FOR UPDATE"));
|
||||
List<String> detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained()))
|
||||
.map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList();
|
||||
if (!detainedNos.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos));
|
||||
}
|
||||
}
|
||||
|
||||
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
|
||||
if (!StringUtils.hasText(customerId)) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
|
||||
+2
-2
@@ -135,8 +135,8 @@ public class DropshipOrderService {
|
||||
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
|
||||
}
|
||||
Map<String, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
|
||||
.filter(receipt -> receipt.getPalletNo() != null)
|
||||
.collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo));
|
||||
.filter(receipt -> receipt.getSourcePalletNo() != null)
|
||||
.collect(Collectors.groupingBy(InboundStockReceipt::getSourcePalletNo));
|
||||
if (receiptsByPallet.size() != profilesForOrder.size()) {
|
||||
throw conflict("PDA pallet count does not match dropship source pallet count");
|
||||
}
|
||||
|
||||
+16
@@ -6,6 +6,12 @@ import java.time.LocalDateTime;
|
||||
public record ContainerPlanProgressSummary(
|
||||
String sourceContainerBookingId,
|
||||
String containerNo,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
int assignmentVersion,
|
||||
boolean canChangeReceivingWarehouse,
|
||||
String receivingWarehouseLockReason,
|
||||
long totalOrderCount,
|
||||
long inboundCodeCount,
|
||||
long missingInboundCodeCount,
|
||||
@@ -16,4 +22,14 @@ public record ContainerPlanProgressSummary(
|
||||
boolean allPlansReady,
|
||||
String blockingReason
|
||||
) {
|
||||
public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo,
|
||||
long totalOrderCount, long inboundCodeCount,
|
||||
long missingInboundCodeCount, long planCount,
|
||||
long plannedInboundCodeCount, long pendingPlanCount,
|
||||
LocalDateTime plannedArrivalAt, boolean allPlansReady,
|
||||
String blockingReason) {
|
||||
this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null,
|
||||
totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount,
|
||||
plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class LastlegOrderCommandDtos {
|
||||
private LastlegOrderCommandDtos() { }
|
||||
|
||||
public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable,
|
||||
@NotBlank @Size(max = 500) String reason,
|
||||
@NotNull @Min(0) Integer expectedVersion) { }
|
||||
public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { }
|
||||
|
||||
public record ManualNode(@NotBlank @Size(max = 32) String nodeCode,
|
||||
LocalDateTime nodeAt) { }
|
||||
public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { }
|
||||
public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled,
|
||||
List<ManualNodeView> nodes, int version) { }
|
||||
public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
|
||||
public record LastlegOrderDetailResponse(
|
||||
LastlegOrderSummary order,
|
||||
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
|
||||
List<LastlegOrderDetail> cargo,
|
||||
List<History> statusHistory,
|
||||
List<LastlegOrderFee> fees,
|
||||
|
||||
@@ -54,6 +54,12 @@ public final class LastlegOrderRequests {
|
||||
}
|
||||
}
|
||||
|
||||
public record ChangeReceivingWarehouse(
|
||||
@NotNull Long overseasWarehouseId,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 500) String remark
|
||||
) { }
|
||||
|
||||
public record TransferDestination(
|
||||
@NotEmpty @Size(max = 200) List<Long> orderIds,
|
||||
@NotBlank @Size(max = 128) String warehouseId,
|
||||
|
||||
@@ -15,12 +15,14 @@ public record LastlegOrderSummary(
|
||||
@Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"})
|
||||
String destinationType,
|
||||
String sourceInboundId,
|
||||
@Schema(description = "真实来源入仓码;无入仓关系的海外本土业务为空")
|
||||
String inboundCode,
|
||||
String lxCode,
|
||||
String sourceOrderNo,
|
||||
String customerId,
|
||||
String customerName,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String transferWarehouseId,
|
||||
String transferWarehouseCode,
|
||||
@@ -30,6 +32,11 @@ public record LastlegOrderSummary(
|
||||
String containerStatus,
|
||||
Boolean abnormal,
|
||||
Boolean detained,
|
||||
Boolean heldChargeable,
|
||||
String holdReason,
|
||||
boolean manualOverrideEnabled,
|
||||
String nodeSource,
|
||||
int version,
|
||||
Integer palletCount,
|
||||
Integer packageCount,
|
||||
BigDecimal volumeCbm,
|
||||
|
||||
@@ -28,7 +28,9 @@ public class LastlegOrder {
|
||||
private String sourceWarehouseId;
|
||||
private String sourceWarehouseName;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
private String overseasWarehouseName;
|
||||
private Integer receivingWarehouseVersion;
|
||||
private String fbaWarehouseCode;
|
||||
private String containerNo;
|
||||
private String transferWarehouseId;
|
||||
@@ -37,6 +39,10 @@ public class LastlegOrder {
|
||||
private String containerStatus;
|
||||
private Boolean abnormal;
|
||||
private Boolean detained;
|
||||
private Boolean holdChargeable;
|
||||
private String holdReason;
|
||||
private Boolean manualNodeOverride;
|
||||
private Integer version;
|
||||
private Integer palletCount;
|
||||
private Integer packageCount;
|
||||
private Integer pickedPackageCount;
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_order_command_record")
|
||||
public class LastlegOrderCommandRecord {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private String action;
|
||||
private String operatorId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String resultRef;
|
||||
private String resultJson;
|
||||
private Integer resultVersion;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_order_hold_history")
|
||||
public class LastlegOrderHoldHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private Boolean held;
|
||||
private Boolean chargeable;
|
||||
private String reason;
|
||||
private Integer orderVersion;
|
||||
private String operatorId;
|
||||
private String operatorName;
|
||||
private String idempotencyKey;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_order_manual_node")
|
||||
public class LastlegOrderManualNode {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private String nodeCode;
|
||||
private LocalDateTime nodeAt;
|
||||
private String source;
|
||||
private Integer orderVersion;
|
||||
private String updatedBy;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_order_manual_node_history")
|
||||
public class LastlegOrderManualNodeHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private String nodesJson;
|
||||
private Integer orderVersion;
|
||||
private String operatorId;
|
||||
private String operatorName;
|
||||
private String idempotencyKey;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_receiving_warehouse_operation")
|
||||
public class ReceivingWarehouseOperation {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String sourceContainerBookingId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private Integer resultVersion;
|
||||
private String operatorName;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+13
-8
@@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
@@ -44,19 +44,24 @@ public class InboundPlanController {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@PostMapping("/convert")
|
||||
@PostMapping("/manual")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
/** 由平台人工将待分配尾程订单转换为入仓计划。 */
|
||||
@Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: "
|
||||
@Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: "
|
||||
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId()));
|
||||
public ApiResponse<InboundPlanResponse> manual(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody ManualInboundPlanRequest request) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划");
|
||||
}
|
||||
convertScope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/convert-container")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
@Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
@Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<java.util.List<InboundPlanResponse>> convertContainer(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertContainerInboundPlanRequest request) {
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record ConvertInboundPlanRequest(
|
||||
@NotNull Long orderId,
|
||||
@Size(max = 128) String customerId
|
||||
) {
|
||||
}
|
||||
+3
-15
@@ -12,6 +12,8 @@ public record InboundPlanListResponse(
|
||||
String inboundCode,
|
||||
String lxCode,
|
||||
String orderType,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String warehouseId,
|
||||
String warehouseCode,
|
||||
String warehouseName,
|
||||
@@ -29,18 +31,4 @@ public record InboundPlanListResponse(
|
||||
String dispatchStatus,
|
||||
LocalDateTime createdAt,
|
||||
Integer version
|
||||
) {
|
||||
public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId,
|
||||
Long orderId, String inboundCode, String orderType,
|
||||
String warehouseId, String warehouseCode, String warehouseName,
|
||||
String fbaWarehouseCode, String customerName, Integer packageCount,
|
||||
BigDecimal volumeCbm, Integer palletCount, String status,
|
||||
String appointmentNo, String appointmentStatus,
|
||||
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
|
||||
String dispatchStatus, LocalDateTime createdAt) {
|
||||
this(lineId, planId, planNo, customerId, orderId, inboundCode, null, orderType, warehouseId,
|
||||
warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0,
|
||||
volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt,
|
||||
expectedLoadingAt, dispatchStatus, createdAt, 0);
|
||||
}
|
||||
}
|
||||
) { }
|
||||
|
||||
-9
@@ -28,15 +28,6 @@ public final class InboundPlanRequests {
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo
|
||||
) {
|
||||
public Query(long page, long size, String planNo, String inboundCode, String status,
|
||||
String overseasWarehouseId, String destinationWarehouseCode, String warehouse,
|
||||
String fbaWarehouseCode, String appointmentStatus, String dispatchStatus,
|
||||
LocalDateTime createdFrom, LocalDateTime createdTo) {
|
||||
this(page, size, planNo, inboundCode, null, status, overseasWarehouseId,
|
||||
destinationWarehouseCode, warehouse, fbaWarehouseCode, appointmentStatus,
|
||||
dispatchStatus, createdFrom, createdTo);
|
||||
}
|
||||
|
||||
public Query {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
|
||||
+12
-18
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record InboundPlanResponse(
|
||||
@@ -10,6 +11,8 @@ public record InboundPlanResponse(
|
||||
String customerId,
|
||||
Long sourceOrderId,
|
||||
String containerNo,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String status,
|
||||
int lineCount,
|
||||
int palletCount,
|
||||
@@ -19,24 +22,15 @@ public record InboundPlanResponse(
|
||||
List<Line> lines,
|
||||
List<Pallet> pallets,
|
||||
List<Allocation> allocations,
|
||||
int version
|
||||
int version,
|
||||
LocalDate plannedArrivalDate,
|
||||
String palletType,
|
||||
boolean assigned,
|
||||
boolean mergeRequired,
|
||||
boolean urgent,
|
||||
String creationMode,
|
||||
String remark
|
||||
) {
|
||||
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
|
||||
String containerNo, String status, int lineCount, int palletCount,
|
||||
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
|
||||
List<Allocation> allocations) {
|
||||
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
|
||||
totalVolumeCbm, lines, pallets, allocations, 0);
|
||||
}
|
||||
|
||||
public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId,
|
||||
String containerNo, String status, int lineCount, int palletCount,
|
||||
BigDecimal totalVolumeCbm, List<Line> lines, List<Pallet> pallets,
|
||||
List<Allocation> allocations, int version) {
|
||||
this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount,
|
||||
totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(),
|
||||
allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version);
|
||||
}
|
||||
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
|
||||
@@ -46,7 +40,7 @@ public record InboundPlanResponse(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo,
|
||||
Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId,
|
||||
String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
|
||||
String palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
|
||||
int actualPackageCount) {
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ManualInboundPlanRequest(
|
||||
@NotBlank @Size(max = 128) String customerId,
|
||||
@NotNull Long orderId,
|
||||
@NotNull @Min(0) Integer expectedOrderVersion,
|
||||
@NotNull LocalDate plannedArrivalDate,
|
||||
@NotNull @Min(1) @Max(999) Integer palletCount,
|
||||
@NotBlank @Size(max = 32) String palletType,
|
||||
@NotNull Boolean assigned,
|
||||
@NotNull Boolean mergeRequired,
|
||||
@NotNull Boolean urgent,
|
||||
@Size(max = 1000) String remark
|
||||
) { }
|
||||
@@ -8,6 +8,7 @@ import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_plan")
|
||||
@@ -19,6 +20,13 @@ public class InboundPlan {
|
||||
private Long sourceOrderId;
|
||||
private String containerNo;
|
||||
private Long overseasWarehouseId;
|
||||
private LocalDate plannedArrivalDate;
|
||||
private String palletType;
|
||||
private Boolean assignedFlag;
|
||||
private Boolean mergeRequired;
|
||||
private Boolean urgentFlag;
|
||||
private String creationMode;
|
||||
private String planRemark;
|
||||
private String status;
|
||||
private Integer lineCount;
|
||||
private Integer palletCount;
|
||||
|
||||
+2
@@ -16,6 +16,8 @@ public class InboundPlanPallet {
|
||||
private String customerId;
|
||||
private Long planId;
|
||||
private String palletNo;
|
||||
private String sourcePalletNo;
|
||||
private String palletType;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
|
||||
+36
-4
@@ -14,6 +14,19 @@ import java.util.List;
|
||||
public interface InboundPlanOrderMapper {
|
||||
record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo,
|
||||
String orderType) { }
|
||||
record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName) { }
|
||||
record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId,
|
||||
String overseasWarehouseId) { }
|
||||
|
||||
@Select("SELECT c.customer_id AS customerId,c.id AS orderId," +
|
||||
"c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " +
|
||||
"FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " +
|
||||
"WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " +
|
||||
"AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " +
|
||||
"ORDER BY c.customer_id,c.id FOR UPDATE")
|
||||
List<ContainerWarehouseRef> lockContainerWarehouseFacts(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," +
|
||||
"order_type AS orderType " +
|
||||
@@ -26,22 +39,30 @@ public interface InboundPlanOrderMapper {
|
||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
||||
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
|
||||
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
|
||||
"COALESCE(o.pallet_count,0) AS recommendedPalletCount," +
|
||||
"COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," +
|
||||
"o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," +
|
||||
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
|
||||
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " +
|
||||
"ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," +
|
||||
"0 AS destinationMismatchCount " +
|
||||
"0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " +
|
||||
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
|
||||
"WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " +
|
||||
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," +
|
||||
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
|
||||
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," +
|
||||
"o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," +
|
||||
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " +
|
||||
"ORDER BY o.id FOR UPDATE")
|
||||
List<SourceOrderSnapshot> lockOrder(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT overseas_warehouse_id AS overseasWarehouseId," +
|
||||
"overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " +
|
||||
"FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId);
|
||||
|
||||
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
|
||||
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{orderId}")
|
||||
@@ -49,6 +70,17 @@ public interface InboundPlanOrderMapper {
|
||||
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
|
||||
"pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," +
|
||||
"pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " +
|
||||
"AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL")
|
||||
int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
|
||||
@Param("mergeRequired") boolean mergeRequired,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " +
|
||||
"VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})")
|
||||
int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus,
|
||||
|
||||
+2
-1
@@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper {
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.lxCode != null and q.lxCode != \"\"'> AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')</if>" +
|
||||
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND l.destination_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
|
||||
@@ -38,6 +38,7 @@ public interface InboundPlanQueryMapper {
|
||||
|
||||
@Select("<script>SELECT l.id lineId,p.id planId,p.plan_no planNo,p.customer_id customerId," +
|
||||
"l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType," +
|
||||
"o.overseas_warehouse_code overseasWarehouseCode,o.overseas_warehouse_name overseasWarehouseName," +
|
||||
"l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," +
|
||||
"l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," +
|
||||
"o.customer_name customerName,l.required_package_count packageCount," +
|
||||
|
||||
+13
-16
@@ -12,30 +12,27 @@ public record SourceOrderSnapshot(
|
||||
BigDecimal volumeCbm,
|
||||
int packageCount,
|
||||
int recommendedPalletCount,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String fbaWarehouseCode,
|
||||
String destinationWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String destinationWarehouseName,
|
||||
int destinationCount,
|
||||
int destinationMismatchCount
|
||||
int destinationMismatchCount,
|
||||
boolean detained,
|
||||
int version
|
||||
) {
|
||||
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
|
||||
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
|
||||
int packageCount, String fbaWarehouseCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int destinationCount, int destinationMismatchCount) {
|
||||
int packageCount, int recommendedPalletCount, String overseasWarehouseId,
|
||||
String overseasWarehouseCode, String overseasWarehouseName, String fbaWarehouseCode,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, int destinationCount, int destinationMismatchCount) {
|
||||
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
|
||||
packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, destinationCount, destinationMismatchCount);
|
||||
}
|
||||
|
||||
public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode,
|
||||
String currentStatus, String inboundOrderNo, BigDecimal volumeCbm,
|
||||
int packageCount, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int destinationCount, int destinationMismatchCount) {
|
||||
this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm,
|
||||
packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, destinationCount, destinationMismatchCount);
|
||||
packageCount, recommendedPalletCount, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
||||
fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
destinationCount, destinationMismatchCount, false, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-4
@@ -6,22 +6,46 @@ import java.util.List;
|
||||
public final class ReceivingContainerResponses {
|
||||
private ReceivingContainerResponses() { }
|
||||
|
||||
public record Candidate(String sourceContainerBookingId, String containerNo, String status,
|
||||
public record Candidate(String sourceContainerBookingId, String containerNo,
|
||||
String overseasWarehouseCode, String overseasWarehouseName, String status,
|
||||
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
|
||||
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
|
||||
List<String> destinationCodes, int plannedPalletCount,
|
||||
int totalPackages, int receivedPackages,
|
||||
boolean operable, boolean allPlansReady, String blockReason,
|
||||
LocalDateTime plannedArrivalAt, boolean currentAccount) { }
|
||||
LocalDateTime plannedArrivalAt, boolean currentAccount) {
|
||||
public Candidate(String sourceContainerBookingId, String containerNo, String status,
|
||||
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
|
||||
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
|
||||
List<String> destinationCodes, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, boolean operable, boolean allPlansReady,
|
||||
String blockReason, LocalDateTime plannedArrivalAt, boolean currentAccount) {
|
||||
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
|
||||
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
|
||||
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
|
||||
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount);
|
||||
}
|
||||
}
|
||||
|
||||
public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { }
|
||||
|
||||
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
||||
public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String status, String operatorName, int version,
|
||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
List<Inbound> inbounds) { }
|
||||
List<Inbound> inbounds) {
|
||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
||||
String status, String operatorName, int version, List<SignedPhoto> photos,
|
||||
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
|
||||
LocalDateTime completedAt, int totalPackages, int receivedPackages,
|
||||
int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
|
||||
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
||||
receivedPackages, pendingPackages, inbounds);
|
||||
}
|
||||
}
|
||||
|
||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
|
||||
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ public class InboundStockReceipt {
|
||||
private String productNameEn;
|
||||
private Long warehousePalletId;
|
||||
private String palletNo;
|
||||
private String sourcePalletNo;
|
||||
private Long warehouseLocationId;
|
||||
private String warehouseLocationCode;
|
||||
private String batchNo;
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ public class ReceivingContainerSession {
|
||||
private String containerFactHash;
|
||||
private String containerNo;
|
||||
private Long overseasWarehouseId;
|
||||
private String overseasWarehouseCode;
|
||||
private String overseasWarehouseName;
|
||||
private String operatorKey;
|
||||
private String operatorName;
|
||||
|
||||
+38
-4
@@ -59,10 +59,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
"businessId", businessId, "reservationKey", reservationKey))
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode());
|
||||
throw warehouseReservationFailure(result.getStatusCode());
|
||||
})
|
||||
.body(ReserveEnvelope.class);
|
||||
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response");
|
||||
if (response == null) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse resource reservation returned no response");
|
||||
}
|
||||
if (response.data() == null) {
|
||||
HttpStatus status = "A0409".equals(response.code()) ? HttpStatus.CONFLICT
|
||||
: "A0404".equals(response.code()) ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE;
|
||||
throw new ResponseStatusException(status,
|
||||
response.message() == null || response.message().isBlank()
|
||||
? "Warehouse resource reservation was rejected" : response.message());
|
||||
}
|
||||
return response.data().lockId();
|
||||
}
|
||||
|
||||
@@ -120,6 +130,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse pallet service returned no usable response");
|
||||
}
|
||||
requireReservable(response.data());
|
||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
|
||||
response.data().warehouseId(), response.data().locationId(), response.data().status(),
|
||||
response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version());
|
||||
@@ -133,6 +144,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
.body(WarehousePalletResponse.class);
|
||||
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse pallet service returned no usable response");
|
||||
requireReservable(response.data());
|
||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(),
|
||||
response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(),
|
||||
response.data().unitPrice(), response.data().version());
|
||||
@@ -144,6 +156,26 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
|
||||
}
|
||||
|
||||
private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) {
|
||||
HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT
|
||||
: status.value() == 404 ? HttpStatus.NOT_FOUND
|
||||
: status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE;
|
||||
return new ResponseStatusException(mapped, "Warehouse resource reservation was rejected");
|
||||
}
|
||||
|
||||
private void requireReservable(WarehousePalletData pallet) {
|
||||
if (pallet.reservable()) return;
|
||||
String reason = switch (pallet.reservationBlockReason() == null
|
||||
? "RESOURCE_STATE_UNKNOWN" : pallet.reservationBlockReason()) {
|
||||
case "PALLET_NOT_IDLE" -> "Pallet is not idle";
|
||||
case "LOCATION_NOT_FOUND" -> "Pallet location was not found";
|
||||
case "VIRTUAL_LOCATION_NOT_ACTIVE" -> "Pallet virtual location is not active";
|
||||
case "LOCATION_NOT_IDLE" -> "Pallet location is not idle";
|
||||
default -> "Pallet resource availability could not be verified";
|
||||
};
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
|
||||
WarehouseLocationResponse response = client.get()
|
||||
@@ -200,8 +232,10 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
|
||||
private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {}
|
||||
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {}
|
||||
private record ReserveEnvelope(ReserveData data) {}
|
||||
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version,
|
||||
String locationType, String locationStatus, boolean reservable,
|
||||
String reservationBlockReason) {}
|
||||
private record ReserveEnvelope(String code, String message, ReserveData data) {}
|
||||
private record ReserveData(long lockId) {}
|
||||
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
|
||||
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
|
||||
|
||||
+6
-2
@@ -171,13 +171,17 @@ public class InboundPdaWorkflowService {
|
||||
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null;
|
||||
throw conflict("Destination pallet is already bound to another physical pallet");
|
||||
}
|
||||
if (resource.palletNo() == null || resource.palletNo().isBlank()) {
|
||||
throw conflict("Resolved physical pallet has no pallet code");
|
||||
}
|
||||
String destinationKey = destinationKey(pallet);
|
||||
String businessId = plan.getContainerNo() + ":" + destinationKey;
|
||||
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
|
||||
long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(),
|
||||
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
|
||||
try {
|
||||
pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId());
|
||||
pallet.setWarehousePalletId(resource.palletId()); pallet.setPalletNo(resource.palletNo().trim());
|
||||
pallet.setWarehouseLocationId(resource.locationId());
|
||||
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
|
||||
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
|
||||
return lockId;
|
||||
@@ -941,7 +945,7 @@ public class InboundPdaWorkflowService {
|
||||
boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType);
|
||||
Map<String, InboundPlanPallet> palletsByNo = wholePalletInbound
|
||||
? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap(
|
||||
InboundPlanPallet::getPalletNo, value -> value)) : Map.of();
|
||||
InboundPlanPallet::getSourcePalletNo, value -> value)) : Map.of();
|
||||
Map<Long, InboundPlanPalletAllocation> allocationsByPallet = wholePalletInbound
|
||||
? allocations.stream().collect(java.util.stream.Collectors.toMap(
|
||||
InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of();
|
||||
|
||||
+6
-2
@@ -116,7 +116,7 @@ public class InboundStockReceiptService {
|
||||
if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) {
|
||||
throw conflict("One-click dropship inbound must receive a complete source pallet");
|
||||
}
|
||||
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getPalletNo());
|
||||
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getSourcePalletNo());
|
||||
if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) {
|
||||
throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt");
|
||||
}
|
||||
@@ -228,6 +228,8 @@ public class InboundStockReceiptService {
|
||||
"Actual overseas warehouse is required for stock-in");
|
||||
String overseasWarehouseName = required(order.getOverseasWarehouseName(),
|
||||
"Actual overseas warehouse name is required for stock-in");
|
||||
String overseasWarehouseCode = required(order.getOverseasWarehouseCode(),
|
||||
"Actual overseas warehouse code is required for stock-in");
|
||||
if (plan.getOverseasWarehouseId() == null
|
||||
|| !overseasWarehouseId.equals(String.valueOf(plan.getOverseasWarehouseId()))) {
|
||||
throw conflict("Inbound plan and tail-leg order overseas warehouses must match for stock-in");
|
||||
@@ -236,7 +238,8 @@ public class InboundStockReceiptService {
|
||||
throw conflict("Actual overseas warehouse must not use the domestic source warehouse");
|
||||
}
|
||||
InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName,
|
||||
overseasWarehouseId, null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode,
|
||||
overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
||||
line.getDestinationWarehouseId(), destinationCode,
|
||||
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
|
||||
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
|
||||
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
|
||||
@@ -261,6 +264,7 @@ public class InboundStockReceiptService {
|
||||
receipt.setProductNameEn(sku.productNameEn());
|
||||
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
|
||||
receipt.setPalletNo(pallet.getPalletNo());
|
||||
receipt.setSourcePalletNo(pallet.getSourcePalletNo());
|
||||
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
|
||||
receipt.setBatchNo(plan.getPlanNo());
|
||||
receipt.setReceivedPackages(packages);
|
||||
|
||||
+23
-37
@@ -126,7 +126,7 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
|
||||
requireReceivingAvailable(customerId, containerNo);
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
requireSessionWarehouse(current, overseasWarehouseId);
|
||||
@@ -158,7 +158,7 @@ public class ReceivingContainerService {
|
||||
}
|
||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
|
||||
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作");
|
||||
requireReceivingAvailable(customerId, containerNo);
|
||||
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
||||
@@ -167,7 +167,6 @@ public class ReceivingContainerService {
|
||||
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
|
||||
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
|
||||
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
||||
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
|
||||
}
|
||||
@@ -176,13 +175,13 @@ public class ReceivingContainerService {
|
||||
}
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
|
||||
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ReceivingContainerSession session = new ReceivingContainerSession();
|
||||
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
||||
session.setSourceContainerBookingId(readiness.sourceContainerBookingId());
|
||||
session.setContainerFactHash(readiness.factHash());
|
||||
session.setOverseasWarehouseId(overseasWarehouseId);
|
||||
session.setOverseasWarehouseCode(receivingWarehouse.warehouseCode());
|
||||
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
|
||||
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
||||
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
|
||||
@@ -412,7 +411,7 @@ public class ReceivingContainerService {
|
||||
ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts,
|
||||
overseasWarehouseId);
|
||||
if (value == null || !value.operable()) {
|
||||
throw conflict(value == null ? "Container is assigned to another overseas warehouse" : value.blockReason());
|
||||
throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -444,8 +443,13 @@ public class ReceivingContainerService {
|
||||
try { assignedWarehouses.add(Long.valueOf(value)); }
|
||||
catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); }
|
||||
});
|
||||
if (assignedWarehouses.size() > 1) throw conflict("Container plans have inconsistent receiving warehouse assignments");
|
||||
if (!assignedWarehouses.isEmpty() && !assignedWarehouses.contains(overseasWarehouseId)) return null;
|
||||
if (assignedWarehouses.size() > 1) return null;
|
||||
if (assignedWarehouses.size() != 1 || !assignedWarehouses.contains(overseasWarehouseId)) return null;
|
||||
Set<String> warehouseCodes = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseCode)
|
||||
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
Set<String> warehouseNames = orderFacts.stream().map(LastlegOrder::getOverseasWarehouseName)
|
||||
.map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
boolean warehouseSnapshotConsistent = warehouseCodes.size() == 1 && warehouseNames.size() == 1;
|
||||
Set<String> customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet());
|
||||
String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null);
|
||||
int totalOrders = orderFacts.size();
|
||||
@@ -464,10 +468,11 @@ public class ReceivingContainerService {
|
||||
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
|
||||
String customerId = customers.size() == 1 ? customers.iterator().next() : null;
|
||||
boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1;
|
||||
boolean operable = allPlansReady && shippingReady;
|
||||
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
|
||||
int pending = Math.max(0, totalOrders - plannedOrders);
|
||||
String blockReason = operable ? null : (!allPlansReady
|
||||
? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
|
||||
String blockReason = operable ? null : (!warehouseSnapshotConsistent
|
||||
? "柜内接柜仓业务快照不一致"
|
||||
: !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码"
|
||||
: "柜号尚未达到海外收货可操作节点");
|
||||
LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt)
|
||||
.filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null);
|
||||
@@ -488,7 +493,9 @@ public class ReceivingContainerService {
|
||||
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(),
|
||||
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
|
||||
inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
|
||||
return new ContainerReadiness(sourceContainerBookingId, containerNo, totalOrders, inboundCodes,
|
||||
return new ContainerReadiness(sourceContainerBookingId, containerNo,
|
||||
warehouseCodes.size() == 1 ? warehouseCodes.iterator().next() : null,
|
||||
warehouseNames.size() == 1 ? warehouseNames.iterator().next() : null, totalOrders, inboundCodes,
|
||||
totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, allPlansCompleted, shippingReady, operable,
|
||||
blockReason, arrival, factHash, totals);
|
||||
}
|
||||
@@ -498,6 +505,7 @@ public class ReceivingContainerService {
|
||||
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
|
||||
Totals totals = value.totals();
|
||||
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
|
||||
value.overseasWarehouseCode(), value.overseasWarehouseName(),
|
||||
active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(),
|
||||
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
|
||||
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
|
||||
@@ -663,7 +671,7 @@ public class ReceivingContainerService {
|
||||
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
|
||||
session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
||||
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
||||
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
|
||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
|
||||
}
|
||||
@@ -864,6 +872,7 @@ public class ReceivingContainerService {
|
||||
String containerNo) {
|
||||
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
|
||||
value.setOverseasWarehouseId(warehouse.warehouseId());
|
||||
value.setOverseasWarehouseCode(warehouse.warehouseCode());
|
||||
value.setOverseasWarehouseName(warehouse.warehouseName());
|
||||
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
|
||||
return value;
|
||||
@@ -881,7 +890,7 @@ public class ReceivingContainerService {
|
||||
if (assigned.size() > 1) {
|
||||
throw conflict("Container plans have inconsistent receiving warehouse assignments");
|
||||
}
|
||||
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
|
||||
return assigned.size() == 1 && assigned.contains(overseasWarehouseId);
|
||||
}
|
||||
|
||||
private void requireReceivingAvailable(String customerId, String containerNo) {
|
||||
@@ -890,30 +899,6 @@ public class ReceivingContainerService {
|
||||
}
|
||||
}
|
||||
|
||||
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
|
||||
WarehouseResourceGateway.WarehouseReference warehouse) {
|
||||
long overseasWarehouseId = warehouse.warehouseId();
|
||||
String warehouseId = String.valueOf(overseasWarehouseId);
|
||||
for (InboundPlan plan : containerPlans) {
|
||||
int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId);
|
||||
InboundPlan actual = plans.selectById(plan.getId());
|
||||
if ((claimed == 0 && actual == null) || actual == null
|
||||
|| !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) {
|
||||
throw conflict("Container was claimed by another overseas warehouse");
|
||||
}
|
||||
plan.setOverseasWarehouseId(overseasWarehouseId);
|
||||
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
|
||||
orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId,
|
||||
warehouse.warehouseName());
|
||||
LastlegOrder order = orders.selectById(line.getOrderId());
|
||||
if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) {
|
||||
throw conflict("Inbound order was claimed by another overseas warehouse");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) {
|
||||
if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
|
||||
throw conflict("Current container belongs to another overseas warehouse");
|
||||
@@ -990,6 +975,7 @@ public class ReceivingContainerService {
|
||||
private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
|
||||
int total, int received) { }
|
||||
private record ContainerReadiness(String sourceContainerBookingId, String containerNo,
|
||||
String overseasWarehouseCode, String overseasWarehouseName,
|
||||
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
|
||||
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
|
||||
boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason,
|
||||
|
||||
+240
-39
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
@@ -23,6 +24,11 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.common.event.ContainerNumbers;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -31,6 +37,10 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -50,6 +60,65 @@ public class InboundPlanService {
|
||||
@Resource private InboundPlanQueryMapper queryMapper;
|
||||
@Resource private InventoryBalanceMapper inventoryBalanceMapper;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@Resource private WarehouseResourceGateway warehouseGateway;
|
||||
@Resource private LastlegOrderCommandRecordMapper commandMapper;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey,
|
||||
String operatorId) {
|
||||
String customerId = trimToNull(request.customerId());
|
||||
if (customerId == null || "0".equals(customerId)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台人工计划必须指定客户");
|
||||
}
|
||||
String key = trimToNull(idempotencyKey);
|
||||
if (key == null || key.length() > 128) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符");
|
||||
}
|
||||
List<InboundPlanOrderMapper.ContainerWarehouseRef> containerFacts =
|
||||
orderMapper.lockContainerWarehouseFacts(customerId, request.orderId());
|
||||
if (containerFacts == null || containerFacts.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜");
|
||||
}
|
||||
String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream()
|
||||
.map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList());
|
||||
List<SourceOrderSnapshot> snapshots = orderMapper.lockOrder(customerId, request.orderId());
|
||||
if (snapshots == null || snapshots.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在");
|
||||
}
|
||||
SourceOrderSnapshot order = snapshots.get(0);
|
||||
String requestHash = sha256(request);
|
||||
LastlegOrderCommandRecord replay = commandMapper.find(customerId, request.orderId(), "MANUAL_PLAN", operatorId, key);
|
||||
if (replay != null) {
|
||||
if (!MessageDigest.isEqual(replay.getRequestHash().getBytes(StandardCharsets.UTF_8),
|
||||
requestHash.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同请求");
|
||||
}
|
||||
return detailByPlanNo(customerId, replay.getResultRef());
|
||||
}
|
||||
if (planMapper.findBySourceOrder(customerId, request.orderId()) != null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单已生成入库计划");
|
||||
}
|
||||
if (order.version() != request.expectedOrderVersion()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试");
|
||||
}
|
||||
if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划");
|
||||
if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试");
|
||||
}
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||
requiredReceivingWarehouse(containerWarehouseId);
|
||||
validateOrders(snapshots);
|
||||
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
|
||||
request, receivingWarehouse);
|
||||
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
|
||||
command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId());
|
||||
command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key);
|
||||
command.setRequestHash(requestHash); command.setResultRef(response.planNo());
|
||||
command.setResultJson(json(response)); command.setResultVersion(request.expectedOrderVersion() + 1);
|
||||
command.setCreatedAt(LocalDateTime.now()); commandMapper.insert(command);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<InboundPlanListResponse> page(String customerId, InboundPlanRequests.Query query) {
|
||||
@@ -112,12 +181,13 @@ public class InboundPlanService {
|
||||
return detail(customerId, existing);
|
||||
}
|
||||
validateOrders(orders);
|
||||
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets);
|
||||
return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null);
|
||||
}
|
||||
|
||||
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
|
||||
List<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
|
||||
List<WholePalletInput> wholePallets) {
|
||||
List<WholePalletInput> wholePallets, ManualInboundPlanRequest manual,
|
||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse) {
|
||||
String containerNo = requiredContainerNo(orders.get(0).containerNo());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
InboundPlan plan = new InboundPlan();
|
||||
@@ -126,7 +196,23 @@ public class InboundPlanService {
|
||||
plan.setCustomerId(customerId);
|
||||
plan.setSourceOrderId(selectedOrderId);
|
||||
plan.setContainerNo(containerNo);
|
||||
String receivingWarehouseId = trimToNull(orders.get(0).overseasWarehouseId());
|
||||
if (receivingWarehouseId == null || orders.stream().anyMatch(order ->
|
||||
!Objects.equals(receivingWarehouseId, trimToNull(order.overseasWarehouseId())))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配或存在不一致");
|
||||
}
|
||||
try { plan.setOverseasWarehouseId(Long.parseLong(receivingWarehouseId)); }
|
||||
catch (NumberFormatException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception);
|
||||
}
|
||||
plan.setStatus("PENDING_ASSIGNMENT");
|
||||
plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate());
|
||||
plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase());
|
||||
plan.setAssignedFlag(manual != null && manual.assigned());
|
||||
plan.setMergeRequired(manual != null && manual.mergeRequired());
|
||||
plan.setUrgentFlag(manual != null && manual.urgent());
|
||||
plan.setCreationMode(manual == null ? "AUTO" : "MANUAL");
|
||||
plan.setPlanRemark(manual == null ? null : trimToNull(manual.remark()));
|
||||
plan.setLineCount(orders.size());
|
||||
plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
@@ -136,16 +222,20 @@ public class InboundPlanService {
|
||||
Map<Long, SourceOrderSnapshot> orderById = orders.stream()
|
||||
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
|
||||
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
|
||||
PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream()
|
||||
PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream()
|
||||
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
|
||||
order.packageCount(), order.recommendedPalletCount())).toList()) : null;
|
||||
if (wholePallets != null) validateWholePallets(orders, wholePallets);
|
||||
plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size());
|
||||
plan.setPalletCount(manual != null ? manual.palletCount()
|
||||
: wholePallets == null ? allocation.pallets().size() : wholePallets.size());
|
||||
planMapper.insert(plan);
|
||||
|
||||
lineByOrder.values().forEach(lineMapper::insert);
|
||||
Map<Long, Integer> palletCounts;
|
||||
if (wholePallets == null) {
|
||||
if (manual != null) {
|
||||
createManualPallets(plan, orders.get(0), lineByOrder.get(orders.get(0).orderId()), manual);
|
||||
palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount());
|
||||
} else if (wholePallets == null) {
|
||||
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation,
|
||||
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos);
|
||||
createAllocations(plan, allocation, lineByOrder, persistedPallets);
|
||||
@@ -162,15 +252,19 @@ public class InboundPlanService {
|
||||
InboundPlanLine line = lineByOrder.get(order.orderId());
|
||||
line.setPlannedPalletCount(count);
|
||||
lineMapper.updateById(line);
|
||||
if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
|
||||
int updated = manual == null
|
||||
? orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now)
|
||||
: orderMapper.markPlannedManual(customerId, order.orderId(), plan.getPlanNo(), count,
|
||||
manual.mergeRequired(), manual.expectedOrderVersion(), now);
|
||||
if (updated != 1) {
|
||||
throw new IllegalStateException("Failed to update order " + order.orderId());
|
||||
}
|
||||
inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(),
|
||||
plan.getPlanNo(), now);
|
||||
inventoryBalanceMapper.bindInboundPlan(customerId, receivingWarehouseId, order.orderId(),
|
||||
order.inboundCode(), plan.getPlanNo(), now);
|
||||
orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(),
|
||||
"Inbound order converted to inbound plan " + plan.getPlanNo(), now);
|
||||
}
|
||||
return detail(customerId, plan);
|
||||
return detail(customerId, plan, receivingWarehouse);
|
||||
}
|
||||
|
||||
/** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */
|
||||
@@ -191,26 +285,24 @@ public class InboundPlanService {
|
||||
List<InboundPlan> existingPlans = refs.stream()
|
||||
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
|
||||
.toList();
|
||||
long existingCount = existingPlans.stream().filter(Objects::nonNull).count();
|
||||
if (existingCount == refs.size()) {
|
||||
List<InboundPlanResponse> replay = new ArrayList<>(refs.size());
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
requirePlanContainer(existingPlans.get(index), containerNo);
|
||||
replay.add(detail(refs.get(index).customerId(), existingPlans.get(index)));
|
||||
List<String> warehouseIds = new ArrayList<>(refs.size());
|
||||
List<Integer> pendingIndexes = new ArrayList<>();
|
||||
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>();
|
||||
List<List<WholePalletInput>> wholePallets = new ArrayList<>();
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
|
||||
InboundPlan existing = existingPlans.get(index);
|
||||
if (existing != null) {
|
||||
requireValidExistingPlan(existing, containerNo);
|
||||
InboundPlanOrderMapper.ReceivingWarehouseSnapshot warehouse =
|
||||
orderMapper.findReceivingWarehouse(ref.customerId(), ref.orderId());
|
||||
warehouseIds.add(warehouse == null ? null : warehouse.overseasWarehouseId());
|
||||
continue;
|
||||
}
|
||||
if (!("PENDING_ASSIGNMENT".equals(ref.currentStatus())
|
||||
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单");
|
||||
}
|
||||
return List.copyOf(replay);
|
||||
}
|
||||
if (existingCount != 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划");
|
||||
}
|
||||
if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus())
|
||||
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单");
|
||||
}
|
||||
|
||||
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
|
||||
List<List<WholePalletInput>> wholePallets = new ArrayList<>(refs.size());
|
||||
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
|
||||
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId());
|
||||
if (orders == null || orders.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在");
|
||||
@@ -220,7 +312,9 @@ public class InboundPlanService {
|
||||
if (!Objects.equals(containerNo, currentContainerNo)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
|
||||
}
|
||||
pendingIndexes.add(index);
|
||||
snapshots.add(orders);
|
||||
warehouseIds.addAll(orders.stream().map(SourceOrderSnapshot::overseasWarehouseId).toList());
|
||||
List<WholePalletInput> sourcePallets = "ONE_CLICK_DROPSHIP".equals(ref.orderType())
|
||||
? dropshipOrders.palletSnapshots(ref.customerId(), ref.orderId()).stream()
|
||||
.map(this::wholePallet).toList()
|
||||
@@ -234,20 +328,64 @@ public class InboundPlanService {
|
||||
}
|
||||
wholePallets.add(sourcePallets);
|
||||
}
|
||||
requireSingleWarehouseId(warehouseIds);
|
||||
|
||||
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
|
||||
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(),
|
||||
wholePallets.get(index)));
|
||||
Map<Integer, InboundPlanResponse> created = new HashMap<>();
|
||||
for (int pendingIndex = 0; pendingIndex < pendingIndexes.size(); pendingIndex++) {
|
||||
int refIndex = pendingIndexes.get(pendingIndex);
|
||||
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(refIndex);
|
||||
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(),
|
||||
wholePallets.get(pendingIndex), null, null));
|
||||
}
|
||||
return List.copyOf(created);
|
||||
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
|
||||
for (int index = 0; index < refs.size(); index++) {
|
||||
InboundPlan existing = existingPlans.get(index);
|
||||
result.add(existing == null ? created.get(index) : detail(refs.get(index).customerId(), existing));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
|
||||
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
|
||||
}
|
||||
|
||||
private void createManualPallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line,
|
||||
ManualInboundPlanRequest request) {
|
||||
if (order.packageCount() > 0 && request.palletCount() > order.packageCount()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "人工计划托盘数不能大于订单箱数");
|
||||
}
|
||||
BigDecimal remainingVolume = volume(order);
|
||||
int remainingPackages = order.packageCount();
|
||||
for (int index = 0; index < request.palletCount(); index++) {
|
||||
int slots = request.palletCount() - index;
|
||||
int packages = slots == 1 ? remainingPackages : remainingPackages / slots;
|
||||
BigDecimal palletVolume = slots == 1 ? remainingVolume
|
||||
: remainingVolume.divide(BigDecimal.valueOf(slots), 4, java.math.RoundingMode.HALF_UP);
|
||||
InboundPlanPallet pallet = new InboundPlanPallet();
|
||||
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
|
||||
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", index + 1));
|
||||
pallet.setPalletType(request.palletType().trim().toUpperCase());
|
||||
pallet.setDestinationWarehouseId(order.destinationWarehouseId());
|
||||
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
|
||||
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
|
||||
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
|
||||
pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED");
|
||||
pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet);
|
||||
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
|
||||
allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId());
|
||||
allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId());
|
||||
allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(packages);
|
||||
allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0);
|
||||
allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0);
|
||||
allocationMapper.insert(allocation);
|
||||
remainingPackages -= packages; remainingVolume = remainingVolume.subtract(palletVolume);
|
||||
}
|
||||
if (remainingPackages != 0 || remainingVolume.signum() != 0) {
|
||||
throw new IllegalStateException("人工计划托盘分配未保持箱数或体积守恒");
|
||||
}
|
||||
}
|
||||
|
||||
private String requiredContainerNo(String value) {
|
||||
try {
|
||||
return ContainerNumbers.requireCanonical(value);
|
||||
@@ -264,6 +402,13 @@ public class InboundPlanService {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireValidExistingPlan(InboundPlan plan, String containerNo) {
|
||||
requirePlanContainer(plan, containerNo);
|
||||
if (trimToNull(plan.getPlanNo()) == null || trimToNull(plan.getStatus()) == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在无效入库计划,不能继续按柜生成");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateWholePallets(List<SourceOrderSnapshot> orders, List<WholePalletInput> pallets) {
|
||||
if (orders.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
@@ -288,7 +433,8 @@ public class InboundPlanService {
|
||||
if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume;
|
||||
InboundPlanPallet pallet = new InboundPlanPallet();
|
||||
pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId());
|
||||
pallet.setPalletNo(source.palletNo().trim()); pallet.setDestinationWarehouseId(order.destinationWarehouseId());
|
||||
pallet.setPalletNo(source.palletNo().trim()); pallet.setSourcePalletNo(source.palletNo().trim());
|
||||
pallet.setDestinationWarehouseId(order.destinationWarehouseId());
|
||||
pallet.setDestinationWarehouseCode(order.destinationWarehouseCode());
|
||||
pallet.setDestinationWarehouseName(order.destinationWarehouseName());
|
||||
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume);
|
||||
@@ -385,7 +531,7 @@ public class InboundPlanService {
|
||||
plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan);
|
||||
return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(),
|
||||
pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(),
|
||||
pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(),
|
||||
pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getPalletType(), pallet.getAssignmentStatus(),
|
||||
null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount());
|
||||
}
|
||||
|
||||
@@ -428,6 +574,7 @@ public class InboundPlanService {
|
||||
String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null;
|
||||
pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank()
|
||||
? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim());
|
||||
pallet.setSourcePalletNo(sourcePalletNo == null || sourcePalletNo.isBlank() ? null : sourcePalletNo.trim());
|
||||
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
|
||||
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
|
||||
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
|
||||
@@ -492,9 +639,21 @@ public class InboundPlanService {
|
||||
}
|
||||
|
||||
private InboundPlanResponse detail(String customerId, InboundPlan plan) {
|
||||
return detail(customerId, plan, null);
|
||||
}
|
||||
|
||||
private InboundPlanResponse detail(String customerId, InboundPlan plan,
|
||||
WarehouseResourceGateway.WarehouseReference resolvedWarehouse) {
|
||||
if (customerId != null && !customerId.equals(plan.getCustomerId())) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
|
||||
}
|
||||
InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse(
|
||||
plan.getCustomerId(), plan.getSourceOrderId());
|
||||
if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实");
|
||||
}
|
||||
WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null
|
||||
? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse;
|
||||
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
|
||||
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
@@ -504,8 +663,10 @@ public class InboundPlanService {
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
|
||||
.orderByAsc(InboundPlanPalletAllocation::getId));
|
||||
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(),
|
||||
plan.getContainerNo(),
|
||||
plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(),
|
||||
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
|
||||
lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(),
|
||||
allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(),
|
||||
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(),
|
||||
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
|
||||
line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(),
|
||||
@@ -513,12 +674,38 @@ public class InboundPlanService {
|
||||
pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(),
|
||||
pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(),
|
||||
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
|
||||
pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
|
||||
pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
|
||||
pallet.getActualPackageCount())).toList(),
|
||||
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
|
||||
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
|
||||
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(),
|
||||
plan.getVersion() == null ? 0 : plan.getVersion());
|
||||
plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(),
|
||||
Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()),
|
||||
Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark());
|
||||
}
|
||||
|
||||
private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) {
|
||||
String stableId = trimToNull(warehouseId);
|
||||
if (stableId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配");
|
||||
}
|
||||
try {
|
||||
return warehouseGateway.requireActiveSharedWarehouse(Long.parseLong(stableId));
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓ID格式无效", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String requireSingleWarehouseId(List<String> warehouseIds) {
|
||||
List<String> normalized = warehouseIds.stream().map(InboundPlanService::trimToNull).toList();
|
||||
if (normalized.stream().anyMatch(Objects::isNull)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在未分配接柜仓的尾程订单");
|
||||
}
|
||||
java.util.Set<String> distinct = new java.util.HashSet<>(normalized);
|
||||
if (distinct.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单接柜仓不一致");
|
||||
}
|
||||
return distinct.iterator().next();
|
||||
}
|
||||
|
||||
private BigDecimal volume(SourceOrderSnapshot order) {
|
||||
@@ -534,4 +721,18 @@ public class InboundPlanService {
|
||||
private static String trimToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private String sha256(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsBytes(value)));
|
||||
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
|
||||
throw new IllegalStateException("无法生成命令摘要", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); }
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inventory.command.mapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@@ -32,6 +33,20 @@ public interface InventoryCommandMapper {
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} ORDER BY id FOR UPDATE")
|
||||
List<InventoryBalance> lockInboundBalances(@Param("customerId") String customerId,
|
||||
@Param("inboundOrderId") Long inboundOrderId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}")
|
||||
long countPositions(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
|
||||
|
||||
@Delete("DELETE FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " +
|
||||
"AND available_packages=0 AND hold_packages=0 AND allocated_packages=0 AND picked_packages=0 " +
|
||||
"AND in_transit_packages=0 AND receiving_packages=0 AND procurement_packages=0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)")
|
||||
int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId);
|
||||
|
||||
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
|
||||
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
|
||||
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
|
||||
|
||||
+71
-5
@@ -28,6 +28,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class InventoryCommandService {
|
||||
@@ -57,6 +58,44 @@ public class InventoryCommandService {
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void rebindReceivingWarehouse(String customerId, Long inboundOrderId, long warehouseId,
|
||||
String warehouseCode, String warehouseName,
|
||||
String operatorId, String idempotencyKey) {
|
||||
String targetId = Long.toString(warehouseId);
|
||||
String targetCode = required(warehouseCode, "Receiving warehouse code is required");
|
||||
String targetName = required(warehouseName, "Receiving warehouse name is required");
|
||||
Long auditOperationId = IdWorker.getId();
|
||||
for (InventoryBalance balance : commands.lockInboundBalances(customerId, inboundOrderId)) {
|
||||
if (value(balance.getAvailablePackages()) != 0 || value(balance.getHoldPackages()) != 0
|
||||
|| value(balance.getAllocatedPackages()) != 0 || value(balance.getPickedPackages()) != 0
|
||||
|| value(balance.getReceivingPackages()) != 0 || value(balance.getProcurementPackages()) != 0
|
||||
|| commands.countPositions(customerId, balance.getId()) != 0) {
|
||||
throw conflict("Receiving warehouse cannot change after physical inventory exists");
|
||||
}
|
||||
InventoryBalance target = balances.lockDimension(customerId, targetId,
|
||||
balance.getDestinationWarehouseCode(), balance.getInboundOrderId(), balance.getInboundCode());
|
||||
if (target != null && !target.getId().equals(balance.getId())) {
|
||||
throw conflict("Receiving warehouse change would create a duplicate inventory dimension");
|
||||
}
|
||||
String sourceId = balance.getOverseasWarehouseId();
|
||||
if (Objects.equals(sourceId, targetId)
|
||||
&& Objects.equals(balance.getOverseasWarehouseCode(), targetCode)
|
||||
&& Objects.equals(balance.getOverseasWarehouseName(), targetName)) continue;
|
||||
long packages = value(balance.getInTransitPackages());
|
||||
balance.setOverseasWarehouseId(targetId);
|
||||
balance.setOverseasWarehouseCode(targetCode);
|
||||
balance.setOverseasWarehouseName(targetName);
|
||||
touch(balance);
|
||||
balances.updateById(balance);
|
||||
ledger(customerId, operatorId, auditOperationId, balance, null, balance.getSkuCode(),
|
||||
"RECEIVING_WAREHOUSE_CHANGED", "IN_TRANSIT", "IN_TRANSIT",
|
||||
packages, 0, packages, null, balance.getInboundOrderNo(), null, null,
|
||||
idempotencyKey + ":BALANCE:" + balance.getId(),
|
||||
"In-transit receiving warehouse changed from " + sourceId + " to " + targetId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InventoryCommandResponse stockIn(String customerId, String operatorId,
|
||||
InventoryCommandRequests.StockIn request) {
|
||||
@@ -126,6 +165,9 @@ public class InventoryCommandService {
|
||||
transferred += moved;
|
||||
pending -= moved;
|
||||
if (targetDimension) continue;
|
||||
if (commands.countPositions(customerId, source.getId()) != 0) {
|
||||
throw conflict("In-transit source balance unexpectedly has physical positions");
|
||||
}
|
||||
touch(source);
|
||||
balances.updateById(source);
|
||||
ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()),
|
||||
@@ -133,6 +175,9 @@ public class InventoryCommandService {
|
||||
sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null,
|
||||
request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(),
|
||||
"In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId());
|
||||
if (empty(source) && commands.deleteEmptyBalance(customerId, source.getId()) != 1) {
|
||||
throw conflict("Empty in-transit source balance changed concurrently");
|
||||
}
|
||||
}
|
||||
return transferred;
|
||||
}
|
||||
@@ -287,6 +332,9 @@ public class InventoryCommandService {
|
||||
String inboundCode, String requestedSkuCode, String productNameZh,
|
||||
String productNameEn) {
|
||||
String frozenCustomerName = required(customerName, "Customer name snapshot is required");
|
||||
String frozenWarehouseId = required(overseasWarehouseId, "Overseas warehouse ID snapshot is required");
|
||||
String frozenWarehouseCode = required(overseasWarehouseCode, "Overseas warehouse code snapshot is required");
|
||||
String frozenWarehouseName = required(overseasWarehouseName, "Overseas warehouse name snapshot is required");
|
||||
String skuCode = sku(requestedSkuCode);
|
||||
String destinationId = trim(destinationWarehouseId);
|
||||
String destinationCode = trim(destinationWarehouseCode);
|
||||
@@ -294,10 +342,11 @@ public class InventoryCommandService {
|
||||
throw badRequest("Destination warehouse ID or code is required");
|
||||
}
|
||||
if (destinationCode == null) destinationCode = "";
|
||||
InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId,
|
||||
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
|
||||
destinationCode, inboundOrderId, inboundCode);
|
||||
if (balance != null) {
|
||||
requireCustomerSnapshot(balance, frozenCustomerName);
|
||||
requireWarehouseSnapshot(balance, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName);
|
||||
mergeSkuSnapshot(balance, skuCode);
|
||||
return balance;
|
||||
}
|
||||
@@ -305,9 +354,9 @@ public class InventoryCommandService {
|
||||
balance.setId(IdWorker.getId());
|
||||
balance.setCustomerId(customerId);
|
||||
balance.setCustomerName(frozenCustomerName);
|
||||
balance.setOverseasWarehouseId(overseasWarehouseId.trim());
|
||||
balance.setOverseasWarehouseCode(trim(overseasWarehouseCode));
|
||||
balance.setOverseasWarehouseName(trim(overseasWarehouseName));
|
||||
balance.setOverseasWarehouseId(frozenWarehouseId);
|
||||
balance.setOverseasWarehouseCode(frozenWarehouseCode);
|
||||
balance.setOverseasWarehouseName(frozenWarehouseName);
|
||||
balance.setDestinationWarehouseId(destinationId);
|
||||
balance.setDestinationWarehouseCode(destinationCode);
|
||||
balance.setDestinationWarehouseName(trim(destinationWarehouseName));
|
||||
@@ -325,10 +374,11 @@ public class InventoryCommandService {
|
||||
balances.insert(balance);
|
||||
return balance;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId,
|
||||
InventoryBalance concurrent = balances.lockDimension(customerId, frozenWarehouseId,
|
||||
destinationCode, inboundOrderId, inboundCode);
|
||||
if (concurrent != null) {
|
||||
requireCustomerSnapshot(concurrent, frozenCustomerName);
|
||||
requireWarehouseSnapshot(concurrent, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName);
|
||||
mergeSkuSnapshot(concurrent, skuCode);
|
||||
return concurrent;
|
||||
}
|
||||
@@ -505,6 +555,13 @@ public class InventoryCommandService {
|
||||
balance.setProcurementPackages(0L);
|
||||
}
|
||||
|
||||
private static boolean empty(InventoryBalance balance) {
|
||||
return value(balance.getAvailablePackages()) == 0 && value(balance.getHoldPackages()) == 0
|
||||
&& value(balance.getAllocatedPackages()) == 0 && value(balance.getPickedPackages()) == 0
|
||||
&& value(balance.getInTransitPackages()) == 0 && value(balance.getReceivingPackages()) == 0
|
||||
&& value(balance.getProcurementPackages()) == 0;
|
||||
}
|
||||
|
||||
private static long positionPackages(InventoryPosition position) {
|
||||
return value(position.getAvailablePackages()) + value(position.getHoldPackages())
|
||||
+ value(position.getAllocatedPackages()) + value(position.getPickedPackages())
|
||||
@@ -583,6 +640,15 @@ public class InventoryCommandService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireWarehouseSnapshot(InventoryBalance balance, String requestedWarehouseId,
|
||||
String requestedWarehouseCode, String requestedWarehouseName) {
|
||||
if (!requestedWarehouseId.equals(trim(balance.getOverseasWarehouseId()))
|
||||
|| !requestedWarehouseCode.equals(trim(balance.getOverseasWarehouseCode()))
|
||||
|| !requestedWarehouseName.equals(trim(balance.getOverseasWarehouseName()))) {
|
||||
throw conflict("Inventory balance overseas warehouse snapshot does not match");
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
String normalized = trim(value);
|
||||
if (normalized == null) throw badRequest(message);
|
||||
|
||||
+3
-1
@@ -35,8 +35,10 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
|
||||
@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " +
|
||||
"WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
|
||||
"WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{overseasWarehouseId} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
|
||||
int bindInboundPlan(@Param("customerId") String customerId,
|
||||
@Param("overseasWarehouseId") String overseasWarehouseId,
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode,
|
||||
@Param("planNo") String planNo,
|
||||
|
||||
+3
-3
@@ -38,7 +38,7 @@ public class InventoryQueryController {
|
||||
|
||||
@GetMapping(BASE_PATH + "/summary")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
@Operation(summary = "Get inbound-order package inventory totals", description = "Zero-balance dimensions are excluded. Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<InventoryDashboardResponse> summary(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -48,7 +48,7 @@ public class InventoryQueryController {
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Returns only non-zero dimensions with the frozen receiving overseas warehouse ID/code/name. Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<PageResult<InventoryBalanceView>> balances(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -79,7 +79,7 @@ public class InventoryQueryController {
|
||||
|
||||
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||
public ResponseEntity<byte[]> exportBalances(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
|
||||
+4
-2
@@ -14,7 +14,8 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InventoryQueryMapper {
|
||||
String BALANCE_FILTER = " WHERE 1=1" +
|
||||
String BALANCE_FILTER = " WHERE (available_packages+hold_packages+allocated_packages+picked_packages+" +
|
||||
"in_transit_packages+receiving_packages+procurement_packages)>0" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
|
||||
"<if test='q.customerName != null and q.customerName != \"\"'> AND customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
@@ -37,7 +38,8 @@ public interface InventoryQueryMapper {
|
||||
"<when test='q.inventoryStatus == \"PROCUREMENT\"'> AND procurement_packages > 0</when>" +
|
||||
"</choose>";
|
||||
|
||||
String JOINED_BALANCE_FILTER = " WHERE 1=1" +
|
||||
String JOINED_BALANCE_FILTER = " WHERE (b.available_packages+b.hold_packages+b.allocated_packages+" +
|
||||
"b.picked_packages+b.in_transit_packages+b.receiving_packages+b.procurement_packages)>0" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND b.customer_id=#{customerId}</if>" +
|
||||
"<if test='q.customerName != null and q.customerName != \"\"'> AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND b.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
|
||||
+1
-1
@@ -268,7 +268,7 @@ public class LocalLooseboardService {
|
||||
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId()));
|
||||
order.setCustomerId(source.getCustomerId());
|
||||
order.setOrderType("LOCAL_LOOSEBOARD");
|
||||
order.setInboundCode(source.getOrderNo());
|
||||
order.setInboundCode(null);
|
||||
order.setSourceRegion("OVERSEAS_LOCAL");
|
||||
order.setSourceInboundId("LOCAL_LOOSEBOARD:" + source.getId());
|
||||
order.setSourceOrderId(String.valueOf(source.getId()));
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderCommandRecordMapper extends BaseMapper<LastlegOrderCommandRecord> {
|
||||
@Select("SELECT * FROM ll_order_command_record WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||
"AND action=#{action} AND operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
|
||||
LastlegOrderCommandRecord find(@Param("customerId") String customerId, @Param("orderId") long orderId,
|
||||
@Param("action") String action, @Param("operatorId") String operatorId,
|
||||
@Param("key") String key);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderHoldHistoryMapper extends BaseMapper<LastlegOrderHoldHistory> { }
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderManualNodeHistoryMapper extends BaseMapper<LastlegOrderManualNodeHistory> { }
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderManualNodeMapper extends BaseMapper<LastlegOrderManualNode> {
|
||||
@Delete("DELETE FROM ll_order_manual_node WHERE customer_id=#{customerId} AND order_id=#{orderId}")
|
||||
int deleteCurrent(@Param("customerId") String customerId, @Param("orderId") long orderId);
|
||||
}
|
||||
@@ -14,20 +14,27 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " +
|
||||
"OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))";
|
||||
|
||||
String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " +
|
||||
"ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
|
||||
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " +
|
||||
"<if test='q.containerNo != null and q.containerNo != \"\"'>AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') </if>" +
|
||||
"<if test='q.createdFrom != null'>AND o.created_at >=#{q.createdFrom} </if>" +
|
||||
"<if test='q.createdTo != null'>AND o.created_at <=#{q.createdTo} </if>" +
|
||||
"GROUP BY o.source_container_booking_id " +
|
||||
"HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
|
||||
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) " +
|
||||
"< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) ";
|
||||
"GROUP BY o.source_container_booking_id ";
|
||||
|
||||
String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," +
|
||||
"CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
|
||||
"THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
|
||||
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_id,''))=1 THEN MAX(o.overseas_warehouse_id) ELSE NULL END AS overseasWarehouseId," +
|
||||
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_code,''))=1 THEN MAX(o.overseas_warehouse_code) ELSE NULL END AS overseasWarehouseCode," +
|
||||
"CASE WHEN COUNT(DISTINCT NULLIF(o.overseas_warehouse_name,''))=1 THEN MAX(o.overseas_warehouse_name) ELSE NULL END AS overseasWarehouseName," +
|
||||
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 THEN MAX(o.receiving_warehouse_version) ELSE -1 END AS assignmentVersion," +
|
||||
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)=1 AND NOT EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN TRUE ELSE FALSE END AS canChangeReceivingWarehouse," +
|
||||
"CASE WHEN COUNT(DISTINCT o.receiving_warehouse_version)<>1 THEN '柜内接柜仓分配版本不一致' " +
|
||||
"WHEN EXISTS (SELECT 1 FROM ll_receiving_container_session s WHERE s.source_container_booking_id=o.source_container_booking_id) THEN '海外PDA已建立接柜会话,接柜仓已永久锁定' ELSE NULL END AS receivingWarehouseLockReason," +
|
||||
"COUNT(*) AS totalOrderCount,COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) AS inboundCodeCount," +
|
||||
"SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END) AS missingInboundCodeCount," +
|
||||
"COUNT(DISTINCT p.id) AS planCount," +
|
||||
@@ -114,14 +121,52 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
List<String> lockContainerNumbers(
|
||||
@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
|
||||
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_receiving_container_session WHERE source_container_booking_id=#{sourceContainerBookingId}")
|
||||
long countReceivingSessions(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||
|
||||
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_code=#{warehouseCode}," +
|
||||
"overseas_warehouse_name=#{warehouseName},receiving_warehouse_version=receiving_warehouse_version+1,updated_at=NOW(3) " +
|
||||
"WHERE source_container_booking_id=#{sourceContainerBookingId} AND receiving_warehouse_version=#{expectedVersion}")
|
||||
int updateContainerReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId,
|
||||
@Param("warehouseId") String warehouseId, @Param("warehouseCode") String warehouseCode,
|
||||
@Param("warehouseName") String warehouseName, @Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("UPDATE ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
|
||||
"SET p.overseas_warehouse_id=#{warehouseId},p.version=p.version+1,p.updated_at=NOW(3) " +
|
||||
"WHERE o.source_container_booking_id=#{sourceContainerBookingId}")
|
||||
int updateContainerPlanReceivingWarehouse(@Param("sourceContainerBookingId") String sourceContainerBookingId,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}")
|
||||
long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
|
||||
|
||||
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE")
|
||||
LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
|
||||
@Update("UPDATE ll_order SET detained=#{held},hold_chargeable=#{chargeable}," +
|
||||
"hold_reason=CASE WHEN #{held}=1 THEN #{reason} ELSE NULL END,version=version+1,updated_at=#{updatedAt} " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{id} AND version=#{expectedVersion}")
|
||||
int updateHold(@Param("customerId") String customerId, @Param("id") long id,
|
||||
@Param("held") boolean held, @Param("chargeable") boolean chargeable,
|
||||
@Param("reason") String reason, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("updatedAt") java.time.LocalDateTime updatedAt);
|
||||
|
||||
@Update("UPDATE ll_order SET manual_node_override=1,estimated_arrival_at=#{estimatedArrivalAt}," +
|
||||
"actual_arrival_at=#{actualArrivalAt},estimated_warehouse_at=#{estimatedWarehouseAt}," +
|
||||
"version=version+1,updated_at=#{updatedAt} WHERE customer_id=#{customerId} AND id=#{id} " +
|
||||
"AND version=#{expectedVersion}")
|
||||
int updateManualNodes(@Param("customerId") String customerId, @Param("id") long id,
|
||||
@Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
|
||||
@Param("actualArrivalAt") java.time.LocalDateTime actualArrivalAt,
|
||||
@Param("estimatedWarehouseAt") java.time.LocalDateTime estimatedWarehouseAt,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("updatedAt") java.time.LocalDateTime updatedAt);
|
||||
|
||||
@Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{id} " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " +
|
||||
"AND package_count-shipped_package_count-picked_package_count >= #{packages}")
|
||||
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
|
||||
@Param("packages") long packages);
|
||||
@@ -130,18 +175,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," +
|
||||
"picked_package_count=picked_package_count-#{packages}," +
|
||||
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " +
|
||||
"AND shipped_package_count+#{packages} <= package_count")
|
||||
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
|
||||
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
|
||||
|
||||
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
|
||||
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
|
||||
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
|
||||
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
|
||||
@Param("warehouseId") String warehouseId,
|
||||
@Param("warehouseName") String warehouseName);
|
||||
|
||||
@Update("UPDATE ll_order SET pallet_merge_flag=#{flag}," +
|
||||
"pallet_merge_batch_id=CASE WHEN #{flag} IS NULL THEN NULL ELSE pallet_merge_batch_id END," +
|
||||
"updated_at=#{updatedAt} " +
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingWarehouseOperationMapper extends BaseMapper<ReceivingWarehouseOperation> {
|
||||
@Select("SELECT * FROM ll_receiving_warehouse_operation WHERE idempotency_key=#{key} LIMIT 1")
|
||||
ReceivingWarehouseOperation findByKey(@Param("key") String key);
|
||||
}
|
||||
+1
-1
@@ -287,7 +287,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
order.setId(IdWorker.getId());
|
||||
order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId()));
|
||||
order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION");
|
||||
order.setInboundCode(relabelNo); order.setSourceRegion("CUSTOMER");
|
||||
order.setInboundCode(null); order.setSourceRegion("CUSTOMER");
|
||||
order.setSourceInboundId(relabelNo + ":" + warehouseId);
|
||||
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
|
||||
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
|
||||
|
||||
+3
-1
@@ -38,11 +38,13 @@ public class InboundOrderDetainedOutboxService {
|
||||
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||
String eventKey = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED + ':'
|
||||
+ order.getSourceInboundId() + ':' + changeVersion;
|
||||
String causationId = EventIdentifiers.isSupported(order.getSourceEventId())
|
||||
? order.getSourceEventId() : null;
|
||||
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt,
|
||||
RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER",
|
||||
order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(),
|
||||
eventKey, order.getSourceEventId(), payloadNode,
|
||||
eventKey, causationId, payloadNode,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
|
||||
|
||||
LastlegEventOutbox event = new LastlegEventOutbox();
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderHoldHistory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LastlegOrderCommandService {
|
||||
private static final String HOLD = "HOLD";
|
||||
private static final String MANUAL_NODES = "MANUAL_NODES";
|
||||
private static final List<String> NODE_CODES = List.of(
|
||||
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
|
||||
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
|
||||
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private LastlegOrderCommandRecordMapper commands;
|
||||
@Resource private LastlegOrderHoldHistoryMapper holdHistory;
|
||||
@Resource private LastlegOrderManualNodeMapper manualNodes;
|
||||
@Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory;
|
||||
@Resource private LastlegOrderOperationLogMapper operationLogs;
|
||||
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId,
|
||||
String idempotencyKey,
|
||||
LastlegOrderCommandDtos.HoldRequest request,
|
||||
String operatorId, String operatorName) {
|
||||
String key = key(idempotencyKey);
|
||||
LastlegOrder order = lockOwned(requestedCustomerId, orderId);
|
||||
String hash = hash(request);
|
||||
LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, HOLD, operatorId, key);
|
||||
if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.HoldResponse.class);
|
||||
int version = version(order);
|
||||
if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试");
|
||||
boolean held = request.held();
|
||||
if (Boolean.TRUE.equals(order.getDetained()) == held) {
|
||||
throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态");
|
||||
}
|
||||
if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount())
|
||||
|| text(order.getOutboundOrderNo()) != null)) {
|
||||
throw conflict("尾程订单已进入出库流程,不能新增暂扣");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
boolean chargeable = held && request.chargeable();
|
||||
String reason = request.reason().trim();
|
||||
if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) {
|
||||
throw conflict("尾程订单版本已变化,请刷新后重试");
|
||||
}
|
||||
int nextVersion = version + 1;
|
||||
LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse(
|
||||
orderId, held, chargeable, held ? reason : null, nextVersion);
|
||||
LastlegOrderHoldHistory history = new LastlegOrderHoldHistory();
|
||||
history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId);
|
||||
history.setHeld(held); history.setChargeable(chargeable); history.setReason(reason);
|
||||
history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); history.setOperatorName(operatorName);
|
||||
history.setIdempotencyKey(key); history.setCreatedAt(now); holdHistory.insert(history);
|
||||
operation(order, held ? "暂扣" : "解除暂扣", operatorName,
|
||||
(chargeable ? "计费;" : "不计费;") + reason, now);
|
||||
saveCommand(order, HOLD, operatorId, key, hash, null, response, nextVersion, now);
|
||||
order.setDetained(held);
|
||||
order.setHoldChargeable(chargeable);
|
||||
order.setHoldReason(held ? reason : null);
|
||||
order.setVersion(nextVersion);
|
||||
if (text(order.getSourceInboundId()) != null) {
|
||||
detainedOutbox.publish(order, held, reason, operatorId, operatorName,
|
||||
now.toInstant(ZoneOffset.UTC), history.getId());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId,
|
||||
String idempotencyKey,
|
||||
LastlegOrderCommandDtos.ManualNodesRequest request,
|
||||
String operatorId, String operatorName) {
|
||||
String key = key(idempotencyKey);
|
||||
LastlegOrder order = lockOwned(requestedCustomerId, orderId);
|
||||
List<LastlegOrderCommandDtos.ManualNode> normalized = normalizeNodes(request.nodes());
|
||||
String hash = hash(new LastlegOrderCommandDtos.ManualNodesRequest(request.expectedVersion(), normalized));
|
||||
LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, MANUAL_NODES, operatorId, key);
|
||||
if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.ManualNodesResponse.class);
|
||||
int version = version(order);
|
||||
if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试");
|
||||
Map<String, LocalDateTime> values = new HashMap<>();
|
||||
normalized.forEach(node -> values.put(node.nodeCode(), node.nodeAt()));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (orders.updateManualNodes(order.getCustomerId(), orderId,
|
||||
values.get("ESTIMATED_ARRIVAL"), values.get("ACTUAL_ARRIVAL_WAITING_UNLOAD"),
|
||||
null, version, now) != 1) {
|
||||
throw conflict("尾程订单版本已变化,请刷新后重试");
|
||||
}
|
||||
int nextVersion = version + 1;
|
||||
manualNodes.deleteCurrent(order.getCustomerId(), orderId);
|
||||
List<LastlegOrderCommandDtos.ManualNodeView> views = new ArrayList<>(normalized.size());
|
||||
for (LastlegOrderCommandDtos.ManualNode node : normalized) {
|
||||
LastlegOrderManualNode entity = new LastlegOrderManualNode();
|
||||
entity.setId(IdWorker.getId()); entity.setCustomerId(order.getCustomerId()); entity.setOrderId(orderId);
|
||||
entity.setNodeCode(node.nodeCode()); entity.setNodeAt(node.nodeAt()); entity.setSource("MANUAL");
|
||||
entity.setOrderVersion(nextVersion); entity.setUpdatedBy(operatorId); entity.setUpdatedAt(now);
|
||||
manualNodes.insert(entity);
|
||||
views.add(new LastlegOrderCommandDtos.ManualNodeView(node.nodeCode(), node.nodeAt(), "MANUAL"));
|
||||
}
|
||||
LastlegOrderCommandDtos.ManualNodesResponse response = new LastlegOrderCommandDtos.ManualNodesResponse(
|
||||
orderId, true, List.copyOf(views), nextVersion);
|
||||
LastlegOrderManualNodeHistory history = new LastlegOrderManualNodeHistory();
|
||||
history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId);
|
||||
history.setNodesJson(json(views)); history.setOrderVersion(nextVersion); history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName); history.setIdempotencyKey(key); history.setCreatedAt(now);
|
||||
manualNodeHistory.insert(history);
|
||||
operation(order, "人工计划节点", operatorName, "保存" + views.size() + "个人工节点", now);
|
||||
saveCommand(order, MANUAL_NODES, operatorId, key, hash, null, response, nextVersion, now);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public LastlegOrderCommandDtos.ManualNodesResponse manualNodes(String requestedCustomerId, long orderId) {
|
||||
LastlegOrder order = findOwned(requestedCustomerId, orderId);
|
||||
List<LastlegOrderCommandDtos.ManualNodeView> nodes = manualNodes.selectList(
|
||||
new LambdaQueryWrapper<LastlegOrderManualNode>()
|
||||
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrderManualNode::getOrderId, orderId)
|
||||
.orderByAsc(LastlegOrderManualNode::getNodeCode))
|
||||
.stream().map(node -> new LastlegOrderCommandDtos.ManualNodeView(
|
||||
node.getNodeCode(), node.getNodeAt(), node.getSource()))
|
||||
.sorted(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))).toList();
|
||||
return new LastlegOrderCommandDtos.ManualNodesResponse(orderId,
|
||||
Boolean.TRUE.equals(order.getManualNodeOverride()), nodes, version(order));
|
||||
}
|
||||
|
||||
private List<LastlegOrderCommandDtos.ManualNode> normalizeNodes(List<LastlegOrderCommandDtos.ManualNode> nodes) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
boolean anyTime = false;
|
||||
List<LastlegOrderCommandDtos.ManualNode> result = new ArrayList<>();
|
||||
for (LastlegOrderCommandDtos.ManualNode node : nodes) {
|
||||
String code = node.nodeCode().trim().toUpperCase();
|
||||
if (!NODE_CODES.contains(code)) throw badRequest("不支持的人工展示节点:" + code);
|
||||
if (!seen.add(code)) throw badRequest("人工节点不能重复:" + code);
|
||||
result.add(new LastlegOrderCommandDtos.ManualNode(code, node.nodeAt()));
|
||||
anyTime |= node.nodeAt() != null;
|
||||
}
|
||||
if (!seen.equals(Set.copyOf(NODE_CODES))) throw badRequest("必须提交完整8行人工时间快照");
|
||||
if (!anyTime) throw badRequest("人工时间快照至少填写一个节点时间");
|
||||
result.sort(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode())));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private LastlegOrder lockOwned(String requestedCustomerId, long orderId) {
|
||||
LastlegOrder found = findOwned(requestedCustomerId, orderId);
|
||||
LastlegOrder locked = orders.lockById(found.getCustomerId(), orderId);
|
||||
if (locked == null) throw notFound();
|
||||
return locked;
|
||||
}
|
||||
|
||||
private LastlegOrder findOwned(String requestedCustomerId, long orderId) {
|
||||
LastlegOrder order = orders.selectById(orderId);
|
||||
if (order == null || (text(requestedCustomerId) != null
|
||||
&& !requestedCustomerId.trim().equals(order.getCustomerId()))) throw notFound();
|
||||
return order;
|
||||
}
|
||||
|
||||
private void operation(LastlegOrder order, String node, String operatorName, String result, LocalDateTime now) {
|
||||
LastlegOrderOperationLog log = new LastlegOrderOperationLog();
|
||||
log.setId(IdWorker.getId()); log.setCustomerId(order.getCustomerId()); log.setOrderId(order.getId());
|
||||
log.setOperationNode(node); log.setOperatorName(operatorName); log.setResult(result); log.setCreatedAt(now);
|
||||
operationLogs.insert(log);
|
||||
}
|
||||
|
||||
private void saveCommand(LastlegOrder order, String action, String operatorId, String key, String hash,
|
||||
String resultRef, Object response, int resultVersion, LocalDateTime now) {
|
||||
LastlegOrderCommandRecord record = new LastlegOrderCommandRecord();
|
||||
record.setId(IdWorker.getId()); record.setCustomerId(order.getCustomerId()); record.setOrderId(order.getId());
|
||||
record.setAction(action); record.setOperatorId(operatorId); record.setIdempotencyKey(key);
|
||||
record.setRequestHash(hash); record.setResultRef(resultRef); record.setResultJson(json(response));
|
||||
record.setResultVersion(resultVersion); record.setCreatedAt(now); commands.insert(record);
|
||||
}
|
||||
|
||||
private <T> T replay(LastlegOrderCommandRecord record, String hash, Class<T> type) {
|
||||
if (!MessageDigest.isEqual(record.getRequestHash().getBytes(StandardCharsets.UTF_8),
|
||||
hash.getBytes(StandardCharsets.UTF_8))) throw conflict("同一幂等键不能用于不同请求");
|
||||
try { return objectMapper.readValue(record.getResultJson(), type); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取已保存命令结果", exception); }
|
||||
}
|
||||
|
||||
private String hash(Object request) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsBytes(request)));
|
||||
} catch (NoSuchAlgorithmException | JsonProcessingException exception) {
|
||||
throw new IllegalStateException("无法生成命令摘要", exception);
|
||||
}
|
||||
}
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); }
|
||||
}
|
||||
private String key(String value) {
|
||||
String key = text(value);
|
||||
if (key == null || key.length() > 128) throw badRequest("Idempotency-Key 必须为1至128个字符");
|
||||
return key;
|
||||
}
|
||||
private int version(LastlegOrder order) { return order.getVersion() == null ? 0 : order.getVersion(); }
|
||||
private boolean positive(Integer value) { return value != null && value > 0; }
|
||||
private String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); }
|
||||
private ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+158
-18
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.event.ContainerNumbers;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.dto.ContainerPlanProgressDetail;
|
||||
@@ -15,6 +16,9 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderFee;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderOperationLog;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderManualNode;
|
||||
import com.baiyue.lastleg.entity.ReceivingWarehouseOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
@@ -23,6 +27,8 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
|
||||
import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper;
|
||||
import com.baiyue.lastleg.model.LastlegOrderType;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -37,6 +43,7 @@ import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -50,16 +57,22 @@ public class LastlegOrderService {
|
||||
public static final String INBOUND_PLANNED = "INBOUND_PLANNED";
|
||||
public static final String PALLET_MERGE_REQUIRED = "REQUIRED";
|
||||
public static final String PALLET_MERGED = "MERGED";
|
||||
private static final List<String> MANUAL_NODE_CODES = List.of(
|
||||
"DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL",
|
||||
"OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED");
|
||||
|
||||
@Resource private LastlegOrderMapper orderMapper;
|
||||
@Resource private LastlegOrderDetailMapper detailMapper;
|
||||
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
||||
@Resource private LastlegOrderFeeMapper feeMapper;
|
||||
@Resource private LastlegOrderOperationLogMapper operationLogMapper;
|
||||
@Resource private LastlegOrderManualNodeMapper manualNodeMapper;
|
||||
@Resource private InboundPlanCartonMapper cartonMapper;
|
||||
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
|
||||
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
|
||||
@Resource private WarehouseResourceGateway warehouseResourceGateway;
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
@@ -80,7 +93,7 @@ public class LastlegOrderService {
|
||||
long total = orderMapper.countContainerProgress(query);
|
||||
List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
|
||||
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size())
|
||||
.stream().map(this::requireFrozenContainer).toList();
|
||||
.stream().map(this::containerSummaryForRead).toList();
|
||||
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
|
||||
return new PageResult<>(records, query.page(), query.size(), total, pages);
|
||||
}
|
||||
@@ -95,17 +108,131 @@ public class LastlegOrderService {
|
||||
if (summary == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
|
||||
}
|
||||
return new ContainerPlanProgressDetail(requireFrozenContainer(summary),
|
||||
return new ContainerPlanProgressDetail(containerSummaryForRead(summary),
|
||||
orderMapper.findContainerProgressItems(bookingId));
|
||||
}
|
||||
|
||||
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
|
||||
try {
|
||||
ContainerNumbers.requireCanonical(summary.containerNo());
|
||||
return summary;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
@Transactional
|
||||
public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId,
|
||||
String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) {
|
||||
String bookingId = normalizeBookingId(sourceContainerBookingId);
|
||||
String key = normalizeIdempotencyKey(idempotencyKey);
|
||||
String requestHash = hash(bookingId + "\n" + request.overseasWarehouseId() + "\n"
|
||||
+ request.expectedVersion() + "\n" + Objects.toString(request.remark(), ""));
|
||||
ReceivingWarehouseOperation replay = receivingWarehouseOperations.findByKey(key);
|
||||
if (replay != null) {
|
||||
if (!bookingId.equals(replay.getSourceContainerBookingId()) || !requestHash.equals(replay.getRequestHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Idempotency-Key 已用于其他接柜仓修改请求");
|
||||
}
|
||||
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
|
||||
}
|
||||
|
||||
List<LastlegOrder> orders = orderMapper.lockContainerOrders(bookingId);
|
||||
if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
|
||||
int version = requireSingleAssignmentVersion(orders);
|
||||
if (version != request.expectedVersion()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓分配版本已变化,请刷新后重试");
|
||||
}
|
||||
if (orderMapper.countReceivingSessions(bookingId) > 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"来源柜真实集装箱号缺失、格式无效或存在漂移", exception);
|
||||
"海外PDA已建立接柜会话,接柜仓已永久锁定");
|
||||
}
|
||||
WarehouseResourceGateway.WarehouseReference warehouse =
|
||||
warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId());
|
||||
for (LastlegOrder order : orders) {
|
||||
inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(),
|
||||
warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key);
|
||||
}
|
||||
int updated = orderMapper.updateContainerReceivingWarehouse(bookingId,
|
||||
Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version);
|
||||
if (updated != orders.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配发生并发变化");
|
||||
}
|
||||
orderMapper.updateContainerPlanReceivingWarehouse(bookingId, warehouse.warehouseId());
|
||||
ReceivingWarehouseOperation operation = new ReceivingWarehouseOperation();
|
||||
operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId);
|
||||
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
|
||||
operation.setResultVersion(version + 1); operation.setOperatorName(operatorName);
|
||||
operation.setRemark(request.remark() == null ? null : request.remark().trim());
|
||||
operation.setCreatedAt(LocalDateTime.now()); receivingWarehouseOperations.insert(operation);
|
||||
return requireFrozenContainer(orderMapper.findContainerProgress(bookingId));
|
||||
}
|
||||
|
||||
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
|
||||
if (summary == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
|
||||
String issue = containerSnapshotIssue(summary);
|
||||
if (issue != null) throw new ResponseStatusException(HttpStatus.CONFLICT, issue);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private ContainerPlanProgressSummary containerSummaryForRead(ContainerPlanProgressSummary summary) {
|
||||
String issue = containerSnapshotIssue(summary);
|
||||
if (issue == null) return summary;
|
||||
boolean invalidContainer = !hasCanonicalContainerNo(summary.containerNo());
|
||||
return new ContainerPlanProgressSummary(summary.sourceContainerBookingId(), summary.containerNo(),
|
||||
summary.overseasWarehouseId(), summary.overseasWarehouseCode(), summary.overseasWarehouseName(),
|
||||
summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(),
|
||||
invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(),
|
||||
summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(),
|
||||
summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.plannedArrivalAt(),
|
||||
false, issue);
|
||||
}
|
||||
|
||||
private String containerSnapshotIssue(ContainerPlanProgressSummary summary) {
|
||||
if (!hasCanonicalContainerNo(summary.containerNo())) {
|
||||
return "来源柜真实集装箱号缺失、格式无效或存在漂移";
|
||||
}
|
||||
if (!StringUtils.hasText(summary.overseasWarehouseId())
|
||||
|| !StringUtils.hasText(summary.overseasWarehouseCode())
|
||||
|| !StringUtils.hasText(summary.overseasWarehouseName()) || summary.assignmentVersion() < 0) {
|
||||
return "柜内接柜仓业务快照不一致";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hasCanonicalContainerNo(String containerNo) {
|
||||
try {
|
||||
ContainerNumbers.requireCanonical(containerNo);
|
||||
return true;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeBookingId(String value) {
|
||||
String result = value == null ? null : value.trim();
|
||||
if (!StringUtils.hasText(result) || result.length() > 128) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String normalizeIdempotencyKey(String value) {
|
||||
String result = value == null ? null : value.trim();
|
||||
if (!StringUtils.hasText(result) || result.length() > 255) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必填且不得超过255字符");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private int requireSingleAssignmentVersion(List<LastlegOrder> orders) {
|
||||
Integer version = orders.get(0).getReceivingWarehouseVersion();
|
||||
if (version == null || orders.stream().anyMatch(order -> !Objects.equals(version,
|
||||
order.getReceivingWarehouseVersion()) || !StringUtils.hasText(order.getOverseasWarehouseId())
|
||||
|| !StringUtils.hasText(order.getOverseasWarehouseCode())
|
||||
|| !StringUtils.hasText(order.getOverseasWarehouseName()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓分配事实不一致");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private String hash(String value) {
|
||||
try {
|
||||
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +254,15 @@ public class LastlegOrderService {
|
||||
.eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrderOperationLog::getOrderId, orderId)
|
||||
.orderByAsc(LastlegOrderOperationLog::getCreatedAt));
|
||||
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream()
|
||||
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes = manualNodeMapper.selectList(
|
||||
new LambdaQueryWrapper<LastlegOrderManualNode>()
|
||||
.eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrderManualNode::getOrderId, orderId)
|
||||
.orderByAsc(LastlegOrderManualNode::getId)).stream()
|
||||
.map(value -> new LastlegOrderCommandDtos.ManualNodeView(
|
||||
value.getNodeCode(), value.getNodeAt(), value.getSource()))
|
||||
.sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList();
|
||||
return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream()
|
||||
.map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(),
|
||||
value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream()
|
||||
.map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(),
|
||||
@@ -358,7 +493,7 @@ public class LastlegOrderService {
|
||||
for (LastlegOrder order : orders) {
|
||||
String line = String.join(",", List.of(
|
||||
csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())),
|
||||
csv(order.getInboundCode()),
|
||||
csv(displayInboundCode(order)),
|
||||
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
|
||||
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
|
||||
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
|
||||
@@ -380,6 +515,7 @@ public class LastlegOrderService {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
|
||||
}
|
||||
String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status();
|
||||
String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null;
|
||||
return new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
|
||||
@@ -389,11 +525,7 @@ public class LastlegOrderService {
|
||||
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
|
||||
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo())
|
||||
.eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus)
|
||||
.and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper
|
||||
.like(LastlegOrder::getOverseasWarehouseId, query.warehouse())
|
||||
.or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse())
|
||||
.or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse())
|
||||
.or().like(LastlegOrder::getTransferWarehouseName, query.warehouse()))
|
||||
.apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse)
|
||||
.eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
|
||||
query.transferWarehouseCode())
|
||||
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
|
||||
@@ -433,10 +565,12 @@ public class LastlegOrderService {
|
||||
List<LastlegOrderMapper.PalletMergeRelationRow> related = relations == null ? List.of() : relations;
|
||||
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
|
||||
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
|
||||
o.getInboundCode(), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||
displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||
o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
|
||||
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
|
||||
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
|
||||
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(),
|
||||
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
|
||||
@@ -445,6 +579,12 @@ public class LastlegOrderService {
|
||||
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
|
||||
}
|
||||
|
||||
private static String displayInboundCode(LastlegOrder order) {
|
||||
String orderType = order.getOrderType();
|
||||
return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType)
|
||||
? null : order.getInboundCode();
|
||||
}
|
||||
|
||||
private static String csv(Object value) {
|
||||
if (value == null) return "";
|
||||
String text = String.valueOf(value);
|
||||
|
||||
+10
-3
@@ -30,6 +30,9 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
@Service
|
||||
public class LoadingCompletedConsumer {
|
||||
static final String DEFAULT_RECEIVING_WAREHOUSE_ID = "193000000000200003";
|
||||
static final String DEFAULT_RECEIVING_WAREHOUSE_CODE = "US-LAX-01";
|
||||
static final String DEFAULT_RECEIVING_WAREHOUSE_NAME = "洛杉矶仓";
|
||||
@Resource private LastlegEventInboxMapper inboxMapper;
|
||||
@Resource private LastlegOrderMapper orderMapper;
|
||||
@Resource private LastlegInboundOrderMapper inboundOrderMapper;
|
||||
@@ -240,7 +243,10 @@ public class LoadingCompletedConsumer {
|
||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
||||
order.setCustomerName(customerName);
|
||||
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
|
||||
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
|
||||
order.setOverseasWarehouseId(DEFAULT_RECEIVING_WAREHOUSE_ID);
|
||||
order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE);
|
||||
order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME);
|
||||
order.setReceivingWarehouseVersion(0);
|
||||
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
|
||||
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
|
||||
order.setPackageCount(source.packageCount());
|
||||
@@ -335,8 +341,9 @@ public class LoadingCompletedConsumer {
|
||||
FirstLegLoadingPlanCompletedV1.Item item = source.items().get(index);
|
||||
if (item.packageCount() <= 0) continue;
|
||||
inventory.registerInTransit(order.getCustomerId(), "SYSTEM",
|
||||
new InventoryCommandRequests.InTransit(order.getCustomerName(), payload.warehouseId(), null,
|
||||
payload.warehouseName(), item.destinationWarehouseId(), item.destinationWarehouseCode(),
|
||||
new InventoryCommandRequests.InTransit(order.getCustomerName(), order.getOverseasWarehouseId(),
|
||||
order.getOverseasWarehouseCode(), order.getOverseasWarehouseName(),
|
||||
item.destinationWarehouseId(), item.destinationWarehouseCode(),
|
||||
item.destinationWarehouseName(), order.getId(), inboundOrderNo, source.inboundCode(),
|
||||
item.skuCode(), item.productNameZh(), item.productNameEn(), item.packageCount(),
|
||||
order.getOrderNo(), event.eventId(),
|
||||
|
||||
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发',
|
||||
lx_code VARCHAR(128) NULL COMMENT '一件代发领星入库单代码',
|
||||
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码',
|
||||
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
|
||||
inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
|
||||
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
|
||||
root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID',
|
||||
@@ -58,8 +58,10 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
customer_name VARCHAR(255) COMMENT '货主名称',
|
||||
source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照',
|
||||
source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照',
|
||||
overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结',
|
||||
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓',
|
||||
overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照',
|
||||
overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照',
|
||||
receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
|
||||
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
|
||||
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
|
||||
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
|
||||
@@ -68,6 +70,10 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
|
||||
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
|
||||
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
|
||||
hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是',
|
||||
hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空',
|
||||
manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本',
|
||||
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
|
||||
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数',
|
||||
@@ -108,6 +114,14 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
|
||||
PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained'));
|
||||
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable'));
|
||||
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason'));
|
||||
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override'));
|
||||
PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_detail (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
|
||||
@@ -177,6 +191,61 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
|
||||
KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_command_record (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用',
|
||||
result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key),
|
||||
KEY idx_ll_order_command_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_hold_history (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是',
|
||||
chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因',
|
||||
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key),
|
||||
KEY idx_ll_order_hold_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_manual_node (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离',
|
||||
node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL',
|
||||
order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_manual_node_history (
|
||||
id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照',
|
||||
order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key),
|
||||
KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation (
|
||||
id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键',
|
||||
request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要',
|
||||
result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称',
|
||||
remark VARCHAR(500) NULL COMMENT '接柜仓修改备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key),
|
||||
KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录';
|
||||
|
||||
-- 派送单列表与详情快照、转派送费用及操作记录。
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
id BIGINT NOT NULL COMMENT '派送单主键ID',
|
||||
@@ -349,7 +418,7 @@ ALTER TABLE ll_order
|
||||
MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
||||
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
|
||||
MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型',
|
||||
MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
|
||||
MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空',
|
||||
MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
|
||||
MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
|
||||
MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID',
|
||||
@@ -357,8 +426,10 @@ ALTER TABLE ll_order
|
||||
MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号',
|
||||
MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
|
||||
MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称',
|
||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID',
|
||||
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称',
|
||||
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL DEFAULT '193000000000200003' COMMENT '海外接柜仓ID,新产生普通尾程整柜默认洛杉矶仓',
|
||||
MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NOT NULL DEFAULT 'US-LAX-01' COMMENT '海外接柜仓业务编码快照',
|
||||
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL DEFAULT '洛杉矶仓' COMMENT '海外接柜仓名称快照',
|
||||
MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT '柜级接柜仓分配乐观锁版本',
|
||||
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
|
||||
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
|
||||
MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
|
||||
@@ -485,7 +556,14 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
|
||||
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||
overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结',
|
||||
overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID',
|
||||
planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期',
|
||||
pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型',
|
||||
assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是',
|
||||
merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是',
|
||||
urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是',
|
||||
creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工',
|
||||
plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
|
||||
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
|
||||
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
|
||||
@@ -537,7 +615,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
|
||||
id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
|
||||
pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号',
|
||||
pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码',
|
||||
source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照',
|
||||
pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
@@ -569,6 +649,20 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation (
|
||||
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id'));
|
||||
PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
ALTER TABLE ll_inbound_plan
|
||||
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
|
||||
MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
|
||||
@@ -599,7 +693,7 @@ ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目
|
||||
ALTER TABLE ll_inbound_plan_pallet
|
||||
MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
|
||||
MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID',
|
||||
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号',
|
||||
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码',
|
||||
MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
||||
MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
@@ -621,6 +715,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外仓实物托盘ID'''));
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no'));
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外仓库位ID'''));
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''海外仓资源锁ID'''));
|
||||
@@ -851,7 +947,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
||||
@@ -1087,7 +1183,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt (
|
||||
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照',
|
||||
product_name_en VARCHAR(255) NULL COMMENT '英文品名快照',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '海外仓实物托盘码',
|
||||
source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照',
|
||||
warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码',
|
||||
batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数',
|
||||
inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID',
|
||||
@@ -1465,13 +1562,20 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址',
|
||||
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
|
||||
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
|
||||
destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ll_relabel_item_application (customer_id,application_id),
|
||||
KEY idx_ll_relabel_item_sku (customer_id,sku_code)
|
||||
KEY idx_ll_relabel_item_sku (customer_id,sku_code),
|
||||
KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
|
||||
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
|
||||
@@ -149,7 +149,7 @@ paths:
|
||||
/api/platform/v1/last-leg/orders:
|
||||
get:
|
||||
summary: 分页查询尾程订单
|
||||
description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
@@ -160,13 +160,13 @@ paths:
|
||||
- { name: inboundCode, in: query, schema: { type: string } }
|
||||
- { name: sourceOrderNo, in: query, schema: { type: string } }
|
||||
- { name: containerNo, in: query, schema: { type: string } }
|
||||
- { name: warehouse, in: query, schema: { type: string } }
|
||||
- { name: warehouse, in: query, description: 按尾程订单冻结的海外接柜仓名称或编码进行大小写无关的包含匹配, schema: { type: string } }
|
||||
- { name: transferWarehouseCode, in: query, schema: { type: string } }
|
||||
- { name: abnormal, in: query, schema: { type: boolean } }
|
||||
- { name: detained, in: query, schema: { type: boolean } }
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } }
|
||||
/api/platform/v1/last-leg/orders/{id}:
|
||||
get:
|
||||
summary: 查询尾程订单详情
|
||||
@@ -192,25 +192,46 @@ paths:
|
||||
responses:
|
||||
'200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } }
|
||||
'404': { description: 尾程订单不存在或不属于操作客户 }
|
||||
/api/platform/v1/last-leg/orders/{id}/detained:
|
||||
/api/platform/v1/last-leg/orders/{id}/hold:
|
||||
put:
|
||||
summary: 设置或解除尾程订单暂扣
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;库存同步转入或释放暂扣,并通过跨区事件回写国内入仓单。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [detained, remark]
|
||||
properties:
|
||||
detained: { type: boolean }
|
||||
remark: { type: string, minLength: 1, maxLength: 500 }
|
||||
responses: { '200': { description: 暂扣状态已更新 } }
|
||||
schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' }
|
||||
responses:
|
||||
'200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } }
|
||||
'409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 }
|
||||
/api/platform/v1/last-leg/orders/{id}/manual-nodes:
|
||||
get:
|
||||
summary: 查询尾程订单人工展示节点
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;人工覆盖开启后返回完整且固定顺序的8行快照,行值允许为空。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
responses: { '200': { description: 完整人工展示节点快照 } }
|
||||
put:
|
||||
summary: 永久启用并保存尾程订单人工展示节点
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;提交完整8行快照且至少一个时间非空,使用订单版本和 Idempotency-Key;保存后 nodeSource 永久为 MANUAL,不回写柜节点,也不提供清除或恢复继承接口。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/LastlegOrderManualNodesRequest' } } }
|
||||
responses:
|
||||
'200': { description: 更新后的完整8行人工展示节点快照 }
|
||||
'409': { description: 订单版本或幂等键冲突 }
|
||||
/api/platform/v1/last-leg/orders/{id}/pallet-merge-required:
|
||||
put:
|
||||
summary: 设置尾程订单合托标记
|
||||
@@ -253,7 +274,7 @@ paths:
|
||||
/api/platform/v1/last-leg/orders/container-summaries:
|
||||
get:
|
||||
summary: 分页查询柜级入库计划进度
|
||||
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回仍有入仓码待生成有效独立计划的柜,全部入仓码均已生成有效独立计划的柜在分页前排除。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
@@ -283,7 +304,7 @@ paths:
|
||||
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:
|
||||
get:
|
||||
summary: 查询柜级入库计划进度详情
|
||||
description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
@@ -298,10 +319,27 @@ paths:
|
||||
code: { type: string, enum: ['200'] }
|
||||
data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' }
|
||||
'404': { description: 稳定柜 ID 不存在 }
|
||||
/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse:
|
||||
put:
|
||||
summary: 修改整柜海外接柜仓
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ChangeReceivingWarehouseRequest' }
|
||||
responses:
|
||||
'200': { description: 更新后的柜级摘要, content: { application/json: { schema: { type: object, properties: { code: { type: string, enum: ['200'] }, data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } } } } }
|
||||
'400': { description: 请求无效或目标不是有效 OVERSEAS 仓 }
|
||||
'409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 }
|
||||
/api/platform/v1/last-leg/orders/{id}/destination:
|
||||
put:
|
||||
summary: 将单票尾程订单转入 FBA 目的仓
|
||||
description: 权限 platform:overseas-warehouse:order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
@@ -318,7 +356,7 @@ paths:
|
||||
/api/platform/v1/last-leg/orders/destination:
|
||||
put:
|
||||
summary: 批量将尾程订单转入 FBA 目的仓
|
||||
description: 权限 platform:overseas-warehouse:order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。
|
||||
description: 权限 platform:overseas-warehouse:last-mile-order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
@@ -331,26 +369,59 @@ paths:
|
||||
'200': { description: 统一 ApiResponse 包装的更新订单数 }
|
||||
'400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 }
|
||||
'503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 }
|
||||
/api/platform/v1/last-leg/inbound-plans/convert:
|
||||
post:
|
||||
summary: 平台生成入库计划
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。
|
||||
/api/platform/v1/last-leg/inbound-plans:
|
||||
get:
|
||||
summary: 平台分页查询海外入库订单
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: planNo, in: query, schema: { type: string } }
|
||||
- { name: inboundCode, in: query, schema: { type: string } }
|
||||
- { name: lxCode, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
- { name: overseasWarehouseId, in: query, schema: { type: string } }
|
||||
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
|
||||
- { name: warehouse, in: query, schema: { type: string } }
|
||||
- { name: fbaWarehouseCode, in: query, schema: { type: string } }
|
||||
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
|
||||
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 平台入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } }
|
||||
/api/platform/v1/last-leg/inbound-plans/{planNo}:
|
||||
get:
|
||||
summary: 平台查询海外入库订单详情
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:list;详情中的 overseasWarehouseCode/overseasWarehouseName 只取当前计划来源尾程订单的预分配接柜仓事实。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: planNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||
responses:
|
||||
'200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }
|
||||
'404': { description: 入库订单不存在或不属于指定客户 }
|
||||
/api/platform/v1/last-leg/inbound-plans/manual:
|
||||
post:
|
||||
summary: 平台按单票手工生成入库计划
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [customerId, orderId]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } }
|
||||
schema: { $ref: '#/components/schemas/ManualInboundPlanRequest' }
|
||||
responses:
|
||||
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
|
||||
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
|
||||
'409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 }
|
||||
/api/platform/v1/last-leg/inbound-plans/convert-container:
|
||||
post:
|
||||
summary: 按整柜批量生成入库计划
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有计划的真实 containerNo 快照一致。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等;任何柜号漂移均冲突且不部分写入。
|
||||
summary: 按整柜批量生成剩余入库计划
|
||||
description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有有效计划的真实 containerNo 与接柜仓快照一致。已有有效计划保持不变,只为剩余尾程订单/入仓码分别生成独立计划;响应按柜内订单稳定顺序返回全部已有及新增计划。整批原子且幂等,剩余为0时直接返回已有结果;任何快照漂移均冲突且不部分写入。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -514,7 +585,7 @@ paths:
|
||||
description: 权限 platform:overseas-warehouse:outbound:list;只按全局唯一 outboundNo 定位并从记录解析客户范围,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: outboundNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 出库单及关联派送单详情 }, '404': { description: 出库单不存在 } }
|
||||
responses: { '200': { description: 出库单、出库托盘及关联派送单详情;pallets[].palletNo 为 Warehouse 权威实物托盘码 }, '404': { description: 出库单不存在 } }
|
||||
/api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:
|
||||
post:
|
||||
summary: 取消未执行出库单
|
||||
@@ -811,7 +882,7 @@ paths:
|
||||
/api/merchant/v1/last-leg/inbound-plans:
|
||||
get:
|
||||
summary: 客户分页查询自己的海外入库订单
|
||||
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId。
|
||||
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
@@ -829,7 +900,7 @@ paths:
|
||||
/api/merchant/v1/last-leg/inbound-plans/{planNo}:
|
||||
get:
|
||||
summary: 客户查询自己的海外入库订单详情
|
||||
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId。
|
||||
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId;overseasWarehouseCode/overseasWarehouseName 只取该计划来源尾程订单的预分配接柜仓事实。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 当前客户入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }, '404': { description: 当前客户下入库订单不存在 } }
|
||||
@@ -1093,10 +1164,16 @@ components:
|
||||
items: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
ContainerPlanProgressSummary:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, containerNo, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
|
||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string }
|
||||
containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 }
|
||||
overseasWarehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 平台预分配的海外接柜仓ID,不是FBA目的仓 }
|
||||
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码 }
|
||||
overseasWarehouseName: { type: string, description: 海外接柜仓业务名称 }
|
||||
assignmentVersion: { type: integer, minimum: 0, description: 柜级接柜仓乐观锁版本 }
|
||||
canChangeReceivingWarehouse: { type: boolean }
|
||||
receivingWarehouseLockReason: { type: string, nullable: true }
|
||||
totalOrderCount: { type: integer, format: int64 }
|
||||
inboundCodeCount: { type: integer, format: int64 }
|
||||
missingInboundCodeCount: { type: integer, format: int64 }
|
||||
@@ -1106,6 +1183,13 @@ components:
|
||||
plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at }
|
||||
allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true }
|
||||
blockingReason: { type: string, nullable: true }
|
||||
ChangeReceivingWarehouseRequest:
|
||||
type: object
|
||||
required: [overseasWarehouseId, expectedVersion]
|
||||
properties:
|
||||
overseasWarehouseId: { type: integer, format: int64, minimum: 1 }
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
remark: { type: string, maxLength: 500, nullable: true }
|
||||
ContainerPlanProgressDetail:
|
||||
type: object
|
||||
required: [summary, orders]
|
||||
@@ -1329,10 +1413,12 @@ components:
|
||||
warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 }
|
||||
ReceivingCandidate:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, containerNo, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
|
||||
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
|
||||
properties:
|
||||
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
|
||||
containerNo: { type: string }
|
||||
overseasWarehouseCode: { type: string }
|
||||
overseasWarehouseName: { type: string }
|
||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||
totalOrderCount: { type: integer, minimum: 1 }
|
||||
missingInboundCodeCount: { type: integer, minimum: 0 }
|
||||
@@ -1351,10 +1437,11 @@ components:
|
||||
currentAccount: { type: boolean }
|
||||
ReceivingSession:
|
||||
type: object
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
|
||||
properties:
|
||||
containerNo: { type: string }
|
||||
overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID }
|
||||
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
|
||||
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
|
||||
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
|
||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||
operatorName: { type: string, nullable: true }
|
||||
@@ -1471,7 +1558,7 @@ components:
|
||||
orderNo: { type: string }
|
||||
orderRefs: { type: string }
|
||||
inboundCode: { type: string }
|
||||
palletNo: { type: string }
|
||||
palletNo: { type: string, description: 未入库时为计划托盘码;绑定实物托盘后为 Warehouse 权威实物托盘码 }
|
||||
destinationWarehouseId: { type: string }
|
||||
destinationWarehouseCode: { type: string }
|
||||
destinationWarehouseName: { type: string }
|
||||
@@ -1730,6 +1817,58 @@ components:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } }
|
||||
LastlegOrderHoldRequest:
|
||||
type: object
|
||||
required: [held, chargeable, reason, expectedVersion]
|
||||
properties:
|
||||
held: { type: boolean }
|
||||
chargeable: { type: boolean, description: 仅 held=true 时生效;解除暂扣后固定为 false }
|
||||
reason: { type: string, minLength: 1, maxLength: 500 }
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
LastlegOrderHoldResponse:
|
||||
type: object
|
||||
required: [orderId, held, chargeable, version]
|
||||
properties:
|
||||
orderId: { type: integer, format: int64 }
|
||||
held: { type: boolean }
|
||||
chargeable: { type: boolean }
|
||||
reason: { type: string, nullable: true }
|
||||
version: { type: integer, minimum: 0 }
|
||||
LastlegOrderManualNodeCode:
|
||||
type: string
|
||||
enum: [DOMESTIC_CUSTOMS_COMPLETED, ESTIMATED_DEPARTURE, ACTUAL_DEPARTURE, ESTIMATED_ARRIVAL, OVERSEAS_CUSTOMS_COMPLETED, ACTUAL_ARRIVAL_WAITING_UNLOAD, PICKUP_APPOINTED, UNLOADED]
|
||||
LastlegOrderManualNode:
|
||||
type: object
|
||||
required: [nodeCode]
|
||||
properties:
|
||||
nodeCode: { $ref: '#/components/schemas/LastlegOrderManualNodeCode' }
|
||||
nodeAt: { type: string, format: date-time, nullable: true }
|
||||
LastlegOrderManualNodesRequest:
|
||||
type: object
|
||||
required: [expectedVersion, nodes]
|
||||
properties:
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
nodes:
|
||||
type: array
|
||||
minItems: 8
|
||||
maxItems: 8
|
||||
description: 必须包含8个枚举节点且各出现一次,至少一个 nodeAt 非空。
|
||||
items: { $ref: '#/components/schemas/LastlegOrderManualNode' }
|
||||
ManualInboundPlanRequest:
|
||||
type: object
|
||||
description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。
|
||||
required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent]
|
||||
properties:
|
||||
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
orderId: { type: integer, format: int64, minimum: 1 }
|
||||
expectedOrderVersion: { type: integer, minimum: 0 }
|
||||
plannedArrivalDate: { type: string, format: date }
|
||||
palletCount: { type: integer, minimum: 1, maximum: 999 }
|
||||
palletType: { type: string, minLength: 1, maxLength: 64 }
|
||||
assigned: { type: boolean }
|
||||
mergeRequired: { type: boolean }
|
||||
urgent: { type: boolean }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
PageMetadata:
|
||||
type: object
|
||||
required: [page, size, total, pages]
|
||||
@@ -1911,7 +2050,7 @@ components:
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
|
||||
InboundPlanSummary:
|
||||
type: object
|
||||
required: [lineId, planId, planNo, customerId, orderId, inboundCode, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
|
||||
required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
|
||||
properties:
|
||||
lineId: { type: string, pattern: '^[0-9]+$' }
|
||||
planId: { type: string, pattern: '^[0-9]+$' }
|
||||
@@ -1920,6 +2059,8 @@ components:
|
||||
orderId: { type: string, pattern: '^[0-9]+$' }
|
||||
inboundCode: { type: string }
|
||||
orderType: { type: string, nullable: true }
|
||||
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
|
||||
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
|
||||
warehouseId: { type: string, nullable: true }
|
||||
warehouseCode: { type: string, nullable: true }
|
||||
warehouseName: { type: string, nullable: true }
|
||||
@@ -1939,19 +2080,28 @@ components:
|
||||
version: { type: integer }
|
||||
InboundPlanDetail:
|
||||
type: object
|
||||
required: [id, planNo, customerId, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
|
||||
required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
planNo: { type: string }
|
||||
customerId: { type: string }
|
||||
sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
containerNo: { type: string, nullable: true }
|
||||
overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 }
|
||||
overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }
|
||||
status: { type: string }
|
||||
lineCount: { type: integer }
|
||||
palletCount: { type: integer }
|
||||
totalVolumeCbm: { type: number }
|
||||
expectedPackageCount: { type: integer }
|
||||
pickedPackageCount: { type: integer }
|
||||
plannedArrivalDate: { type: string, format: date, nullable: true }
|
||||
palletType: { type: string, nullable: true }
|
||||
assigned: { type: boolean }
|
||||
mergeRequired: { type: boolean }
|
||||
urgent: { type: boolean }
|
||||
creationMode: { type: string, enum: [AUTO, MANUAL] }
|
||||
remark: { type: string, nullable: true }
|
||||
lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } }
|
||||
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
|
||||
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
|
||||
@@ -1974,6 +2124,7 @@ components:
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
palletNo: { type: string }
|
||||
palletType: { type: string, nullable: true }
|
||||
destinationWarehouseId: { type: string, nullable: true }
|
||||
destinationWarehouseCode: { type: string, nullable: true }
|
||||
destinationWarehouseName: { type: string, nullable: true }
|
||||
|
||||
@@ -46,6 +46,14 @@ paths:
|
||||
description: 一件代发复用统一出库任务模型;不接收customerId或warehouseId,派送不进入PDA。
|
||||
parameters: [{ name: status, in: query, required: false, schema: { type: string } }]
|
||||
responses: { '200': { description: 出库任务列表 } }
|
||||
/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:
|
||||
get:
|
||||
summary: 解析出库作业扫码业务号
|
||||
description: 接受一件代发任务号、出库单号或排车单号;按Token绑定仓返回 OUTBOUND 的 taskNo 或 DISPATCH 的 dispatchNo,PDA据此进入正确作业页。
|
||||
parameters: [{ name: businessNo, in: path, required: true, schema: { type: string } }]
|
||||
responses:
|
||||
'200': { description: 正确作业类型及稳定业务编号 }
|
||||
'404': { description: 当前绑定仓无此出库或排车任务 }
|
||||
/api/pda/v1/last-leg/outbound-tasks/{taskNo}:
|
||||
get:
|
||||
summary: 查询一件代发出库任务详情
|
||||
@@ -569,7 +577,7 @@ components:
|
||||
required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions]
|
||||
properties:
|
||||
outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 }
|
||||
palletNo: { type: string }
|
||||
palletNo: { type: string, description: Warehouse 权威实物托盘码 }
|
||||
skuCode: { type: string }
|
||||
inboundCode: { type: string }
|
||||
packageCount: { type: integer }
|
||||
@@ -582,7 +590,7 @@ components:
|
||||
required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages]
|
||||
properties:
|
||||
inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 }
|
||||
palletNo: { type: string }
|
||||
palletNo: { type: string, description: Warehouse 权威实物托盘码 }
|
||||
cartonMark: { type: string, nullable: true }
|
||||
skuCode: { type: string }
|
||||
inboundCode: { type: string }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg;
|
||||
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
|
||||
class QueryBindingContractTest {
|
||||
@Test
|
||||
void mvcQueryRecordsExposeOneResolvableConstructor() {
|
||||
assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(DeliveryRequests.Query.class));
|
||||
assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(InboundPlanRequests.Query.class));
|
||||
}
|
||||
}
|
||||
+40
@@ -9,11 +9,14 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.service.LastlegOrderCommandService;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderCommandDtos;
|
||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -32,6 +35,7 @@ import static org.mockito.Mockito.when;
|
||||
class LastlegOrderControllerTest {
|
||||
// Controller contract tests intentionally instantiate the controller without a Spring context here.
|
||||
private final LastlegOrderService service = mock(LastlegOrderService.class);
|
||||
private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class);
|
||||
private final LastlegOrderController controller = controller();
|
||||
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
|
||||
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
@@ -59,6 +63,23 @@ class LastlegOrderControllerTest {
|
||||
verify(service).page(eq("200"), eq(query));
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdCommandPassesVersionedIdempotentRequestToSingleOrderService() {
|
||||
var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2);
|
||||
|
||||
controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request);
|
||||
|
||||
verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester");
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualNodeQueryUsesOrderScopeRatherThanContainerScope() {
|
||||
controller.manualNodes(user(0L, "PLATFORM"), "100", 11L);
|
||||
|
||||
verify(commandService).manualNodes("100", 11L);
|
||||
verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
|
||||
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null);
|
||||
@@ -68,6 +89,24 @@ class LastlegOrderControllerTest {
|
||||
verify(service).containerProgressPage(summaryQuery);
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerEndpointsUseCanonicalLastMileOrderAuthorities() throws Exception {
|
||||
var list = LastlegOrderController.class.getMethod("containerSummaries",
|
||||
AuthenticatedUser.class, LastlegOrderRequests.ContainerProgressQuery.class);
|
||||
var detail = LastlegOrderController.class.getMethod("containerSummaryDetail",
|
||||
AuthenticatedUser.class, String.class);
|
||||
var change = LastlegOrderController.class.getMethod("changeReceivingWarehouse",
|
||||
AuthenticatedUser.class, String.class, String.class,
|
||||
LastlegOrderRequests.ChangeReceivingWarehouse.class);
|
||||
|
||||
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')",
|
||||
list.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')",
|
||||
detail.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:edit')",
|
||||
change.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerSummariesRejectMerchantIdentity() {
|
||||
var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null);
|
||||
@@ -130,6 +169,7 @@ class LastlegOrderControllerTest {
|
||||
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
|
||||
ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class));
|
||||
ReflectionTestUtils.setField(controller, "orderService", service);
|
||||
ReflectionTestUtils.setField(controller, "commandService", commandService);
|
||||
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
|
||||
return controller;
|
||||
}
|
||||
|
||||
+33
-3
@@ -11,6 +11,12 @@ import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -25,8 +31,31 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class PlatformDispatchOutboundContractTest {
|
||||
@Test
|
||||
void outboundDetailAcceptsTheDocumentUrlWithoutPagingParameters() throws Exception {
|
||||
DeliveryTransferController controller = new DeliveryTransferController();
|
||||
ReflectionTestUtils.setField(controller, "service",
|
||||
mock(com.baiyue.lastleg.delivery.service.DeliveryTransferService.class));
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
|
||||
.build();
|
||||
AuthenticatedUser platform = new AuthenticatedUser(1000001L, 0L, 0L, 1L, "platform_admin",
|
||||
"PLATFORM", "WEB", Set.of(OverseasPermissions.OUTBOUND_LIST));
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(platform, null, Set.of()));
|
||||
try {
|
||||
mockMvc.perform(get("/api/platform/v1/last-leg/outbound-orders/OB-WH-20260907-030529"))
|
||||
.andExpect(status().isOk());
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception {
|
||||
Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class,
|
||||
@@ -35,7 +64,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Transfer.class);
|
||||
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
|
||||
AuthenticatedUser.class, String.class, DeliveryRequests.PalletQuery.class);
|
||||
AuthenticatedUser.class, String.class);
|
||||
Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound",
|
||||
AuthenticatedUser.class, String.class, DeliveryRequests.Cancel.class);
|
||||
|
||||
@@ -43,6 +72,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL);
|
||||
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
|
||||
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
|
||||
assertEquals(2, outboundDetail.getParameterCount());
|
||||
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
|
||||
for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) {
|
||||
assertTrue(Arrays.stream(method.getParameters())
|
||||
@@ -91,8 +121,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
() -> dispatchController.cancel(merchant, "DSP-101"));
|
||||
ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> deliveryController.outboundDetail(merchant, "OUT-101",
|
||||
new DeliveryRequests.PalletQuery(1, 20, null)));
|
||||
() -> deliveryController.outboundDetail(merchant, "OUT-101"));
|
||||
|
||||
assertEquals(403, dispatchRejected.getStatusCode().value());
|
||||
assertEquals(403, outboundRejected.getStatusCode().value());
|
||||
@@ -137,6 +166,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
|
||||
assertFalse(action.contains("name: customerId"));
|
||||
}
|
||||
assertTrue(outboundDetail.contains("pallets[].palletNo"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class MerchantDeliveryControllerTest {
|
||||
private final DeliveryTransferService service = mock(DeliveryTransferService.class);
|
||||
private final MerchantDeliveryController controller = controller();
|
||||
private final DeliveryRequests.Query query = new DeliveryRequests.Query(
|
||||
1, 20, null, null, null, null, null, null, null, null, null, null);
|
||||
1, 20, null, null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
@Test
|
||||
void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception {
|
||||
|
||||
+95
-8
@@ -53,6 +53,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -140,6 +141,40 @@ class DeliveryTransferServiceTest {
|
||||
assertTrue(!result.replayed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferBackfillsReceivedPlanPalletsBeforeCheckingDispatchItems() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.bindReceivedPallets("TENANT-A", 101L)).thenReturn(1);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-after-receiving", null));
|
||||
|
||||
var ordered = inOrder(dispatchItems);
|
||||
ordered.verify(dispatchItems).bindReceivedPallets("TENANT-A", 101L);
|
||||
ordered.verify(dispatchItems).findByDispatchOrder("TENANT-A", 101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
LastlegOrder primary = new LastlegOrder();
|
||||
primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1");
|
||||
LastlegOrder derived = new LastlegOrder();
|
||||
derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null);
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of());
|
||||
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null));
|
||||
|
||||
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
assertEquals("LX-1", outbound.getValue().getLxCodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
@@ -235,20 +270,26 @@ class DeliveryTransferServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() {
|
||||
void outboundDetailIncludesPalletCodesForDisplay() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
OutboundPalletItem pallet = pallet(501L, "IP-ONT8-20260907-000001-P001", "ONT8", "Ontario", "IN-A", 12);
|
||||
pallet.setSkuCode("SKU-1");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet));
|
||||
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
|
||||
new DeliveryRequests.PalletQuery(1, 20, null));
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
|
||||
|
||||
assertEquals("OUT-301", detail.outbound().outboundNo());
|
||||
assertEquals("DEL-401", detail.delivery().deliveryNo());
|
||||
assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents())
|
||||
.noneMatch(component -> "pallets".equals(component.getName())));
|
||||
verify(outboundPallets, never()).selectPage(any(Page.class), any());
|
||||
assertEquals(1, detail.pallets().size());
|
||||
assertEquals("IP-ONT8-20260907-000001-P001", detail.pallets().get(0).palletNo());
|
||||
assertEquals("IN-A", detail.pallets().get(0).inboundCode());
|
||||
assertEquals("ONT8", detail.pallets().get(0).destinationWarehouseCode());
|
||||
assertEquals("SKU-1", detail.pallets().get(0).skuCode());
|
||||
assertEquals(12, detail.pallets().get(0).packageCount());
|
||||
verify(outboundPallets).findByOutbound("TENANT-A", 301L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -258,8 +299,7 @@ class DeliveryTransferServiceTest {
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery);
|
||||
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301",
|
||||
new DeliveryRequests.PalletQuery(1, 20, null));
|
||||
DeliveryResponses.OutboundDetail detail = service.outboundDetail("OUT-301");
|
||||
|
||||
assertEquals("OUT-301", detail.outbound().outboundNo());
|
||||
verify(deliveryOrders).lockByOutbound("TENANT-A", 301L);
|
||||
@@ -393,6 +433,53 @@ class DeliveryTransferServiceTest {
|
||||
verify(pdaTasks).listByWarehouse("WH-1", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundNumberResolvesDispatchWorkflowInBoundWarehouse() {
|
||||
OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
outbound.setOutboundType("DISPATCH_DELIVERY");
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch);
|
||||
|
||||
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
|
||||
|
||||
assertEquals("DISPATCH", result.workflow());
|
||||
assertEquals("DSP-401", result.dispatchNo());
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundNumberResolvesOneClickTaskInBoundWarehouse() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setOverseasWarehouseId("WH-1");
|
||||
outbound.setOutboundType("ONE_CLICK_DROPSHIP");
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(pdaTasks.findByOutboundAndWarehouse("TENANT-A", 301L, "WH-1")).thenReturn(task);
|
||||
|
||||
DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301");
|
||||
|
||||
assertEquals("OUTBOUND", result.workflow());
|
||||
assertEquals("PDA-701", result.taskNo());
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaTaskResolutionDoesNotLeakAnotherWarehouse() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
outbound.setOverseasWarehouseId("WH-2");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.resolvePdaTask("WH-1", "OUT-301"));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(pdaTasks, never()).findByOutboundAndWarehouse(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPdaDetailReturnsEveryPickablePositionWithStableBusinessCodes() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key");
|
||||
|
||||
+14
@@ -35,6 +35,20 @@ class DispatchCandidateMapperContractTest {
|
||||
assertTrue(sql.contains("pos.available_packages > 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
|
||||
for (Method method : List.of(
|
||||
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
|
||||
String.class, String.class, String.class),
|
||||
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
|
||||
assertTrue(sql.contains("pos.overseas_warehouse_id"));
|
||||
assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)"));
|
||||
assertTrue(sql.contains("ll_inventory_balance"));
|
||||
}
|
||||
}
|
||||
|
||||
private void assertConstructorColumnOrder(Method method) {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
int previous = -1;
|
||||
|
||||
+67
-2
@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
@@ -16,6 +18,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -32,6 +36,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -49,6 +54,7 @@ class DispatchServiceTest {
|
||||
@Mock DispatchDraftMapper drafts;
|
||||
@Mock DispatchDraftContainerMapper draftContainers;
|
||||
@Mock DispatchPdaTaskMapper pdaTasks;
|
||||
@Mock LastlegOrderMapper tailOrders;
|
||||
private DispatchService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -61,8 +67,26 @@ class DispatchServiceTest {
|
||||
ReflectionTestUtils.setField(service, "drafts", drafts);
|
||||
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
|
||||
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
|
||||
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
|
||||
lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of());
|
||||
lenient().when(tailOrders.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
LastlegOrder held = new LastlegOrder();
|
||||
held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true);
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(held));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null,
|
||||
null, null, null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,6 +151,21 @@ class DispatchServiceTest {
|
||||
verify(items).insert(any(DispatchItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() {
|
||||
DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
|
||||
null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8",
|
||||
12, new BigDecimal("1.2000"), "RECEIVED");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
|
||||
|
||||
assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
@@ -145,15 +184,41 @@ class DispatchServiceTest {
|
||||
void preArrangeConvertsOwnedDraftLockInsteadOfInsertingAnotherLock() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
|
||||
lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
|
||||
DispatchDraft draft = new DispatchDraft();
|
||||
draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
|
||||
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
|
||||
when(drafts.lockActive("draft-1", "7")).thenReturn(draft);
|
||||
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, "draft-1"));
|
||||
|
||||
assertNotNull(lock.getDispatchOrderId());
|
||||
assertEquals(null, lock.getDraftToken());
|
||||
assertNull(lock.getDraftToken());
|
||||
assertNull(lock.getDraftContainerId());
|
||||
assertEquals("RELEASED", draft.getStatus());
|
||||
assertNotNull(draft.getReleasedAt());
|
||||
verify(locks).updateById(lock);
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class));
|
||||
verify(draftContainers).delete(any(LambdaQueryWrapper.class));
|
||||
verify(drafts).updateById(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() {
|
||||
DispatchDraft draft = new DispatchDraft();
|
||||
draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
|
||||
DispatchDraftContainer container = new DispatchDraftContainer();
|
||||
container.setId(21L); container.setDraftToken("draft-1"); container.setCapacity(12);
|
||||
DispatchPalletLock owned = new DispatchPalletLock();
|
||||
owned.setPlanPalletId(10L); owned.setDraftToken("draft-1"); owned.setDraftContainerId(21L);
|
||||
when(drafts.findActiveByOperator("7")).thenReturn(List.of(draft));
|
||||
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(container));
|
||||
when(locks.findActiveByDraftContainer("draft-1", 21L)).thenReturn(List.of(owned));
|
||||
|
||||
var result = service.activeDrafts("7");
|
||||
|
||||
assertEquals(List.of("10"), result.get(0).containers().get(0).palletIds());
|
||||
verify(locks).findActiveByDraftContainer("draft-1", 21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -141,7 +141,8 @@ class DropshipOrderServiceTest {
|
||||
|
||||
private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) {
|
||||
InboundStockReceipt receipt = new InboundStockReceipt();
|
||||
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo);
|
||||
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId);
|
||||
receipt.setPalletNo("WH-" + palletId); receipt.setSourcePalletNo(palletNo);
|
||||
receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
+7
@@ -27,6 +27,13 @@ class InboundPlanSchemaContractTest {
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
|
||||
assertTrue(schema.contains("expected_loading_at"));
|
||||
assertTrue(schema.contains("planned_arrival_date"));
|
||||
assertTrue(schema.contains("creation_mode"));
|
||||
assertTrue(schema.contains("pallet_type"));
|
||||
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
|
||||
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
|
||||
assertTrue(schema.contains("create table if not exists ll_order_manual_node"));
|
||||
assertTrue(schema.contains("create table if not exists ll_order_manual_node_history"));
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item"));
|
||||
assertTrue(schema.contains("fba_warehouse_code"));
|
||||
assertFalse(schema.contains("cabinet_no"));
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.inboundplan;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TailPlanCommandOpenApiContractTest {
|
||||
@Test
|
||||
void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception {
|
||||
try (var stream = getClass().getClassLoader()
|
||||
.getResourceAsStream("openapi/overseas-business-v1.yaml")) {
|
||||
assertTrue(stream != null);
|
||||
String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:"));
|
||||
assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:"));
|
||||
assertFalse(api.contains("plannedWarehouseId"));
|
||||
assertFalse(api.contains("plannedWarehouseCode"));
|
||||
assertFalse(api.contains("plannedWarehouseName"));
|
||||
int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:");
|
||||
int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart);
|
||||
assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart);
|
||||
assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode"));
|
||||
assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents())
|
||||
.anyMatch(component -> "inboundCode".equals(component.getName())));
|
||||
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:"));
|
||||
assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:"));
|
||||
assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:"));
|
||||
assertTrue(api.contains("minItems: 8"));
|
||||
assertTrue(api.contains("maxItems: 8"));
|
||||
assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-23
@@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
@@ -19,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -41,7 +42,7 @@ class InboundPlanControllerTest {
|
||||
@Test
|
||||
void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() {
|
||||
InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null,
|
||||
null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
controller.page(platform(), null, query);
|
||||
@@ -61,31 +62,19 @@ class InboundPlanControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformConversionRequiresAnExplicitCustomer() {
|
||||
assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("平台生成入库计划必须指定客户");
|
||||
verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
void platformManualPlanPassesVersionedIdempotentCommand() {
|
||||
ManualInboundPlanRequest request = manualRequest();
|
||||
controller.manual(platform(), "manual-key", request);
|
||||
verify(service).convertManual(request, "manual-key", "7");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformConversionPassesTheProvidedLongCustomerId() {
|
||||
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L,
|
||||
"193000000000200003");
|
||||
|
||||
controller.convert(platform(), request);
|
||||
|
||||
verify(service).convert("193000000000200003", 9223372036854775807L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() {
|
||||
void merchantCannotCreateManualPlan() {
|
||||
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
|
||||
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200");
|
||||
|
||||
controller.convert(customer, request);
|
||||
|
||||
verify(service).convert("100", 2L);
|
||||
assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest()))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,4 +98,9 @@ class InboundPlanControllerTest {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
||||
}
|
||||
|
||||
private static ManualInboundPlanRequest manualRequest() {
|
||||
return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0,
|
||||
LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class InboundPlanPermissionContractTest {
|
||||
@Test
|
||||
void conversionUsesCanonicalInboundOrderCreateAuthority() {
|
||||
Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("convert"))
|
||||
.filter(candidate -> candidate.getName().equals("manual"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
|
||||
+29
@@ -17,5 +17,34 @@ class InboundPlanOrderMapperContractTest {
|
||||
assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,'')))"));
|
||||
assertTrue(sql.contains("THEN 1 ELSE COUNT(DISTINCT"));
|
||||
assertTrue(sql.contains("0 AS destinationMismatchCount"));
|
||||
assertTrue(sql.contains("o.overseas_warehouse_id AS overseasWarehouseId"));
|
||||
assertTrue(sql.contains("o.overseas_warehouse_code AS overseasWarehouseCode"));
|
||||
assertTrue(sql.contains("o.overseas_warehouse_name AS overseasWarehouseName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void receivingWarehouseSnapshotIsReadFromTenantScopedOrderFact() throws Exception {
|
||||
Select select = InboundPlanOrderMapper.class
|
||||
.getMethod("findReceivingWarehouse", String.class, Long.class)
|
||||
.getAnnotation(Select.class);
|
||||
String sql = String.join(" ", select.value());
|
||||
|
||||
assertTrue(sql.contains("overseas_warehouse_code AS overseasWarehouseCode"));
|
||||
assertTrue(sql.contains("overseas_warehouse_name AS overseasWarehouseName"));
|
||||
assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}"));
|
||||
assertTrue(!sql.contains("destination_warehouse"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualPlanLocksEveryOrderInTheSelectedStableContainer() throws Exception {
|
||||
Select select = InboundPlanOrderMapper.class
|
||||
.getMethod("lockContainerWarehouseFacts", String.class, Long.class)
|
||||
.getAnnotation(Select.class);
|
||||
String sql = String.join(" ", select.value());
|
||||
|
||||
assertTrue(sql.contains("c.source_container_booking_id=target.source_container_booking_id"));
|
||||
assertTrue(sql.contains("target.customer_id=#{customerId} AND target.id=#{orderId}"));
|
||||
assertTrue(sql.contains("c.overseas_warehouse_id AS overseasWarehouseId"));
|
||||
assertTrue(sql.contains("ORDER BY c.customer_id,c.id FOR UPDATE"));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user