Merge origin/dev and resolve backend conflicts

This commit is contained in:
2026-09-08 20:50:28 +08:00
342 changed files with 13161 additions and 2296 deletions
+8 -6
View File
@@ -18,7 +18,7 @@
| 任务关键词 | 首选服务 | 先看入口 | | 任务关键词 | 首选服务 | 先看入口 |
|---|---|---| |---|---|---|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | | 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 客户建档默认尺寸初始化与补偿 | `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/` 定位;业务事件生产者回到所属服务 | | 消息中心、站内通知、消息模板、收件箱 | `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` 幂等同步,不覆盖实测、箱码或已确认费用 | | 平台修改客户原始申报、履约渠道变更与跨服务同步 | `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` 保存并消费服务端快照 | | 履约分配、入库、测量、箱规算法、审核、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 拣货和国内出仓写入口统一复用门禁并失败关闭 | | 入库单扣货、解除、凭证与履约门禁 | `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`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 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`,不得等部署启动发现 | | 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` | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `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/`、`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/` | `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/` | `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/` | `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 只消费各客户不可变来源快照 | | 柜级公共费用跨客户分摊 | `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 待结算 | | 海外业务费用来源跨区入池 | `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` 和客户范围校验 | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
@@ -50,13 +51,13 @@
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/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` | | 海外 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/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只分页返回仍有入仓码待生成有效独立计划的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | | 尾程订单控制与柜级入仓计划转换 | `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 已移除 | | 海外入仓分页、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 出库或派送 | | 海外仓排车查询、候选与智能预排 | `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` | | 海外排车执行、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` |
| 海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 | | 海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 |
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | | 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按“托盘中的入仓单库存”选择,合托后的同一托盘允许包含多张入仓单但冻结明细必须保持原入仓单归属。每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结;PDA 扫箱唛、照片复核完成后释放原库存,再沿既有排车出库。Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕,出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕,出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
@@ -112,6 +113,7 @@
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - 固定客户待审核订单使用 `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 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
+2 -2
View File
@@ -230,9 +230,9 @@ SELECT
## 海外排车、出库与派送权限 ## 海外排车、出库与派送权限
- 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。 - 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建和客户回访列表/编辑十四项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。 - 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建/处理和客户回访列表/编辑十五项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。 - 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
- `WAREHOUSE_OPERATOR` 获得十四项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。 - `WAREHOUSE_OPERATOR` 获得十五项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
## 海外排车与出库结构升级 ## 海外排车与出库结构升级
+541 -82
View File
@@ -2860,6 +2860,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -2877,7 +2878,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index), UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version),
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
@@ -3139,6 +3139,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
baseline_chargeable_quantity DECIMAL(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量',
tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT '人工调整允许的最大偏差百分比,边界包含',
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
currency CHAR(3) NOT NULL COMMENT '币种', currency CHAR(3) NOT NULL COMMENT '币种',
@@ -3172,6 +3174,32 @@ ALTER TABLE fl_inbound_fee
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL MODIFY COLUMN customer_id BIGINT UNSIGNED NULL
COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段'; COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段';
SET @fee_baseline_quantity_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'),
'SELECT 1',
'ALTER TABLE fl_inbound_fee ADD COLUMN baseline_chargeable_quantity DECIMAL(18,6) NULL COMMENT ''系统按权威测量与生效报价计算的原始计费量'' AFTER converted_quantity'
);
PREPARE fee_baseline_quantity_stmt FROM @fee_baseline_quantity_sql;
EXECUTE fee_baseline_quantity_stmt;
DEALLOCATE PREPARE fee_baseline_quantity_stmt;
UPDATE fl_inbound_fee
SET baseline_chargeable_quantity = chargeable_quantity
WHERE baseline_chargeable_quantity IS NULL;
ALTER TABLE fl_inbound_fee
MODIFY COLUMN baseline_chargeable_quantity DECIMAL(18,6) NOT NULL
COMMENT '系统按权威测量与生效报价计算的原始计费量';
SET @fee_tolerance_percent_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_inbound_fee' AND column_name='tolerance_percent'),
'SELECT 1',
'ALTER TABLE fl_inbound_fee ADD COLUMN tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT ''人工调整允许的最大偏差百分比,边界包含'' AFTER baseline_chargeable_quantity'
);
PREPARE fee_tolerance_percent_stmt FROM @fee_tolerance_percent_sql;
EXECUTE fee_tolerance_percent_stmt;
DEALLOCATE PREPARE fee_tolerance_percent_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -3783,7 +3811,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
CREATE TABLE IF NOT EXISTS fl_price_book_version ( CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属',
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
@@ -5190,6 +5218,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
product_quantity INT UNSIGNED NULL COMMENT '产品总数量', product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托',
pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空',
pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照',
indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数', pallet_count INT UNSIGNED NULL COMMENT '托盘数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
@@ -5210,24 +5242,23 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细查询键;删除后返回NULL',
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END active_assignment_key VARCHAR(96) GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:', pallet_id) END, ':', inbound_detail_id) ELSE NULL END) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁',
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间',
COMMENT '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key),
KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted), KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted),
KEY idx_loading_item_pallet (pallet_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted), KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0), CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
@@ -5555,65 +5586,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号',
open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号',
pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照',
sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照',
sealed_at DATETIME(3) NULL COMMENT '封托时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code),
UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id),
UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id),
KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id),
CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED'))
) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id),
UNIQUE KEY uk_loading_work_carton_operation (operation_id),
KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id),
KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id),
CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id),
CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id),
CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0)
) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$ DELIMITER $$
@@ -6276,12 +6249,13 @@ BEGIN
'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg', 'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN 'unit_type', 'pallet_id', 'pallet_no', 'indivisible', 'active_assignment_key',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -7681,6 +7655,15 @@ SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL
0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000'
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT');
INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name,
pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable,
allocation_fee, description, status, created_by, created_by_name, created_at,
updated_by, updated_by_name, updated_at, deleted, version)
SELECT 193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY', '尾程派送费', 'INTERNATIONAL',
'私人仓尾程派送费', 0, 0, 1, 1, 0, '仅用于destinationType=PRIVATE的入库单',
'ACTIVE', 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 0
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'PRIVATE_LAST_MILE_DELIVERY');
-- ============================================================================ -- ============================================================================
-- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线 -- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线
-- ============================================================================ -- ============================================================================
@@ -8045,6 +8028,57 @@ SELECT role.id, permission.id, role.tenant_id
) )
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
-- 已存在库必须先把装柜分配唯一键收敛到“普通明细或封托托盘”,再执行三期断言。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS converge_loading_assignment_schema;
DELIMITER $$
CREATE PROCEDURE converge_loading_assignment_schema()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND column_name='unit_type') THEN
ALTER TABLE fl_loading_plan_item
ADD COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'
COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托' AFTER product_quantity,
ADD COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空' AFTER unit_type,
ADD COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照' AFTER pallet_id,
ADD COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元' AFTER pallet_no;
END IF;
ALTER TABLE fl_loading_plan_item
MODIFY COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'
COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托',
MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空',
MODIFY COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照',
MODIFY COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元';
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND column_name='active_assignment_key') THEN
ALTER TABLE fl_loading_plan_item
ADD COLUMN active_assignment_key VARCHAR(96)
GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:',pallet_id) END,':',inbound_detail_id) ELSE NULL END) STORED
COMMENT '普通明细或已封托托盘明细的有效唯一锁' AFTER active_inbound_detail_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND index_name='uk_loading_item_plan_detail') THEN
ALTER TABLE fl_loading_plan_item DROP INDEX uk_loading_item_plan_detail;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND index_name='uk_loading_item_plan_assignment') THEN
ALTER TABLE fl_loading_plan_item
ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND index_name='idx_loading_item_pallet') THEN
ALTER TABLE fl_loading_plan_item ADD KEY idx_loading_item_pallet (pallet_id,deleted);
END IF;
END$$
DELIMITER ;
CALL converge_loading_assignment_schema();
DROP PROCEDURE IF EXISTS converge_loading_assignment_schema;
-- 污染存量库必须先完成旧入库权限搬迁和两类授权恢复,再校验智能装柜权限。 -- 污染存量库必须先完成旧入库权限搬迁和两类授权恢复,再校验智能装柜权限。
USE baiyue_firstleg; USE baiyue_firstleg;
CALL assert_phase3_smart_loading_schema(); CALL assert_phase3_smart_loading_schema();
@@ -13473,10 +13507,12 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'charge_rule_id',
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'charge_rule_version', '历史客户规则版本快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'charge_rule_version', '历史客户规则版本快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'charge_unit', '计费单位'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'charge_unit', '计费单位');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'chargeable_quantity', '最终计费量'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'chargeable_quantity', '最终计费量');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'baseline_chargeable_quantity', '系统按权威测量与生效报价计算的原始计费量');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'confirmed_at', '确认时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'confirmed_at', '确认时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'confirmed_by', '确认人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'confirmed_by', '确认人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'conversion_factor', '历史客户规则使用的换算系数快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'conversion_factor', '历史客户规则使用的换算系数快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'converted_quantity', '换算后的重量或体积'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'converted_quantity', '换算后的重量或体积');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'tolerance_percent', '人工调整允许的最大偏差百分比,边界包含');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'currency', '币种'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'currency', '币种');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'customer_id', '客户ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee', 'customer_id', '客户ID快照');
@@ -15153,6 +15189,15 @@ BEGIN
'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_fee'
AND ((column_name='baseline_chargeable_quantity' AND is_nullable='NO'
AND numeric_precision=18 AND numeric_scale=6)
OR (column_name='tolerance_percent' AND is_nullable='NO'
AND numeric_precision=5 AND numeric_scale=2))) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:入库费用基准计费量或容差字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' WHERE table_schema='baiyue_iam' AND table_name='iam_tenant'
AND column_name='dimension_initialization_status') <> 1 AND column_name='dimension_initialization_status') <> 1
@@ -15607,6 +15652,11 @@ BEGIN
AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费'
AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0
AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_fee_item
WHERE tenant_id = 0 AND fee_code = 'PRIVATE_LAST_MILE_DELIVERY'
AND category = '尾程派送费' AND trade_scope = 'INTERNATIONAL'
AND fee_type_name = '私人仓尾程派送费' AND warehouse_payable = 1
AND warehouse_receivable = 1 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR EXISTS ( OR EXISTS (
SELECT 1 SELECT 1
FROM baiyue_firstleg.fl_inbound_fee_detail detail FROM baiyue_firstleg.fl_inbound_fee_detail detail
@@ -16955,6 +17005,7 @@ BEGIN
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list') OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create') OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
OR (id = 5107073 AND permission_code <> 'platform:overseas-warehouse:exception-order:process')
OR (id = 5107072 AND permission_code <> 'platform:overseas-warehouse:delivery:return') OR (id = 5107072 AND permission_code <> 'platform:overseas-warehouse:delivery:return')
OR (id = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list') OR (id = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list')
OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit')
@@ -16973,6 +17024,7 @@ BEGIN
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070) OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071) OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
OR (permission_code = 'platform:overseas-warehouse:exception-order:process' AND id <> 5107073)
OR (permission_code = 'platform:overseas-warehouse:delivery:return' AND id <> 5107072) OR (permission_code = 'platform:overseas-warehouse:delivery:return' AND id <> 5107072)
OR (permission_code = 'platform:overseas-warehouse:customer-visit:list' AND id <> 5107080) OR (permission_code = 'platform:overseas-warehouse:customer-visit:list' AND id <> 5107080)
OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081)
@@ -16997,6 +17049,7 @@ VALUES
(5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0),
(5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0),
(5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0),
(5107073,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST',773,'ACTIVE',1,0),
(5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0), (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0),
(5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0), (5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0),
(5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0), (5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0),
@@ -17032,6 +17085,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create', 'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
@@ -17056,6 +17110,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:list'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
@@ -17084,6 +17139,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create', 'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
@@ -17131,12 +17187,13 @@ BEGIN
'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create', 'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit', 'platform:overseas-warehouse:customer-visit:edit',
'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:list',
'merchant:warehouse:delivery:create')) <> 16 THEN 'merchant:warehouse:delivery:create')) <> 17 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission grant_row IF (SELECT COUNT(*) FROM iam_role_permission grant_row
@@ -17154,10 +17211,11 @@ BEGIN
'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create', 'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) <> 14 THEN 'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
END IF; END IF;
IF EXISTS (SELECT 1 FROM iam_role role IF EXISTS (SELECT 1 FROM iam_role role
@@ -17178,17 +17236,18 @@ BEGIN
'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create', 'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:customer-visit:edit')) <> 14) THEN 'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整';
END IF; END IF;
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
FROM tmp_phase5_delivery_role_permission; FROM tmp_phase5_delivery_role_permission;
IF v_warehouse_operator_permission_count <> 14 IF v_warehouse_operator_permission_count <> 15
OR v_warehouse_tally_permission_count <> 5 THEN OR v_warehouse_tally_permission_count <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
END IF; END IF;
@@ -17207,6 +17266,7 @@ BEGIN
'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:exception-order:create', 'platform:overseas-warehouse:exception-order:create',
'platform:overseas-warehouse:exception-order:process',
'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:return',
'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:status',
'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:list',
@@ -17393,7 +17453,9 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数',
counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已逐箱确认实收数量的箱数',
allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数',
discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数',
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照', assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照',
claimed_at DATETIME(3) NULL COMMENT '任务领取时间', claimed_at DATETIME(3) NULL COMMENT '任务领取时间',
@@ -17406,7 +17468,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at),
CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id),
CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')),
CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons)
) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; ) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
@@ -17415,8 +17477,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托',
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID', seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID',
@@ -17433,7 +17495,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id),
KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status),
CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED'))
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','id','柜次证据记录ID'); CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','id','柜次证据记录ID');
@@ -17500,28 +17562,182 @@ DROP TEMPORARY TABLE IF EXISTS tmp_table_comment_target;
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID',
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID', task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID', pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量',
discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE',
discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明',
count_operation_id VARCHAR(128) NOT NULL COMMENT '逐箱清点幂等操作ID',
bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID',
last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号',
counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID',
counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照',
counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间',
bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照', bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照',
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', bound_at DATETIME(3) NULL COMMENT '上托时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id),
KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id),
KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id),
KEY idx_dropship_carton_operation (operation_id), UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id),
KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id),
CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细'; ) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
-- 已存在开发库直接收敛到逐箱清点与整托分柜结构;新建库由上面的最新定义一次完成。
DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema;
DELIMITER $$
CREATE PROCEDURE converge_dropship_palletization_schema()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_carton_label'
AND column_name='units_per_carton') THEN
ALTER TABLE fl_inbound_carton_label
ADD COLUMN units_per_carton INT UNSIGNED NULL
COMMENT '每箱SKU数量快照;一件代发必须为正数' AFTER sku_code;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_task'
AND column_name='counted_cartons') THEN
ALTER TABLE fl_dropship_pallet_task
ADD COLUMN counted_cartons INT UNSIGNED NOT NULL DEFAULT 0
COMMENT '已逐箱确认实收数量的箱数' AFTER total_cartons;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_task'
AND column_name='discrepancy_cartons') THEN
ALTER TABLE fl_dropship_pallet_task
ADD COLUMN discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0
COMMENT '已确认差异箱数' AFTER allocated_cartons;
END IF;
UPDATE fl_dropship_pallet_task
SET counted_cartons=GREATEST(counted_cartons,allocated_cartons);
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_task'
AND constraint_name='chk_dropship_task_cartons') THEN
ALTER TABLE fl_dropship_pallet_task DROP CHECK chk_dropship_task_cartons;
END IF;
ALTER TABLE fl_dropship_pallet_task
ADD CONSTRAINT chk_dropship_task_cartons
CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons);
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton'
AND column_name='operation_id')
AND NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton'
AND column_name='count_operation_id') THEN
ALTER TABLE fl_dropship_pallet_carton
CHANGE COLUMN operation_id count_operation_id VARCHAR(128) NOT NULL
COMMENT '逐箱清点幂等操作ID';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton'
AND column_name='expected_sku_quantity') THEN
ALTER TABLE fl_dropship_pallet_carton
ADD COLUMN expected_sku_quantity INT UNSIGNED NULL AFTER box_code,
ADD COLUMN actual_sku_quantity INT UNSIGNED NULL AFTER expected_sku_quantity,
ADD COLUMN discrepancy_status VARCHAR(24) NULL AFTER actual_sku_quantity,
ADD COLUMN discrepancy_remark VARCHAR(500) NULL AFTER discrepancy_status,
ADD COLUMN bind_operation_id VARCHAR(128) NULL AFTER count_operation_id,
ADD COLUMN last_operation_id VARCHAR(128) NULL AFTER bind_operation_id,
ADD COLUMN counted_by BIGINT UNSIGNED NULL AFTER last_operation_id,
ADD COLUMN counted_by_name VARCHAR(64) NULL AFTER counted_by,
ADD COLUMN counted_at DATETIME(3) NULL AFTER counted_by_name;
END IF;
UPDATE fl_dropship_pallet_carton carton
LEFT JOIN fl_inbound_carton_label label ON label.id=carton.carton_label_id
SET carton.expected_sku_quantity=COALESCE(carton.expected_sku_quantity,label.units_per_carton,1),
carton.actual_sku_quantity=COALESCE(carton.actual_sku_quantity,label.units_per_carton,1),
carton.discrepancy_status=COALESCE(carton.discrepancy_status,'MATCH'),
carton.bind_operation_id=COALESCE(carton.bind_operation_id,carton.count_operation_id),
carton.last_operation_id=COALESCE(carton.last_operation_id,carton.count_operation_id),
carton.counted_by=COALESCE(carton.counted_by,carton.bound_by),
carton.counted_by_name=COALESCE(carton.counted_by_name,carton.bound_by_name),
carton.counted_at=COALESCE(carton.counted_at,carton.bound_at);
ALTER TABLE fl_dropship_pallet_carton
MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空',
MODIFY COLUMN box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
MODIFY COLUMN expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
MODIFY COLUMN actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量',
MODIFY COLUMN discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE',
MODIFY COLUMN discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明',
MODIFY COLUMN bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID',
MODIFY COLUMN last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号',
MODIFY COLUMN counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID',
MODIFY COLUMN counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照',
MODIFY COLUMN counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间',
MODIFY COLUMN bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID',
MODIFY COLUMN bound_at DATETIME(3) NULL COMMENT '上托时间';
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton'
AND index_name='uk_dropship_carton_count_operation') THEN
ALTER TABLE fl_dropship_pallet_carton
ADD UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton'
AND index_name='idx_dropship_carton_bind_operation') THEN
ALTER TABLE fl_dropship_pallet_carton
ADD KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND column_name='unit_type') THEN
ALTER TABLE fl_loading_plan_item
ADD COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'
COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托' AFTER product_quantity,
ADD COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空' AFTER unit_type,
ADD COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照' AFTER pallet_id,
ADD COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元' AFTER pallet_no;
END IF;
ALTER TABLE fl_loading_plan_item
MODIFY COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'
COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托',
MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空',
MODIFY COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照',
MODIFY COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元';
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND column_name='active_assignment_key') THEN
ALTER TABLE fl_loading_plan_item
ADD COLUMN active_assignment_key VARCHAR(96)
GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:',pallet_id) END,':',inbound_detail_id) ELSE NULL END) STORED
COMMENT '普通明细或已封托托盘明细的有效唯一锁' AFTER active_inbound_detail_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND index_name='uk_loading_item_plan_detail') THEN
ALTER TABLE fl_loading_plan_item DROP INDEX uk_loading_item_plan_detail;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND index_name='uk_loading_item_plan_assignment') THEN
ALTER TABLE fl_loading_plan_item
ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics
WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item'
AND index_name='idx_loading_item_pallet') THEN
ALTER TABLE fl_loading_plan_item ADD KEY idx_loading_item_pallet (pallet_id,deleted);
END IF;
END$$
DELIMITER ;
CALL converge_dropship_palletization_schema();
DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema;
-- Auth 登录手机号只从 IAM 中唯一归属的中国大陆手机号回填;存量重复号保持 NULL。 -- Auth 登录手机号只从 IAM 中唯一归属的中国大陆手机号回填;存量重复号保持 NULL。
USE baiyue_auth; USE baiyue_auth;
DROP PROCEDURE IF EXISTS auth_add_login_phone; DROP PROCEDURE IF EXISTS auth_add_login_phone;
@@ -18116,6 +18332,249 @@ END$$
DELIMITER ; DELIMITER ;
CALL migrate_merchant_return_inventory_permission(); CALL migrate_merchant_return_inventory_permission();
DROP PROCEDURE IF EXISTS migrate_merchant_return_inventory_permission; DROP PROCEDURE IF EXISTS migrate_merchant_return_inventory_permission;
-- 目的港码头拖车、提柜与还空闭环(模块来源: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;
-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)
-- 国内送柜报关与国外清关提柜双工作台权限;国内沿用现有货柜权限,国外直接收敛到独立权限族。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_container_workspace_permissions()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5102090 AND permission_code <> 'platform:firstleg:container:list')
OR (id = 5102092 AND permission_code <> 'platform:firstleg:container:create')
OR (id = 5102093 AND permission_code <> 'platform:firstleg:container:edit')
OR (id = 5102200 AND permission_code <> 'platform:firstleg:terminal-drayage:list')
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
) OR EXISTS (
SELECT 1 FROM iam_permission
WHERE (permission_code = 'platform:firstleg:container:list' AND id <> 5102090)
OR (permission_code = 'platform:firstleg:container:create' AND id <> 5102092)
OR (permission_code = 'platform:firstleg:container:edit' AND id <> 5102093)
OR (permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200)
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list',
'国内送柜报关','/container-loading/domestic','GET',40,'ACTIVE',1,0),
(5102092,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:create',
'建立国内货柜',NULL,'POST',20,'ACTIVE',1,0),
(5102093,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:edit',
'维护国内货柜资料',NULL,'PUT',30,'ACTIVE',1,0),
(5102200,0,'PLATFORM','MENU',5102000,'platform:firstleg:terminal-drayage:list',
'国外清关提柜','/container-loading/overseas','GET',41,'ACTIVE',1,0),
(5102201,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:document:edit',
'维护目的港清关与DO/PU',NULL,'POST',10,'ACTIVE',1,0),
(5102202,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:fleet:edit',
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE',
built_in=1, deleted=0;
-- list 已包含详情只读;旧 container:view 不再作为别名或双权限入口。
DELETE grant_row FROM iam_role_permission grant_row
JOIN iam_permission permission
ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
WHERE permission.tenant_id=0
AND permission.permission_code='platform:firstleg:container:view';
UPDATE iam_permission
SET status='INACTIVE', deleted=1
WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view';
-- 平台超级管理员和现有订单运营保持货柜全链路能力;其他角色按两个菜单独立授权。
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit',
'platform:firstleg:terminal-drayage:list',
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND role.status='ACTIVE' AND role.deleted=0
AND permission.status='ACTIVE' AND permission.deleted=0;
IF (SELECT COUNT(*) FROM iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0
AND permission_code IN (
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit',
'platform:firstleg:terminal-drayage:list',
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
FROM iam_role_permission grant_row
JOIN iam_role role
ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission
ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND permission.permission_code IN (
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit',
'platform:firstleg:terminal-drayage:list',
'platform:firstleg:terminal-drayage:document:edit',
'platform:firstleg:terminal-drayage:fleet:edit',
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
'platform:firstleg:terminal-drayage:pickup:confirm',
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
END IF;
IF EXISTS (SELECT 1 FROM iam_permission
WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view'
AND (status='ACTIVE' OR deleted=0)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:旧查看权限仍然有效';
END IF;
END$$
DELIMITER ;
CALL migrate_container_workspace_permissions();
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
-- 货柜双工作台权限结束,恢复头程数据库上下文
USE baiyue_firstleg;
CALL baiyue_firstleg.assert_unified_final_schema(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
+205 -3
View File
@@ -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' 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')); ), '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; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS ( SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' 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 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')); '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; 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 ( CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
@@ -1150,7 +1153,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态',
sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id), UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id),
KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code) KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code)
@@ -1187,6 +1190,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间', planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', signed_at DATETIME(3) NULL COMMENT '签收时间', remark VARCHAR(1000) NULL COMMENT '派送备注', expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', signed_at DATETIME(3) NULL COMMENT '签收时间', remark VARCHAR(1000) NULL COMMENT '派送备注',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID',
version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_delivery_no(customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency(customer_id,idempotency_key), UNIQUE KEY uk_ll_delivery_no(customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency(customer_id,idempotency_key),
@@ -1287,13 +1291,33 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号', delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号', sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号',
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称', handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果',
evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID',
handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID', source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID',
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code) KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单'; ) ENGINE=InnoDB COMMENT='海外仓异常订单';
CREATE TABLE IF NOT EXISTS ll_exception_return_item (
id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照',
sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
KEY idx_ll_exception_return_order(customer_id,exception_order_id)
) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE IF NOT EXISTS ll_exception_order_command (
id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作',
idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希',
result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key),
KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at)
) ENGINE=InnoDB COMMENT='异常处理命令幂等审计';
CREATE TABLE IF NOT EXISTS ll_customer_visit ( CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', id BIGINT NOT NULL COMMENT '客户回访记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
@@ -1754,6 +1778,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_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_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_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;
@@ -1786,3 +1815,176 @@ SET @ddl = (SELECT IF(
'SELECT 1', 'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)''')); 'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。
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;
+1
View File
@@ -16,6 +16,7 @@
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
@@ -21,6 +21,7 @@ enum BusinessFileCategory {
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
+1 -1
View File
@@ -6,7 +6,7 @@ USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object ( CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型', business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
@@ -0,0 +1,109 @@
openapi: 3.0.3
info:
title: 海外异常订单证据文件内部契约
version: 1.0.0
description: >-
File 只保存文件对象并校验复合归属;异常订单状态、完成门禁和证据引用由 Lastleg 持有。
所有接口仅接受内部服务令牌,业务失败按统一响应体 code 判断。
paths:
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:
post:
summary: 上传海外异常订单证据
description: >-
tenantId 固定为异常订单所属客户ID,exceptionOrderId 固定为异常订单内部ID。
单文件最大10MiB,仅支持TXT、PDF、JPG、PNG;File不校验异常状态。
security: []
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionOrderId'
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: {type: string, format: binary}
responses:
'200':
description: 返回文件元数据
content:
application/json:
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}:
get:
summary: 校验并读取海外异常订单证据元数据
description: 必须同时匹配客户ID、异常订单ID、文件类型与文件ID,否则返回不存在或归属不匹配。
security: []
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionOrderId'
- $ref: '#/components/parameters/FileId'
responses:
'200':
description: 返回匹配复合归属的文件元数据
content:
application/json:
schema: {$ref: '#/components/schemas/MetadataEnvelope'}
'403': {description: 内部服务令牌无效}
/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}/content:
get:
summary: 下载海外异常订单证据内容
description: 下载前执行与元数据接口相同的复合归属校验,响应禁止缓存。
security: []
parameters:
- $ref: '#/components/parameters/InternalToken'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/ExceptionOrderId'
- $ref: '#/components/parameters/FileId'
responses:
'200': {description: 文件二进制;携带Content-Disposition并设置Cache-Control no-store}
'403': {description: 内部服务令牌无效}
components:
parameters:
InternalToken:
name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
TenantId:
name: tenantId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
ExceptionOrderId:
name: exceptionOrderId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
FileId:
name: fileId
in: path
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
MetadataEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/Metadata'}
Metadata:
type: object
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
properties:
fileId: {type: string, pattern: '^[1-9][0-9]*$'}
ownerScope: {type: string, enum: [CUSTOMER]}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}
businessId: {type: string, pattern: '^[1-9][0-9]*$', description: 异常订单内部ID}
fileName: {type: string}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string}
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
@@ -315,4 +315,45 @@ class InternalBusinessFileServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("明确客户"); .isInstanceOf(BizException.class).hasMessageContaining("明确客户");
} }
@Test void overseasExceptionEvidenceUsesExactCustomerAndExceptionOwnership() {
MockMultipartFile file = new MockMultipartFile(
"file", "异常处理证据.pdf", "application/pdf", "%PDF-1.4".getBytes());
when(storage.store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/701/evidence.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "异常处理证据.pdf"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(801L);
return 1;
});
var result = service.upload(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file);
assertThat(result).extracting(InternalBusinessFileService.Metadata::ownerScope,
InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly("CUSTOMER", 10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701");
verify(storage).store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE);
assertThatThrownBy(() -> service.upload(0L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
}
@Test void overseasExceptionEvidenceMetadataFailsClosedForEveryOwnershipMismatch() {
FileObjectEntity value = new FileObjectEntity();
value.setId(801L); value.setTenantId(10001L);
value.setBusinessType("OVERSEAS_EXCEPTION_EVIDENCE"); value.setBusinessId("701");
value.setOriginalName("evidence.pdf"); value.setObjectKey("business-files/10001/701/evidence.pdf");
value.setContentType("application/pdf"); value.setSha256("b".repeat(64)); value.setSizeBytes(8L);
when(files.selectOne(any())).thenReturn(value, null, null, null);
assertThat(service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L).fileId())
.isEqualTo(801L);
assertThatThrownBy(() -> service.metadata(10002L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "702", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "INBOUND_HOLD_EVIDENCE", "701", 801L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
} }
@@ -0,0 +1,37 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasExceptionEvidenceContractTest {
@Test
void categoryAndOpenApiFreezeCustomerExceptionOwnership() throws Exception {
BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_EXCEPTION_EVIDENCE");
assertThat(category.maxSizeBytes()).isEqualTo(10L * 1024 * 1024);
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD);
try (var stream = getClass().getResourceAsStream(
"/openapi/overseas-exception-evidence-v1.yaml")) {
assertThat(stream).isNotNull();
String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(yaml).contains(
"/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:",
"ownerScope: {type: string, enum: [CUSTOMER]}",
"businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}",
"maximum: 10485760");
}
}
@Test
void initializationSqlHasEnoughWidthAndDocumentsTheCategory() throws Exception {
try (var stream = getClass().getResourceAsStream("/db/file.sql")) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE");
assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32);
}
}
}
+1
View File
@@ -43,6 +43,7 @@
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `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` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `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` | | 海外仓业务国内出仓后船节点定时查询 | `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` | | 船节点跨区交接事件 | `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 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 完成不推进拖车状态 | | 订舱级拖车与整柜出仓调度 | `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)(?:\.|$)/], ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/],
['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], ['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)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['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)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
@@ -63,12 +63,20 @@ public interface OrderCargoClient {
} }
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, String skuNo, Integer packageCount, Integer unitsPerCarton,
BigDecimal heightCm) { BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
String shipmentId) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) { BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); this(cargoId, orderId, itemNo, cargoName, null,
packageCount, null, lengthCm, widthCm, heightCm, null);
}
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, String shipmentId) {
this(cargoId, orderId, itemNo, cargoName, skuNo,
packageCount, null, lengthCm, widthCm, heightCm, shipmentId);
} }
} }
} }
@@ -0,0 +1,18 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "order-service", contextId = "printableOrderInboundClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
public interface PrintableOrderInboundClient {
@PostMapping("/{tenantId}/{orderId}/printable-inbound-snapshot")
ApiResponse<OrderApprovedInboundCommand> reserve(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId);
}
@@ -6,10 +6,12 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService; import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.terminaldrayage.service.ContainerWorkbenchService;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService; import com.baiyue.firstleg.service.LoadingListService;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
@@ -39,21 +41,50 @@ import org.springframework.web.multipart.MultipartFile;
@RequestMapping("/api/platform/v1/containers") @RequestMapping("/api/platform/v1/containers")
public class PlatformContainerController { public class PlatformContainerController {
@Resource private ContainerService service; @Resource private ContainerService service;
@Resource private ContainerWorkbenchService workbenches;
@Resource private LoadingListService loadingLists; @Resource private LoadingListService loadingLists;
@Resource private ContainerOverviewService overview; @Resource private ContainerOverviewService overview;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @GetMapping("/domestic") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<WorkbenchItem>> domesticPage(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) { @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticPage(query, user.permissions()));
} }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @GetMapping("/overseas") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<WorkbenchItem>> overseasPage(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) { @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id)); FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasPage(query, user.permissions()));
}
@GetMapping("/domestic/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<WorkbenchItem> domesticDetail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticDetail(id, user.permissions()));
}
@GetMapping("/overseas/{id}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<WorkbenchItem> overseasDetail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasDetail(id, user.permissions()));
} }
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) { public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id)); FirstlegScopeGuard.platform(user); workbenches.domesticDetail(id, user.permissions());
return ApiResponse.success(overview.overview(id));
}
@GetMapping("/overseas/{id}/overview")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<Overview> overseasOverview(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); service.requireOverseas(id);
return ApiResponse.success(overview.overview(id));
}
@PutMapping("/{id}/pickup-code")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@BusinessAudit(event="CONTAINER_PICKUP_CODE_UPDATE", resourceType="CONTAINER", resourceId="#id", description="填写PU码")
public ApiResponse<Overview> updatePickupCode(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody PickupCodeRequest request) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(overview.updatePickupCode(id, user.userId(), user.username(), request));
} }
@GetMapping("/{id}/pda/photos/{fileId}/download") @GetMapping("/{id}/pda/photos/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
@@ -61,6 +92,7 @@ public class PlatformContainerController {
@PathVariable @Positive long id, @PathVariable @Positive long id,
@PathVariable @Positive long fileId) { @PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireDomestic(id);
var value = overview.downloadPhoto(id, fileId); var value = overview.downloadPhoto(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline() .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
@@ -71,20 +103,20 @@ public class PlatformContainerController {
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) { @Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null); FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
} }
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) { @Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null); FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
} }
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));} public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());} public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);service.requireOverseas(id);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false) @BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -104,21 +136,23 @@ public class PlatformContainerController {
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
} }
@GetMapping("/{id}/clearance") @GetMapping("/{id}/clearance")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
@Operation(summary = "查询货柜目的港清关资料") @Operation(summary = "查询货柜目的港清关资料")
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) { @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.containerClearance(id)); return ApiResponse.success(loadingLists.containerClearance(id));
} }
@GetMapping("/{id}/clearance/document/{fileId}/download") @GetMapping("/{id}/clearance/document/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
public ResponseEntity<byte[]> downloadClearance( public ResponseEntity<byte[]> downloadClearance(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@PathVariable @Positive long fileId) { @PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
var download = loadingLists.downloadContainerClearance(id, fileId); var download = loadingLists.downloadContainerClearance(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
@@ -128,19 +162,20 @@ public class PlatformContainerController {
} }
@PostMapping("/{id}/clearance/document") @PostMapping("/{id}/clearance/document")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@Operation(summary = "上传或替换货柜目的港清关资料") @Operation(summary = "上传或替换货柜目的港清关资料")
public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id, @PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentAttachRequest request) { @Valid @RequestBody DocumentAttachRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.attachContainerClearance( return ApiResponse.success(loadingLists.attachContainerClearance(
id, user.userId(), user.username(), key, request)); id, user.userId(), user.username(), key, request));
} }
@PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@Operation(summary = "直接上传或替换货柜目的港清关资料") @Operation(summary = "直接上传或替换货柜目的港清关资料")
public ApiResponse<BusinessDocument> uploadClearance( public ApiResponse<BusinessDocument> uploadClearance(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@@ -149,18 +184,20 @@ public class PlatformContainerController {
@RequestParam("expectedVersion") @Min(0) int expectedVersion, @RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.uploadContainerClearance( return ApiResponse.success(loadingLists.uploadContainerClearance(
id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
} }
@PostMapping("/{id}/clearance/complete") @PostMapping("/{id}/clearance/complete")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@Operation(summary = "确认货柜目的港清关完成") @Operation(summary = "确认货柜目的港清关完成")
public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id, @PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentCompleteRequest request) { @Valid @RequestBody DocumentCompleteRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.completeContainerClearance( return ApiResponse.success(loadingLists.completeContainerClearance(
id, user.userId(), user.username(), key, request)); id, user.userId(), user.username(), key, request));
} }
@@ -62,23 +62,44 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username())); return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/open") @GetMapping("/tasks/{taskId}/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Pallet> open(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<DropshipPalletizationDtos.Carton> resolveCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) { @RequestParam @Positive long warehouseId,
@RequestParam @Size(max = 80) String boxCode) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.open(taskId, request, user.userId(), user.username())); return ApiResponse.success(service.resolveCarton(taskId, warehouseId, boxCode, user.userId()));
} }
@PostMapping("/tasks/{taskId}/pallets/{palletId}/scan") @PostMapping("/tasks/{taskId}/cartons/count")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<DropshipPalletizationDtos.ScanResult> scan( public ApiResponse<DropshipPalletizationDtos.CountResult> countCarton(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId, @PathVariable @Positive long palletId, @PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) { @Valid @RequestBody DropshipPalletizationDtos.CountCartonRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.scan(taskId, palletId, request, user.userId(), user.username())); return ApiResponse.success(service.countCarton(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/bind-pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> bindPending(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.BindPendingRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long cartonId,
@Valid @RequestBody DropshipPalletizationDtos.MoveCartonRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.moveCarton(taskId, cartonId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal")
@@ -49,7 +49,8 @@ public class PlatformInboundMeasurementController {
@Resource private MeasurementSubmissionService submission; @Resource private MeasurementSubmissionService submission;
@Resource private CartonMeasurementService cartonMeasurements; @Resource private CartonMeasurementService cartonMeasurements;
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。") @Operation(summary = "查询入库测量详情", description = "返回唯一入仓码、原单、实测箱规、审核快照和 OCR 重量结果;"
+ "箱规行按 inboundDetailId 返回 Order 权威 FBA 货件编号,并保留独立设备实扫值。")
@GetMapping("/measurement") @GetMapping("/measurement")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')") @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
public ApiResponse<InboundMeasurementResponses.Detail> detail( public ApiResponse<InboundMeasurementResponses.Detail> detail(
@@ -78,7 +79,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿") resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") @Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
+ "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。")
@PutMapping("/measurement/draft") @PutMapping("/measurement/draft")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
+ " 'platform:firstleg:inbound:measure:edit')") + " 'platform:firstleg:inbound:measure:edit')")
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
@@ -24,11 +25,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.PositiveOrZero;
import com.baiyue.firstleg.service.LoadingWorkPalletService;
import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@@ -56,8 +52,6 @@ public class PlatformLoadingPdaController {
private LoadingPickService service; private LoadingPickService service;
@Resource @Resource
private CabinetLoadingEvidenceService evidenceService; private CabinetLoadingEvidenceService evidenceService;
@Resource
private LoadingWorkPalletService palletService;
@GetMapping("/tasks") @GetMapping("/tasks")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
@@ -168,6 +162,15 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username())); return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username()));
} }
@PostMapping("/tasks/{taskId}/pallets/pick")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> pickPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody PalletPickRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pickPallet(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/items/{itemId}/partial") @PostMapping("/tasks/{taskId}/items/{itemId}/partial")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Task> partial(@AuthenticationPrincipal AuthenticatedUser user,
@@ -226,44 +229,4 @@ public class PlatformLoadingPdaController {
return ApiResponse.success(service.operations(taskId)); return ApiResponse.success(service.operations(taskId));
} }
@GetMapping("/tasks/{taskId}/pallets")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<Pallet>> pallets(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.list(taskId, warehouseId));
}
@PostMapping("/tasks/{taskId}/pallets/open")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> openPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody OpenRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.open(taskId, request, user.userId(), user.username()));
}
/** 扫到其他未封托托盘上的箱唛时,同时完成按箱移托。 */
@PostMapping("/tasks/{taskId}/pallets/{palletId}/cartons/scan")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody ScanRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.scan(taskId, palletId, request,
user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/{palletId}/seal")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Pallet> sealPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@Valid @RequestBody SealRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(palletService.seal(taskId, palletId, request,
user.userId(), user.username()));
}
} }
@@ -38,9 +38,10 @@ public class PlatformPriceBookController {
@Operation(summary = "查询船期价格表版本") @Operation(summary = "查询船期价格表版本")
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list( public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(defaultValue = "0") long customerId,
@RequestParam(required = false) PriceBookStatus status) { @RequestParam(required = false) PriceBookStatus status) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.list(status)); return ApiResponse.success(service.list(customerId, status));
} }
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -48,9 +49,10 @@ public class PlatformPriceBookController {
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认") @BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload( public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(defaultValue = "0") long customerId,
@RequestPart("file") MultipartFile file) { @RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.upload(user.userId(), file)); return ApiResponse.success(service.upload(user.userId(), customerId, file));
} }
@GetMapping("/{versionId}") @GetMapping("/{versionId}")
@@ -24,6 +24,10 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/pallet-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignPallet(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPalletRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPallet(planId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/pallet-assignments/{palletId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> removePallet(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long palletId,@Valid @RequestBody RemovePalletRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removePallet(planId,palletId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));} public ApiResponse<Plan> assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") @PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@@ -19,6 +19,8 @@ import java.time.LocalDateTime;
public final class ContainerDtos { public final class ContainerDtos {
private ContainerDtos() {} private ContainerDtos() {}
public enum WorkbenchScope { ACTIVE, HISTORY }
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件") @Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
public record Query( public record Query(
@Size(max = 64) String containerNo, @Size(max = 64) String containerNo,
@@ -35,6 +37,7 @@ public final class ContainerDtos {
SmartLoadingStatus status, SmartLoadingStatus status,
LocalDateTime estimatedSailingFrom, LocalDateTime estimatedSailingFrom,
LocalDateTime estimatedSailingTo, LocalDateTime estimatedSailingTo,
@NotNull WorkbenchScope scope,
@Min(1) long page, @Min(1) long page,
@Min(1) @Max(200) long size @Min(1) @Max(200) long size
) { ) {
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -21,7 +22,8 @@ public final class ContainerOverviewDtos {
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt, LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt, LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
Long emptyReportSourceVersion, Long emptyReportSourceVersion, String pickupCode, Long pickupCodeUpdatedBy,
String pickupCodeUpdatedByName, LocalDateTime pickupCodeUpdatedAt,
Integer version) {} Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
@@ -35,4 +37,6 @@ public final class ContainerOverviewDtos {
public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {} public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {} public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {} public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {}
public record PickupCodeRequest(@NotBlank @Size(max=64) @Pattern(regexp="^[0-9]+$", message="PU码只能包含数字") String pickupCode,
@NotNull @Min(0) Integer expectedVersion) {}
} }
@@ -1,7 +1,8 @@
package com.baiyue.firstleg.dto; package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -14,13 +15,23 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {} public record ClaimRequest(@Positive long warehouseId) {}
public record OpenPalletRequest(@Positive long warehouseId, public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
@NotBlank @Size(max = 128) String operationId) {}
public record ScanRequest(@Positive long warehouseId, public record CountCartonRequest(@Positive long warehouseId,
@NotBlank @Size(max = 64) String boxCode, @NotBlank @Size(max = 80) String boxCode,
@Positive @Max(2000) int cartonCount, @Positive int actualSkuQuantity,
@NotBlank @Size(max = 128) String operationId) {} @NotNull DiscrepancyAction discrepancyAction,
@Size(max = 500) String discrepancyRemark,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record BindPendingRequest(@Positive long warehouseId,
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record MoveCartonRequest(@Positive long warehouseId,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String targetPalletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record SealPalletRequest(@Positive long warehouseId, public record SealPalletRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId, @NotBlank @Size(max = 128) String operationId,
@@ -28,24 +39,34 @@ public final class DropshipPalletizationDtos {
public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId, public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId,
String customerName, Long warehouseId, String warehouseName, String customerName, Long warehouseId, String warehouseName,
Integer totalCartons, Integer allocatedCartons, String taskStatus, Integer totalCartons, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer discrepancyCartons, String taskStatus,
LocalDateTime createdAt) {} LocalDateTime createdAt) {}
public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId, public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId,
String customerName, Long warehouseId, String warehouseName, String customerName, Long warehouseId, String warehouseName,
Integer totalCartons, Integer allocatedCartons, String taskStatus, Integer totalCartons, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer discrepancyCartons, String taskStatus,
Long assigneeId, String assigneeName, Integer version, Long assigneeId, String assigneeName, Integer version,
List<Pallet> pallets) {} List<Pallet> pallets, List<Carton> unbound) {}
public record Pallet(Long palletId, String palletNo, String palletStatus, public record Pallet(Long palletId, String palletNo, String status,
Integer cartonCount, Integer skuCount, Integer version, Integer cartonCount, Integer skuCount, Integer discrepancyCartons,
List<SkuItem> items, LocalDateTime completedAt) {} java.math.BigDecimal weightKg, java.math.BigDecimal volumeCbm,
Integer version, List<SkuItem> items, List<Carton> cartons,
LocalDateTime sealedAt) {}
public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId, public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId,
String skuCode, String cargoName, Integer cartonCount, String skuCode, String cargoName, Integer cartonCount, Integer skuQuantity,
List<String> cartonMarks) {} List<String> cartonMarks) {}
public record ScanResult(String skuCode, String cargoName, String inboundCode, public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode,
int addedCartons, int allocatedCartons, int remainingCartons, Long inboundDetailId, String skuCode, String cargoName,
Pallet pallet) {} Integer expectedSkuQuantity, Integer actualSkuQuantity,
String discrepancyStatus, String discrepancyRemark,
String countStatus, Long palletId, String palletNo,
Boolean canCount, String blockReason) {}
public record CountResult(Carton carton, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer remainingCartons, Pallet pallet) {}
} }
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -80,7 +81,8 @@ public final class FbaPricingDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount, @NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason @NotBlank @Size(max = 500) String reason,
@NotNull @AssertTrue Boolean toleranceConfirmed
) {} ) {}
public record FeeSnapshotResponse( public record FeeSnapshotResponse(
@@ -96,7 +98,9 @@ public final class FbaPricingDtos {
@NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale, @NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale,
@NotBlank String roundingMode, @NotBlank String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit, BigDecimal convertedQuantity, BigDecimal baselineChargeableQuantity,
BigDecimal lowerChargeableQuantity, BigDecimal upperChargeableQuantity,
BigDecimal tolerancePercent, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount, BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
BigDecimal totalAmount, String currency, BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo, LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
@@ -75,6 +75,8 @@ public final class InboundMeasurementResponses {
public record Item( public record Item(
Long id, Long id,
@Schema(description = "当前测量详情唯一权威入仓码,与根对象 inboundCode 一致")
String inboundCode,
Long inboundDetailId, Long inboundDetailId,
String itemNo, String itemNo,
String boxSpecification, String boxSpecification,
@@ -99,6 +101,9 @@ public final class InboundMeasurementResponses {
String coefficientMode, String coefficientMode,
String remark, String remark,
String boxCode, String boxCode,
@Schema(description = "Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空,禁止回退设备实扫值")
String fbaShipmentNo,
@Schema(description = "设备从Amazon FBA箱标实扫的Shipment ID;仅作现场证据,不覆盖或回退权威货件编号")
String scannedShipmentId, String scannedShipmentId,
Integer confirmedPackageCount, Integer confirmedPackageCount,
String machineNo, String machineNo,
@@ -177,6 +177,8 @@ public final class LoadingListDtos {
Long itemId, Long itemId,
Long accountId, Long accountId,
String customerName, String customerName,
String customsTaxMode,
Boolean taxRefundFlag,
Long orderId, Long orderId,
String orderNo, String orderNo,
Long inboundId, Long inboundId,
@@ -188,6 +190,10 @@ public final class LoadingListDtos {
String chineseName, String chineseName,
String englishName, String englishName,
String goodsImageUrl, String goodsImageUrl,
String unitType,
Long palletId,
String palletNo,
Boolean indivisible,
int packageCount, int packageCount,
int palletCount, int palletCount,
BigDecimal grossWeightKg, BigDecimal grossWeightKg,
@@ -1,41 +0,0 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** 一件代发PDA现场打托接口模型。 */
public final class LoadingPalletDtos {
private LoadingPalletDtos() {}
public record OpenRequest(
@NotNull @Min(1) Long warehouseId,
@Min(1) Long warehousePalletId,
@NotBlank @Size(max = 64) String palletCode,
@NotBlank @Size(max = 128) String operationId
) {}
public record ScanRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Size(max = 80) String boxCode,
@NotBlank @Size(max = 128) String operationId
) {}
public record SealRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String operationId
) {}
public record Carton(Long id, String boxCode, String skuCode, Long tenantId,
Long inboundId, Long inboundDetailId, Integer unitsPerCarton) {}
public record SkuSummary(String skuCode, Integer cartonCount, Long productQuantity) {}
public record Pallet(Long id, String palletCode, Long warehousePalletId, String status,
Integer version, Integer cartonCount, Integer skuCount,
Integer customerCount, List<SkuSummary> skus, List<Carton> cartons) {}
}
@@ -23,6 +23,13 @@ public final class LoadingPickDtos {
@NotBlank @Size(max = 128) String operationId @NotBlank @Size(max = 128) String operationId
) {} ) {}
public record PalletPickRequest(
@NotNull @Min(1) Long warehouseId,
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotNull @Min(0) Integer expectedTaskVersion,
@NotBlank @Size(max = 128) String operationId
) {}
/** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */ /** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */
public record PartialPickRequest( public record PartialPickRequest(
@NotNull @Min(1) Long warehouseId, @NotNull @Min(1) Long warehouseId,
@@ -23,6 +23,7 @@ public final class PriceBookDtos {
public record PriceBookVersionSummary( public record PriceBookVersionSummary(
Long id, Long id,
Long customerId,
Integer versionNo, Integer versionNo,
String originalFileName, String originalFileName,
Long sourceFileId, Long sourceFileId,
@@ -119,9 +120,20 @@ public final class PriceBookDtos {
@Size(max = 128) String destinationName, @Size(max = 128) String destinationName,
PriceRegion destinationRegion, PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode, PricingTaxMode pricingTaxMode,
@Pattern(regexp = "KG|CBM") String chargeMode,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg, @DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm @DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
) {} ) {
public EstimateRequest(Long originWarehouseId, String channelCode, String destinationCode,
PricingDestinationType destinationType, Long destinationWarehouseId,
String destinationName, PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode, BigDecimal actualWeightKg,
BigDecimal actualVolumeCbm) {
this(originWarehouseId, channelCode, destinationCode, destinationType,
destinationWarehouseId, destinationName, destinationRegion, pricingTaxMode,
null, actualWeightKg, actualVolumeCbm);
}
}
public record EstimateResponse( public record EstimateResponse(
Long priceBookVersionId, Long priceBookVersionId,
@@ -22,11 +22,14 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo,
String inboundCode, String customerName, Integer availablePackageCount, String inboundCode, String customerName, Integer availablePackageCount,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record PalletCandidate(Long palletId,String palletNo,String unitType,Boolean indivisible,
Integer cartonCount,Integer skuCount,BigDecimal weightKg,BigDecimal volumeCbm) {}
public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
String unitType,Long palletId,String palletNo,Boolean indivisible,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, Boolean canAssign, String blockReason, Boolean locked, Boolean canAssign, String blockReason,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt, String orderStatusSnapshot, LocalDateTime expectedArrivalAt,
@@ -66,6 +69,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record AssignGroupRequest(@NotNull @Positive Long inboundId, public record AssignGroupRequest(@NotNull @Positive Long inboundId,
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record AssignPalletRequest(@NotNull @Positive Long palletId,@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record RemovePalletRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {}
public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion, public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {} @NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@@ -10,12 +10,12 @@ import lombok.Setter;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** 船司 FBA 目的仓价格的不可变版本。 */ /** 公共或客户专属的头程报价不可变版本。 */
@Getter @Getter
@Setter @Setter
@TableName("fl_carrier_fba_rate") @TableName("fl_carrier_fba_rate")
public class CarrierFbaRateEntity extends BaseEntity { public class CarrierFbaRateEntity extends BaseEntity {
/** 船司价格是平台公共主数据,不绑定任何客户。 */ /** 0 为平台公共报价,正数为客户专属报价作用域。 */
public static final long PLATFORM_SCOPE = 0L; public static final long PLATFORM_SCOPE = 0L;
@TableId(type = IdType.ASSIGN_ID) @TableId(type = IdType.ASSIGN_ID)
@@ -60,6 +60,10 @@ public class ContainerBookingEntity {
private LocalDateTime estimatedEmptyReportedAt; private LocalDateTime estimatedEmptyReportedAt;
private LocalDateTime actualEmptyReportedAt; private LocalDateTime actualEmptyReportedAt;
private Long emptyReportSourceVersion; private Long emptyReportSourceVersion;
private String pickupCode;
private Long pickupCodeUpdatedBy;
private String pickupCodeUpdatedByName;
private LocalDateTime pickupCodeUpdatedAt;
private Long createdBy; private Long createdBy;
private String createdByName; private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
@@ -76,5 +80,6 @@ public class ContainerBookingEntity {
@TableField(exist = false) private String destinationClearanceDocumentStatus; @TableField(exist = false) private String destinationClearanceDocumentStatus;
@TableField(exist = false) private String customsDeclarationDocumentStatus; @TableField(exist = false) private String customsDeclarationDocumentStatus;
@TableField(exist = false) private String deliveryOrderDocumentStatus; @TableField(exist = false) private String deliveryOrderDocumentStatus;
@TableField(exist = false) private String pickupOrderDocumentStatus; @TableField(exist = false) private String outboundDispatchStatus;
@TableField(exist = false) private LocalDateTime outboundConfirmedAt;
} }
@@ -23,6 +23,7 @@ public class InboundCartonLabelEntity {
private Integer cargoItemNo; private Integer cargoItemNo;
private String cargoName; private String cargoName;
private String skuCode; private String skuCode;
private Integer unitsPerCarton;
private Integer detailPackageCount; private Integer detailPackageCount;
private String inboundCode; private String inboundCode;
private String boxCode; private String boxCode;
@@ -54,6 +54,10 @@ public class InboundFeeEntity {
private BigDecimal actualWeightKg; private BigDecimal actualWeightKg;
private BigDecimal actualVolumeCbm; private BigDecimal actualVolumeCbm;
private BigDecimal convertedQuantity; private BigDecimal convertedQuantity;
/** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */
private BigDecimal baselineChargeableQuantity;
/** 人工计费量相对系统基准允许的最大偏差百分比。 */
private BigDecimal tolerancePercent;
private BigDecimal chargeableQuantity; private BigDecimal chargeableQuantity;
private String chargeUnit; private String chargeUnit;
private String currency; private String currency;
@@ -6,6 +6,7 @@ public class LoadingPlanItemEntity {
private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId; private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId;
private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn; private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn;
private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton; private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton;
private String unitType; private Long palletId; private String palletNo; private Boolean indivisible;
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy; private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
@@ -1,37 +0,0 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 一件代发箱唛与现场实际托盘的当前绑定事实。 */
@Getter
@Setter
@TableName("fl_loading_work_pallet_carton")
public class LoadingWorkPalletCartonEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long workPalletId;
private Long loadingPlanItemId;
private Long pickItemId;
private Long tenantId;
private Long inboundId;
private Long inboundDetailId;
private Long cartonLabelId;
private String boxCode;
private String skuCode;
private Integer unitsPerCarton;
private Long boundBy;
private String boundByName;
private String operationId;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -1,36 +0,0 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 一件代发装柜现场的实际托盘。 */
@Getter
@Setter
@TableName("fl_loading_work_pallet")
public class LoadingWorkPalletEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long pickTaskId;
private Long loadingPlanId;
private Long warehouseId;
private Long warehousePalletId;
private String palletCode;
private String openOperationId;
private String sealOperationId;
private String palletStatus;
private Long createdBy;
private String createdByName;
private Long sealedBy;
private String sealedByName;
private LocalDateTime sealedAt;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -40,8 +40,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status, COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status, AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status, AND MAX(d.reversed_at) IS NULL THEN 'DEPARTED'
ELSE MIN(d.status) END
FROM fl_outbound_dispatch d WHERE d.container_booking_id=b.id),'NOT_CREATED')
AS outbound_dispatch_status,
(SELECT MIN(d.confirmed_at) FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') AS outbound_confirmed_at,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING' CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS ( WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item SELECT 1 FROM fl_loading_plan_item active_item
@@ -78,6 +84,24 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
ELSE p.plan_status END = #{query.status}) ELSE p.plan_status END = #{query.status})
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at &gt;= #{query.estimatedSailingFrom}) AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at &gt;= #{query.estimatedSailingFrom})
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo}) AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo})
AND ((#{workbench}='DOMESTIC' AND (
(#{query.scope}='ACTIVE' AND NOT EXISTS (
SELECT 1 FROM fl_outbound_dispatch departure
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING'))
OR (#{query.scope}='HISTORY' AND EXISTS (
SELECT 1 FROM fl_outbound_dispatch departure
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING'))))
OR (#{workbench}='OVERSEAS' AND EXISTS (
SELECT 1 FROM fl_outbound_dispatch departure
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING')
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL))))
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -86,7 +110,9 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
ORDER BY b.updated_at DESC, b.id DESC ORDER BY b.updated_at DESC, b.id DESC
</script> </script>
""") """)
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query); Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page,
@Param("query") Query query,
@Param("workbench") String workbench);
@Select(""" @Select("""
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no, SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
@@ -100,8 +126,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status, COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status, AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status, AND MAX(d.reversed_at) IS NULL THEN 'DEPARTED'
ELSE MIN(d.status) END
FROM fl_outbound_dispatch d WHERE d.container_booking_id=b.id),'NOT_CREATED')
AS outbound_dispatch_status,
(SELECT MIN(d.confirmed_at) FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') AS outbound_confirmed_at,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING' CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS ( WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item SELECT 1 FROM fl_loading_plan_item active_item
@@ -119,6 +151,23 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE") @Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id); ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("""
SELECT COUNT(1) FROM fl_outbound_dispatch
WHERE container_booking_id=#{id} AND confirmed_at IS NOT NULL AND reversed_at IS NULL
AND status='WAITING_FOR_LOADING'
""")
int hasActiveOutboundDeparture(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId},
pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3),
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND record_status='ACTIVE' AND version=#{version}
""")
int updatePickupCode(@Param("id") long id, @Param("pickupCode") String pickupCode,
@Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'") @Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo); ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
@@ -3,7 +3,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import org.apache.ibatis.annotations.*; import org.apache.ibatis.annotations.*;
import java.util.List; import java.util.List;
@Mapper public interface ContainerOverviewMapper { @Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id); @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select(""" @Select("""
SELECT history.id,plan.container_booking_id containerBookingId, SELECT history.id,plan.container_booking_id containerBookingId,
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo, JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.List;
/** 同柜同客户区域重量阶梯计费查询。 */
@Mapper
public interface ContainerTierPricingMapper {
@Select("""
<script>
SELECT plan.container_booking_id AS containerBookingId,
grouped_inbound.id AS inboundId,
grouped_inbound.actual_net_weight_kg AS actualWeightKg,
grouped_inbound.actual_volume_cbm AS actualVolumeCbm
FROM fl_loading_plan_item current_item
JOIN fl_loading_plan current_plan ON current_plan.id=current_item.loading_plan_id
JOIN fl_loading_plan plan ON plan.container_booking_id=current_plan.container_booking_id
JOIN fl_loading_plan_item grouped_item ON grouped_item.loading_plan_id=plan.id
AND grouped_item.deleted=0
AND grouped_item.account_id=#{customerId}
JOIN fl_inbound_order current_inbound ON current_inbound.id=#{inboundId}
AND current_inbound.tenant_id=#{customerId}
AND current_inbound.deleted=0
JOIN fl_inbound_order grouped_inbound ON grouped_inbound.id=grouped_item.inbound_id
AND grouped_inbound.tenant_id=#{customerId}
AND grouped_inbound.deleted=0
WHERE current_item.inbound_id=#{inboundId}
AND current_item.account_id=#{customerId}
AND current_item.deleted=0
AND plan.plan_status IN ('EDITING','LOADING','COMPLETED')
GROUP BY plan.container_booking_id,grouped_inbound.id,
grouped_inbound.actual_net_weight_kg,grouped_inbound.actual_volume_cbm
ORDER BY plan.container_booking_id,grouped_inbound.id
</script>
""")
List<MemberRow> members(@Param("customerId") long customerId,
@Param("inboundId") long inboundId);
/** 锁住当前柜次内某客户全部有效明细,供阶梯费用组重算使用。 */
@Select("""
SELECT plan.container_booking_id AS containerBookingId,
inbound.id AS inboundId,
inbound.actual_net_weight_kg AS actualWeightKg,
inbound.actual_volume_cbm AS actualVolumeCbm
FROM fl_loading_plan plan
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id
AND item.deleted=0
AND item.account_id=#{customerId}
JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id
AND inbound.tenant_id=#{customerId}
AND inbound.deleted=0
WHERE plan.container_booking_id=#{containerBookingId}
AND plan.plan_status IN ('EDITING','LOADING','COMPLETED')
ORDER BY item.inbound_id,item.id
FOR UPDATE
""")
List<MemberRow> membersForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("customerId") long customerId);
record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg,
BigDecimal actualVolumeCbm) {}
}
@@ -1,203 +1,182 @@
package com.baiyue.firstleg.mapper; package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask;
import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.*;
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.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
/** 一件代发合托持久化;任务、托盘和逐箱绑定均独立于装柜拣货。 */ /** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper @Mapper
public interface DropshipPalletizationMapper { public interface DropshipPalletizationMapper {
@Insert(""" @Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,allocated_cartons,version,created_at,updated_at) (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,0,0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""")
""") int insertTask(@Param("id") long id,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,
int insertTask(@Param("id") long id, @Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,@Param("totalCartons") int totalCartons);
@Param("inboundId") long inboundId, @Param("warehouseId") long warehouseId,
@Param("totalCartons") int totalCartons);
@Select(""" @Select("""
SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id, SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id,
o.warehouse_name,t.total_cartons,t.allocated_cartons,t.task_status,t.created_at o.warehouse_name,t.total_cartons,t.counted_cartons,t.allocated_cartons bound_cartons,
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at
WHERE t.task_status IN ('PENDING','CLAIMED') FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) WHERE t.task_status IN ('PENDING','CLAIMED') AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId})
AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))
OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) ORDER BY t.created_at,t.id""")
ORDER BY t.created_at,t.id List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword);
""")
List<PendingTask> pending(@Param("operatorId") long operatorId, @Param("keyword") String keyword);
@Select(""" @Select("""
SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId} WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""")
""") TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
TaskRow task(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId);
@Update(""" @Update("""
UPDATE fl_dropship_pallet_task UPDATE fl_dropship_pallet_task SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName},
SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName}, claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED')
WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') AND (assignee_id IS NULL OR assignee_id=#{operatorId})""")
AND (assignee_id IS NULL OR assignee_id=#{operatorId}) int claim(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId,
""") @Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
int claim(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Insert(""" @Insert("""
INSERT INTO fl_dropship_pallet INSERT INTO fl_dropship_pallet
(id,task_id,tenant_id,inbound_id,warehouse_id,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at) (id,task_id,tenant_id,inbound_id,warehouse_id,pallet_no,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at)
VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},'OPEN',#{operationId},1, VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},#{palletNo},'OPEN',#{operationId},1,
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""")
""") int insertPallet(@Param("id") long id,@Param("taskId") long taskId,@Param("tenantId") long tenantId,
int insertPallet(@Param("id") long id, @Param("taskId") long taskId, @Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo,
@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Param("warehouseId") long warehouseId, @Param("operationId") String operationId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}") @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo);
PalletRow palletByOpenOperation(@Param("operationId") String operationId); @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}")
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List<PalletRow> pallets(@Param("taskId") long taskId);
@Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id")
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") @Select("""
PalletRow palletBySealOperation(@Param("operationId") String operationId); SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.units_per_carton,l.inbound_code,l.box_code
FROM fl_inbound_carton_label l JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'""")
LabelRow label(@Param("taskId") long taskId,@Param("boxCode") String boxCode);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.count_operation_id=#{operationId}") CartonRow cartonByCountOperation(@Param("operationId") String operationId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.id=#{cartonId}") CartonRow carton(@Param("taskId") long taskId,@Param("cartonId") long cartonId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.carton_label_id=#{labelId}")
CartonRow countedByLabel(@Param("taskId") long taskId,@Param("labelId") long labelId);
@Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") @Insert("""
PalletRow pallet(@Param("taskId") long taskId, @Param("palletId") long palletId, INSERT INTO fl_dropship_pallet_carton
@Param("warehouseId") long warehouseId); (id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name,box_code,
expected_sku_quantity,actual_sku_quantity,discrepancy_status,discrepancy_remark,count_operation_id,last_operation_id,
counted_by,counted_by_name,counted_at,bound_by,bound_by_name,bound_at)
VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName},#{boxCode},
#{expectedQuantity},#{actualQuantity},#{discrepancyStatus},#{discrepancyRemark},#{operationId},#{operationId},
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CASE WHEN #{palletId} IS NULL THEN NULL ELSE #{operatorId} END,
CASE WHEN #{palletId} IS NULL THEN NULL ELSE #{operatorName} END,CASE WHEN #{palletId} IS NULL THEN NULL ELSE CURRENT_TIMESTAMP(3) END)""")
int insertCarton(@Param("id") long id,@Param("taskId") long taskId,@Param("palletId") Long palletId,
@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("detailId") long detailId,
@Param("labelId") long labelId,@Param("skuCode") String skuCode,@Param("cargoName") String cargoName,
@Param("boxCode") String boxCode,@Param("expectedQuantity") int expectedQuantity,@Param("actualQuantity") int actualQuantity,
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") @Update("""
List<PalletRow> pallets(@Param("taskId") long taskId); UPDATE fl_dropship_pallet_task SET counted_cartons=counted_cartons+1,allocated_cartons=allocated_cartons+#{boundCount},
discrepancy_cartons=discrepancy_cartons+#{discrepancyCount},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId} AND counted_cartons<total_cartons
AND allocated_cartons+#{boundCount}<=total_cartons""")
int addCounted(@Param("taskId") long taskId,@Param("operatorId") long operatorId,
@Param("boundCount") int boundCount,@Param("discrepancyCount") int discrepancyCount);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE task_id=#{taskId} AND pallet_id IS NULL") int unboundCount(@Param("taskId") long taskId);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE task_id=#{taskId} AND bind_operation_id=#{operationId}")
int bindOperationCount(@Param("taskId") long taskId,@Param("operationId") String operationId);
@Update("""
UPDATE fl_dropship_pallet_carton SET pallet_id=#{palletId},bind_operation_id=#{operationId},last_operation_id=#{operationId},
bound_by=#{operatorId},bound_by_name=#{operatorName},bound_at=CURRENT_TIMESTAMP(3) WHERE task_id=#{taskId} AND pallet_id IS NULL""")
int bindPending(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("operationId") String operationId,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("UPDATE fl_dropship_pallet_task SET allocated_cartons=allocated_cartons+#{delta},version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND assignee_id=#{operatorId} AND allocated_cartons+#{delta} BETWEEN 0 AND counted_cartons")
int adjustBound(@Param("taskId") long taskId,@Param("operatorId") long operatorId,@Param("delta") int delta);
@Update("""
UPDATE fl_dropship_pallet_carton SET pallet_id=#{targetPalletId},last_operation_id=#{operationId},
bound_by=CASE WHEN #{targetPalletId} IS NULL THEN NULL ELSE #{operatorId} END,
bound_by_name=CASE WHEN #{targetPalletId} IS NULL THEN NULL ELSE #{operatorName} END,
bound_at=CASE WHEN #{targetPalletId} IS NULL THEN NULL ELSE CURRENT_TIMESTAMP(3) END
WHERE id=#{cartonId} AND task_id=#{taskId} AND last_operation_id<>#{operationId}""")
int moveCarton(@Param("taskId") long taskId,@Param("cartonId") long cartonId,@Param("targetPalletId") Long targetPalletId,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select(""" @Update("""
SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId},
WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
""") WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion}
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""")
int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId);
@Update("""
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""")
int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List<CartonRow> cartons(@Param("palletId") long palletId);
String inboundCode(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId);
@Select("""
SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""")
PalletMetrics metrics(@Param("palletId") long palletId);
@Select("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count,
COUNT(DISTINCT c.sku_code) sku_count,
COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED'
AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0)
GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id
""") List<PalletCandidate> palletCandidates(@Param("inboundId") long inboundId);
@Select(""" @Select("""
SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no,
l.inbound_code,l.box_code o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
FROM fl_inbound_carton_label l o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE' SUM(c.actual_sku_quantity) actual_sku_quantity,
""") COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg,
LabelRow label(@Param("taskId") long taskId, @Param("boxCode") String boxCode); COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg,
COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code,
o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name,
o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
ORDER BY c.inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
@Select(""" @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; }
SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
l.inbound_code,l.box_code @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
FROM fl_inbound_carton_label l @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
AND l.inbound_detail_id=#{detailId} AND l.batch_status='ACTIVE' @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
AND NOT EXISTS (SELECT 1 FROM fl_dropship_pallet_carton c
WHERE c.task_id=#{taskId} AND c.carton_label_id=l.id)
ORDER BY CASE WHEN l.box_code=#{preferredBoxCode} THEN 0 ELSE 1 END,l.carton_index,l.id
LIMIT #{count}
""")
List<LabelRow> unboundLabels(@Param("taskId") long taskId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("detailId") long detailId,
@Param("preferredBoxCode") String preferredBoxCode,
@Param("count") int count);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE operation_id=#{operationId}")
int operationCount(@Param("operationId") String operationId);
@Insert("""
INSERT INTO fl_dropship_pallet_carton
(id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name,
box_code,operation_id,bound_by,bound_by_name,bound_at)
VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName},
#{boxCode},#{operationId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
""")
int insertCarton(@Param("id") long id, @Param("taskId") long taskId,
@Param("palletId") long palletId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("detailId") long detailId,
@Param("labelId") long labelId, @Param("skuCode") String skuCode,
@Param("cargoName") String cargoName, @Param("boxCode") String boxCode,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_dropship_pallet_task
SET allocated_cartons=allocated_cartons+#{count},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId}
AND allocated_cartons+#{count}<=total_cartons
""")
int addAllocated(@Param("taskId") long taskId, @Param("operatorId") long operatorId,
@Param("count") int count);
@Update("""
UPDATE fl_dropship_pallet
SET pallet_no=#{palletNo},pallet_status='COMPLETED',seal_operation_id=#{operationId},
completed_by=#{operatorId},completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),
version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion}
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
""")
int seal(@Param("taskId") long taskId, @Param("palletId") long palletId,
@Param("expectedVersion") int expectedVersion, @Param("palletNo") String palletNo,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'")
int openPalletCount(@Param("taskId") long taskId);
@Update("""
UPDATE fl_dropship_pallet_task
SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND allocated_cartons=total_cartons
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')
""")
int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o
SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
WHERE p.inbound_id=o.id AND p.pallet_status='COMPLETED'),o.updated_by=#{operatorId},o.version=o.version+1
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')
""")
int completeInbound(@Param("taskId") long taskId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("operatorId") long operatorId);
@Select("""
SELECT c.* FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}
ORDER BY c.sku_code,c.inbound_detail_id,c.id
""")
List<CartonRow> cartons(@Param("palletId") long palletId);
@Getter @Setter class TaskRow {
private Long id; private Long tenantId; private Long inboundId; private Long warehouseId;
private String inboundCode; private String customerName; private String warehouseName;
private String taskStatus; private Integer totalCartons; private Integer allocatedCartons;
private Long assigneeId; private String assigneeName; private Integer version;
private String processStatus; private String fulfillmentType;
}
@Getter @Setter class PalletRow {
private Long id; private Long taskId; private Long tenantId; private Long inboundId;
private Long warehouseId; private String palletNo; private String palletStatus;
private String openOperationId; private String sealOperationId; private Integer version;
private LocalDateTime completedAt;
}
@Getter @Setter class LabelRow {
private Long cartonLabelId; private Long inboundDetailId; private String skuCode;
private String cargoName; private Integer detailPackageCount; private String inboundCode;
private String boxCode;
}
@Getter @Setter class CartonRow {
private Long inboundId; private Long inboundDetailId; private String skuCode;
private String cargoName; private String boxCode;
}
} }
@@ -91,7 +91,7 @@ public interface LoadingCompletedEventMapper {
JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId}
AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id
AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0 AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0
WHERE pallet.pallet_status='COMPLETED' WHERE pallet.pallet_status='SEALED'
ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id
""") """)
List<PalletRow> actualPalletRows(@Param("planId") long planId); List<PalletRow> actualPalletRows(@Param("planId") long planId);
@@ -99,7 +99,7 @@ public interface LoadingCompletedEventMapper {
@Select(""" @Select("""
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id
AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='COMPLETED' AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
""") """)
int workPalletCount(@Param("planId") long planId); int workPalletCount(@Param("planId") long planId);
@@ -168,10 +168,18 @@ public interface LoadingListQueryMapper {
@Param("operatorName") String operatorName); @Param("operatorName") String operatorName);
@Select(""" @Select("""
SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no, SELECT i.id AS item_id, i.account_id, i.customer_name,
inbound.tax_mode AS customs_tax_mode,
CASE inbound.tax_mode
WHEN 'TAX_REFUND' THEN TRUE
WHEN 'BUY_DECLARATION' THEN FALSE
ELSE NULL
END AS tax_refund_flag,
i.order_id, i.order_no,
i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no, i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no,
i.sku_code, i.destination_warehouse_name, i.product_name_zh, i.sku_code, i.destination_warehouse_name, i.product_name_zh,
product_name_en, product_image_url, i.package_count, product_name_en, product_image_url, i.unit_type, i.pallet_id,
i.pallet_no, i.indivisible, i.package_count,
COALESCE(NULLIF(i.pallet_count,0), COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(inbound.pallet_count,0), CEIL(COALESCE(NULLIF(inbound.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight (SELECT COUNT(*) FROM fl_inbound_weight_result weight
@@ -409,6 +417,8 @@ public interface LoadingListQueryMapper {
private Long itemId; private Long itemId;
private Long accountId; private Long accountId;
private String customerName; private String customerName;
private String customsTaxMode;
private Boolean taxRefundFlag;
private Long orderId; private Long orderId;
private String orderNo; private String orderNo;
private Long inboundId; private Long inboundId;
@@ -420,6 +430,10 @@ public interface LoadingListQueryMapper {
private String productNameZh; private String productNameZh;
private String productNameEn; private String productNameEn;
private String productImageUrl; private String productImageUrl;
private String unitType;
private Long palletId;
private String palletNo;
private Boolean indivisible;
private Integer packageCount; private Integer packageCount;
private Integer palletCount; private Integer palletCount;
private BigDecimal grossWeightKg; private BigDecimal grossWeightKg;
@@ -1,90 +0,0 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import lombok.Getter;
import lombok.Setter;
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.util.List;
@Mapper
public interface LoadingWorkPalletCartonMapper extends BaseMapper<LoadingWorkPalletCartonEntity> {
@Select("""
SELECT label.id carton_label_id,label.tenant_id,label.inbound_id,label.inbound_detail_id,
label.box_code,plan_item.id loading_plan_item_id,pick_item.id pick_item_id,
plan_item.sku_code,plan_item.units_per_carton
FROM fl_inbound_carton_label label
JOIN fl_loading_plan_item plan_item
ON plan_item.account_id=label.tenant_id AND plan_item.inbound_id=label.inbound_id
AND plan_item.inbound_detail_id=label.inbound_detail_id AND plan_item.deleted=0
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id
JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id
AND task.loading_plan_id=plan_item.loading_plan_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0
WHERE task.id=#{taskId} AND task.task_status='LOADING' AND booking.warehouse_id=#{warehouseId}
AND label.box_code=#{boxCode} AND label.batch_status='ACTIVE'
AND inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP'
AND pick_item.item_status<>'SKIPPED'
ORDER BY label.tenant_id,label.id
""")
List<CartonCandidate> selectCandidates(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId,
@Param("boxCode") String boxCode);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE pick_task_id=#{taskId} AND carton_label_id=#{labelId}")
LoadingWorkPalletCartonEntity selectBinding(@Param("taskId") long taskId,
@Param("labelId") long labelId);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE operation_id=#{operationId}")
LoadingWorkPalletCartonEntity selectByOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId} ORDER BY id")
List<LoadingWorkPalletCartonEntity> selectPalletCartons(@Param("palletId") long palletId);
@Select("SELECT COUNT(*) FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId}")
int countPalletCartons(@Param("palletId") long palletId);
@Select("""
SELECT COUNT(*) FROM (
SELECT carton.pick_item_id
FROM fl_loading_work_pallet_carton carton
JOIN fl_loading_pick_item pick_item ON pick_item.id=carton.pick_item_id
WHERE carton.pick_task_id=#{taskId}
GROUP BY carton.pick_item_id,pick_item.picked_package_count
HAVING COUNT(*)>pick_item.picked_package_count
) unreconciled
""")
int countUnreconciled(@Param("taskId") long taskId);
@Update("""
UPDATE fl_loading_work_pallet_carton
SET work_pallet_id=#{targetPalletId},bound_by=#{operatorId},bound_by_name=#{operatorName},
operation_id=#{operationId},version=version+1
WHERE id=#{id} AND version=#{version}
""")
int move(@Param("id") long id, @Param("version") int version,
@Param("targetPalletId") long targetPalletId,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName,
@Param("operationId") String operationId);
@Getter
@Setter
class CartonCandidate {
private Long cartonLabelId;
private Long tenantId;
private Long inboundId;
private Long inboundDetailId;
private String boxCode;
private Long loadingPlanItemId;
private Long pickItemId;
private String skuCode;
private Integer unitsPerCarton;
}
}
@@ -1,77 +0,0 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
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.util.List;
@Mapper
public interface LoadingWorkPalletMapper extends BaseMapper<LoadingWorkPalletEntity> {
@Select("""
SELECT task.id task_id,task.loading_plan_id,task.task_status,booking.warehouse_id,
assignment.assignee_id,
SUM(CASE WHEN inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' THEN 1 ELSE 0 END) dropship_item_count
FROM fl_loading_pick_task task
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id=task.id
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id AND plan_item.deleted=0
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.id=#{taskId} AND booking.warehouse_id=#{warehouseId}
GROUP BY task.id,task.loading_plan_id,task.task_status,booking.warehouse_id,assignment.assignee_id
""")
TaskContext selectTaskContext(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_loading_work_pallet WHERE open_operation_id=#{operationId}")
LoadingWorkPalletEntity selectByOpenOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_loading_work_pallet WHERE seal_operation_id=#{operationId}")
LoadingWorkPalletEntity selectBySealOperation(@Param("operationId") String operationId);
@Select("""
SELECT pallet.*
FROM fl_loading_work_pallet pallet
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
WHERE pallet.pick_task_id=#{taskId} AND pallet.id=#{palletId}
AND booking.warehouse_id=#{warehouseId}
""")
LoadingWorkPalletEntity selectScoped(@Param("taskId") long taskId,
@Param("palletId") long palletId,
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pallet.*
FROM fl_loading_work_pallet pallet
JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id
JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
WHERE pallet.pick_task_id=#{taskId} AND booking.warehouse_id=#{warehouseId}
AND pallet.pallet_status<>'CANCELLED'
ORDER BY pallet.id
""")
List<LoadingWorkPalletEntity> selectTaskPallets(@Param("taskId") long taskId,
@Param("warehouseId") long warehouseId);
@Update("""
UPDATE fl_loading_work_pallet
SET pallet_status='SEALED',seal_operation_id=#{operationId},sealed_by=#{operatorId},
sealed_by_name=#{operatorName},sealed_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{palletId} AND pick_task_id=#{taskId} AND warehouse_id=#{warehouseId}
AND pallet_status='OPEN' AND version=#{expectedVersion}
""")
int seal(@Param("taskId") long taskId, @Param("palletId") long palletId,
@Param("warehouseId") long warehouseId, @Param("expectedVersion") int expectedVersion,
@Param("operationId") String operationId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
record TaskContext(Long taskId, Long loadingPlanId, String taskStatus, Long warehouseId,
Long assigneeId, Integer dropshipItemCount) {}
}
@@ -112,7 +112,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*), SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*),
COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0)
FROM fl_dropship_pallet_carton carton FROM fl_dropship_pallet_carton carton
JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='COMPLETED' JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='SEALED'
JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id
AND source.fulfillment_type='ONE_CLICK_DROPSHIP' AND source.fulfillment_type='ONE_CLICK_DROPSHIP'
JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id
@@ -399,7 +399,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*), SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*),
COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0)
FROM fl_dropship_pallet_carton carton FROM fl_dropship_pallet_carton carton
JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='COMPLETED' JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='SEALED'
JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id
AND source.fulfillment_type='ONE_CLICK_DROPSHIP' AND source.fulfillment_type='ONE_CLICK_DROPSHIP'
JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -47,6 +50,17 @@ public class PlatformCarrierScheduleQueryController {
return ApiResponse.success(service.carriers()); return ApiResponse.success(service.carriers());
} }
@Operation(summary = "按货柜批量查询实际船节点摘要",
description = "最多200柜;仅返回实际节点时间,不返回或推断预计时间;没有船节点任务的货柜不返回行。")
@GetMapping("/container-actual-facts")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<List<ContainerActualFacts>> containerActualFacts(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam("containerBookingIds") @Size(min = 1, max = 200) List<@Positive Long> containerBookingIds) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.containerActualFacts(containerBookingIds));
}
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -80,6 +80,17 @@ public final class CarrierScheduleQueryDtos {
public record QueryView(Task query, boolean expired) {} public record QueryView(Task query, boolean expired) {}
/** Actual-only summary used by container workbenches; estimated facts are intentionally excluded. */
public record ContainerActualFacts(Long containerBookingId, Long queryId, Integer queryVersion,
String queryStatus, String latestActualNode,
LocalDateTime latestActualAt,
LocalDateTime departedWarehouseAt,
LocalDateTime departedOriginPortAt,
LocalDateTime arrivedDestinationPortAt,
LocalDateTime pickupReadyAt,
LocalDateTime pickedUpAt,
LocalDateTime emptyReturnedAt) {}
public record NodeFact(Long id, String providerCode, String providerEventId, public record NodeFact(Long id, String providerCode, String providerEventId,
String providerNodeCode, String standardNode, String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -46,6 +47,33 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Param("oceanBookingId") long oceanBookingId, @Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId); @Param("containerBookingId") long containerBookingId);
@Select("""
<script>
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
FROM fl_carrier_schedule_query q
JOIN (
SELECT container_booking_id,MAX(id) AS query_id
FROM fl_carrier_schedule_query
WHERE container_booking_id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY container_booking_id
) latest ON latest.query_id=q.id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
ORDER BY q.container_booking_id
</script>
""")
List<ContainerActualFacts> selectContainerActualFacts(
@Param("containerBookingIds") List<Long> containerBookingIds);
@Select(""" @Select("""
SELECT * FROM fl_carrier_schedule_query SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED') WHERE status IN ('READY','MANUAL_REQUIRED')
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
@@ -40,6 +41,9 @@ public class CarrierScheduleQueryRepository {
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
} }
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1; return queries.claimLease(id, owner, now, leaseUntil) == 1;
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers; import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -48,6 +49,7 @@ import java.time.Clock;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@@ -76,6 +78,18 @@ public class CarrierScheduleQueryService {
source.page(), source.size(), source.total(), source.pages()); source.page(), source.size(), source.total(), source.pages());
} }
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
throw badRequest("containerBookingIds数量必须在1到200之间");
}
Set<Long> unique = new HashSet<>();
for (Long id : containerBookingIds) {
if (id == null || id <= 0) throw badRequest("containerBookingIds必须全部为正数");
if (!unique.add(id)) throw badRequest("containerBookingIds不能包含重复值");
}
return repository.containerActualFacts(containerBookingIds);
}
public Detail detail(long id) { return detail(required(id)); } public Detail detail(long id) { return detail(required(id)); }
public List<History> history(long id) { public List<History> history(long id) {
@@ -32,6 +32,16 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
""") """)
Long findActiveVersionId(); Long findActiveVersionId();
@Select("""
SELECT id
FROM fl_price_book_version
WHERE tenant_id IN (0, #{customerId}) AND status = 'ACTIVE' AND deleted = 0
ORDER BY CASE WHEN tenant_id = #{customerId} THEN 0 ELSE 1 END,
published_at DESC, version_no DESC
LIMIT 1
""")
Long findEffectiveVersionId(@Param("customerId") long customerId);
@Select(""" @Select("""
SELECT c.price_book_version_id AS priceBookVersionId, SELECT c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName, c.channel_code AS channelCode, c.channel_name AS channelName,
@@ -48,7 +58,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
SELECT 1 FROM fl_carrier_fba_rate r SELECT 1 FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = c.price_book_version_id WHERE r.price_book_version_id = c.price_book_version_id
AND r.channel_code = c.channel_code AND r.channel_code = c.channel_code
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0 AND r.status = 'ACTIVE' AND r.deleted = 0
) )
ORDER BY c.id ORDER BY c.id
@@ -60,7 +70,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
FROM fl_carrier_fba_rate r FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId} WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0 AND r.status = 'ACTIVE' AND r.deleted = 0
AND r.pricing_tax_mode IS NOT NULL AND r.pricing_tax_mode IS NOT NULL
GROUP BY r.channel_code, r.pricing_tax_mode GROUP BY r.channel_code, r.pricing_tax_mode
@@ -83,7 +93,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId} WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA' AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA'
AND UPPER(r.destination_code) = #{destinationCode} AND UPPER(r.destination_code) = #{destinationCode}
AND r.pricing_tax_mode = #{pricingTaxMode} AND r.pricing_tax_mode = #{pricingTaxMode}
@@ -109,7 +119,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId} WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS' AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS'
AND r.destination_warehouse_id = #{destinationWarehouseId} AND r.destination_warehouse_id = #{destinationWarehouseId}
AND r.pricing_tax_mode = #{pricingTaxMode} AND r.pricing_tax_mode = #{pricingTaxMode}
@@ -135,7 +145,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId} WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.channel_code = #{channelCode}
AND r.price_model = 'REGION_WEIGHT_TIER' AND r.price_model = 'REGION_WEIGHT_TIER'
AND r.region_code = #{regionCode} AND r.region_code = #{regionCode}
@@ -16,6 +16,9 @@ public class PriceBookQueryService implements PriceBookQueryUseCase {
@Resource private PriceBookQueryReader reader; @Resource private PriceBookQueryReader reader;
@Override public Long activeVersionId() { return reader.findActiveVersionId(); } @Override public Long activeVersionId() { return reader.findActiveVersionId(); }
@Override public Long effectiveVersionId(long customerId) {
return reader.findEffectiveVersionId(customerId);
}
@Override public List<ChannelRow> channels(long versionId, long originWarehouseId) { @Override public List<ChannelRow> channels(long versionId, long originWarehouseId) {
return reader.findChannels(versionId, originWarehouseId); return reader.findChannels(versionId, originWarehouseId);
} }
@@ -9,6 +9,7 @@ import java.util.List;
public interface PriceBookQueryUseCase { public interface PriceBookQueryUseCase {
Long activeVersionId(); Long activeVersionId();
Long effectiveVersionId(long customerId);
List<ChannelRow> channels(long versionId, long originWarehouseId); List<ChannelRow> channels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId); List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId);
List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode, List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
@@ -9,6 +9,7 @@ import java.util.List;
public interface PriceBookQueryReader { public interface PriceBookQueryReader {
Long findActiveVersionId(); Long findActiveVersionId();
Long findEffectiveVersionId(long customerId);
List<ChannelRow> findChannels(long versionId, long originWarehouseId); List<ChannelRow> findChannels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId); List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode, List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
@@ -27,10 +27,14 @@ public class ActivePriceBookEstimator {
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
public Estimate estimate(EstimateRequest request) { public Estimate estimate(EstimateRequest request) {
return estimate(0L, request);
}
public Estimate estimate(long customerId, EstimateRequest request) {
if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) { if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) {
throw validation("请选择有效起运仓"); throw validation("请选择有效起运仓");
} }
Long versionId = repository.activeVersionId(); Long versionId = repository.effectiveVersionId(customerId);
if (versionId == null) { if (versionId == null) {
throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本"); throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本");
} }
@@ -65,6 +69,8 @@ public class ActivePriceBookEstimator {
} else { } else {
throw validation("私人地址暂不支持参考估价"); throw validation("私人地址暂不支持参考估价");
} }
String chargeMode = requiredChargeMode(request);
rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList();
RateRow rate = uniqueRate(rates); RateRow rate = uniqueRate(rates);
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE); requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm()); var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
@@ -81,6 +87,9 @@ public class ActivePriceBookEstimator {
if (request.pricingTaxMode() == null) { if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价"); throw validation("请选择包税或不包税报价");
} }
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
throw validation("区域重量阶梯仅支持KG计费");
}
PriceRegion region = request.destinationRegion() == null PriceRegion region = request.destinationRegion() == null
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion(); ? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode, List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
@@ -113,6 +122,19 @@ public class ActivePriceBookEstimator {
return rates.get(0); return rates.get(0);
} }
private static String requiredChargeMode(EstimateRequest request) {
if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED) {
if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
return "KG";
}
if (!StringUtils.hasText(request.chargeMode())) {
throw validation("不包税报价请选择客户计费方式");
}
return request.chargeMode();
}
private static void requireModel(RateRow rate, PriceModel expected) { private static void requireModel(RateRow rate, PriceModel expected) {
if (rate == null || !expected.name().equals(rate.getPriceModel())) { if (rate == null || !expected.name().equals(rate.getPriceModel())) {
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致"); throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
@@ -0,0 +1,72 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 将同柜同客户的阶梯总额按各票计费重分摊,并保证分摊总额守恒。 */
@Component
public class ContainerTierAmountAllocator {
private static final int MONEY_SCALE = 2;
private static final BigDecimal CENT = new BigDecimal("0.01");
public Allocation allocate(List<Member> source, BigDecimal unitPrice) {
if (source == null || source.isEmpty() || unitPrice == null || unitPrice.signum() < 0) {
throw validation("柜级阶梯计费参数无效");
}
List<Member> members = source.stream()
.sorted(Comparator.comparingLong(Member::inboundId))
.toList();
if (members.stream().map(Member::inboundId).distinct().count() != members.size()) {
throw validation("柜级阶梯计费成员不能重复");
}
BigDecimal totalQuantity = members.stream().map(Member::chargeableQuantity)
.peek(value -> {
if (value == null || value.signum() < 0) throw validation("计费重不能小于0");
})
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalQuantity.signum() <= 0) throw validation("柜级合计计费重必须大于0");
BigDecimal totalAmount = totalQuantity.multiply(unitPrice).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
List<Share> shares = new ArrayList<>(members.size());
BigDecimal allocated = BigDecimal.ZERO.setScale(MONEY_SCALE);
for (Member member : members) {
BigDecimal exact = totalAmount.multiply(member.chargeableQuantity())
.divide(totalQuantity, 12, RoundingMode.HALF_UP);
BigDecimal floor = exact.setScale(MONEY_SCALE, RoundingMode.DOWN);
allocated = allocated.add(floor);
shares.add(new Share(member.inboundId(), floor, exact.subtract(floor)));
}
int cents = totalAmount.subtract(allocated).movePointRight(MONEY_SCALE).intValueExact();
shares.sort(Comparator.comparing(Share::remainder).reversed().thenComparingLong(Share::inboundId));
for (int index = 0; index < cents; index++) {
Share share = shares.get(index % shares.size());
shares.set(index % shares.size(), share.add(CENT));
}
shares.sort(Comparator.comparingLong(Share::inboundId));
Map<Long, BigDecimal> amounts = new LinkedHashMap<>();
shares.forEach(share -> amounts.put(share.inboundId(), share.amount()));
return new Allocation(totalQuantity, totalAmount, amounts);
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
public record Member(long inboundId, BigDecimal chargeableQuantity) {}
public record Allocation(BigDecimal totalQuantity, BigDecimal totalAmount,
Map<Long, BigDecimal> memberAmounts) {}
private record Share(long inboundId, BigDecimal amount, BigDecimal remainder) {
private Share add(BigDecimal increment) {
return new Share(inboundId, amount.add(increment), remainder);
}
}
}
@@ -12,15 +12,16 @@ import org.springframework.stereotype.Repository;
@Repository @Repository
public class ContainerRepository { public class ContainerRepository {
@Resource private ContainerBookingMapper mapper; @Resource private ContainerBookingMapper mapper;
public PageResult<ContainerBookingEntity> page(Query query) { public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
Page<ContainerBookingEntity> result = mapper.selectBookingPage( Page<ContainerBookingEntity> result = mapper.selectBookingPage(
new Page<>(query.page(), query.size()), query); new Page<>(query.page(), query.size()), query, workbench);
result.getRecords().forEach(this::resolveLoadingTime); result.getRecords().forEach(this::resolveLoadingTime);
return PageResult.from(result); return PageResult.from(result);
} }
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); } public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); } public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); } public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
public boolean containerNoExists(String no, Long excludedId) { public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false; if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>() return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -45,6 +46,9 @@ public class ContainerRepository {
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) { public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName); return mapper.updateDraft(value, version, operatorId, operatorName);
} }
public int updatePickupCode(long id, String pickupCode, int version, long operatorId, String operatorName) {
return mapper.updatePickupCode(id, pickupCode, version, operatorId, operatorName);
}
public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; } public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; }
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; } public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
@@ -18,6 +18,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper;
import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
import com.baiyue.firstleg.mapper.InboundPricingMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper;
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -40,6 +41,17 @@ public class FbaPricingRepository {
private InboundPricingIdempotencyMapper idempotencies; private InboundPricingIdempotencyMapper idempotencies;
@Resource @Resource
private InboundOutboxMapper outbox; private InboundOutboxMapper outbox;
@Resource
private ContainerTierPricingMapper containerTierPricing;
public List<ContainerTierPricingMapper.MemberRow> containerTierMembers(long customerId, long inboundId) {
return containerTierPricing.members(customerId, inboundId);
}
public List<ContainerTierPricingMapper.MemberRow> containerTierMembersForUpdate(
long containerBookingId, long customerId) {
return containerTierPricing.membersForUpdate(containerBookingId, customerId);
}
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
@@ -171,7 +183,7 @@ public class FbaPricingRepository {
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId())
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType()) .eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode()) .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode())
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.le(CustomerChargeRuleEntity::getEffectiveFrom, at) .le(CustomerChargeRuleEntity::getEffectiveFrom, at)
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo) .and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
@@ -304,6 +316,19 @@ public class FbaPricingRepository {
return inbounds.update(null, update); return inbounds.update(null, update);
} }
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
String chargeMode, java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
return inbounds.update(null, update);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) { public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>() return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId) .eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
@@ -184,10 +184,12 @@ public class LoadingListRepository {
private List<Item> items(List<ItemRow> rows) { private List<Item> items(List<ItemRow> rows) {
return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(), return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(),
row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(), row.getCustomsTaxMode(), row.getTaxRefundFlag(), row.getOrderId(), row.getOrderNo(),
row.getInboundId(), row.getInboundDetailId(),
row.getInboundCode(), row.getContainerNo(), row.getSkuCode(), row.getInboundCode(), row.getContainerNo(), row.getSkuCode(),
row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(),
row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), row.getProductImageUrl(), row.getUnitType(), row.getPalletId(), row.getPalletNo(),
Boolean.TRUE.equals(row.getIndivisible()), integer(row.getPackageCount()), integer(row.getPalletCount()),
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()), decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()), decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
@@ -32,39 +32,50 @@ public class PriceBookRepository {
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); } public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
public int updateVersion(PriceBookVersionEntity value) { public int updateVersion(PriceBookVersionEntity value) {
return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>() return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, value.getTenantId())
.eq(PriceBookVersionEntity::getId, value.getId())); .eq(PriceBookVersionEntity::getId, value.getId()));
} }
public PriceBookVersionEntity findVersion(long id) { public PriceBookVersionEntity findVersion(long id) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>() return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, id) .eq(PriceBookVersionEntity::getId, id)
.eq(PriceBookVersionEntity::getDeleted, false)); .eq(PriceBookVersionEntity::getDeleted, false));
} }
public List<PriceBookVersionEntity> listVersions(String status) { public List<PriceBookVersionEntity> listVersions(String status) {
return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status);
}
public List<PriceBookVersionEntity> listVersions(long customerId, String status) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>() return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status) .eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status)
.eq(PriceBookVersionEntity::getDeleted, false) .eq(PriceBookVersionEntity::getDeleted, false)
.orderByDesc(PriceBookVersionEntity::getCreatedAt)); .orderByDesc(PriceBookVersionEntity::getCreatedAt));
} }
public PriceBookVersionEntity findActiveForUpdate() { public PriceBookVersionEntity findActiveForUpdate() {
return findActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public PriceBookVersionEntity findActiveForUpdate(long customerId) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>() return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false) .eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE")); .last("FOR UPDATE"));
} }
public List<PriceBookVersionEntity> listActiveForUpdate() { public List<PriceBookVersionEntity> listActiveForUpdate() {
return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public List<PriceBookVersionEntity> listActiveForUpdate(long customerId) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>() return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false) .eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE")); .last("FOR UPDATE"));
} }
public PriceBookVersionEntity findActive() { public PriceBookVersionEntity findActive() {
return findActive(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public PriceBookVersionEntity findActive(long customerId) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>() return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)); .eq(PriceBookVersionEntity::getDeleted, false));
} }
@@ -74,14 +85,20 @@ public class PriceBookRepository {
* ACTIVE version exists (both transactions could otherwise pass the check). * ACTIVE version exists (both transactions could otherwise pass the check).
*/ */
public void lockPlatformVersions() { public void lockPlatformVersions() {
lockVersions(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public void lockVersions(long customerId) {
versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>() versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, customerId)
.eq(PriceBookVersionEntity::getDeleted, false) .eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE")); .last("FOR UPDATE"));
} }
public int nextVersionNo() { public int nextVersionNo() {
return nextVersionNo(PriceBookVersionEntity.PLATFORM_SCOPE);
}
public int nextVersionNo(long customerId) {
PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>() PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getTenantId, customerId)
.orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE")); .orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE"));
return latest == null ? 1 : latest.getVersionNo() + 1; return latest == null ? 1 : latest.getVersionNo() + 1;
} }
@@ -97,6 +97,11 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundId, inboundId) .eq(LoadingPlanItemEntity::getInboundId, inboundId)
.eq(LoadingPlanItemEntity::getDeleted, false)); .eq(LoadingPlanItemEntity::getDeleted, false));
} }
public List<LoadingPlanItemEntity> activeItemsByPallet(long palletId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getPalletId, palletId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) { public LoadingPlanEntity planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>() return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
@@ -121,6 +126,13 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId) .eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false)); .eq(LoadingPlanItemEntity::getDeleted, false));
} }
public LoadingPlanItemEntity activePalletItem(long planId, long palletId, long detailId) {
return items.selectOne(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getPalletId, palletId)
.eq(LoadingPlanItemEntity::getInboundDetailId, detailId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanHistoryEntity operation(String operationId) { public LoadingPlanHistoryEntity operation(String operationId) {
return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>() return histories.selectOne(new LambdaQueryWrapper<LoadingPlanHistoryEntity>()
.eq(LoadingPlanHistoryEntity::getOperationId, operationId)); .eq(LoadingPlanHistoryEntity::getOperationId, operationId));
@@ -51,7 +51,9 @@ public class ContainerOverviewService {
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(), booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(), booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(), booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()), booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getPickupCode(),
booking.getPickupCodeUpdatedBy(), booking.getPickupCodeUpdatedByName(), booking.getPickupCodeUpdatedAt(),
booking.getVersion()),
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents, task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
new ExceptionState(latestException != null && "MARK".equals(latestException.action()), new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
latestException == null ? null : latestException.reason(), latestException == null ? null : latestException.reason(),
@@ -72,6 +74,18 @@ public class ContainerOverviewService {
changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL"); changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL");
} }
@Transactional
public Overview updatePickupCode(long id, long operatorId, String operatorName, PickupCodeRequest request) {
ContainerBookingEntity booking = containers.findActiveForUpdate(id);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试");
String pickupCode = request.pickupCode().trim();
if (containers.updatePickupCode(id, pickupCode, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜版本已变化,请刷新后重试");
}
return overview(id);
}
private void changeException(long id, long operatorId, String operatorName, String rawKey, private void changeException(long id, long operatorId, String operatorName, String rawKey,
ExceptionRequest request, String action) { ExceptionRequest request, String action) {
String operationId = operationId(rawKey); String operationId = operationId(rawKey);
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.service; package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -9,7 +8,6 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
@@ -41,11 +39,15 @@ public class ContainerService {
@Resource private SmartLoadingService smartLoadingService; @Resource private SmartLoadingService smartLoadingService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) { public void requireOverseas(long id) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); required(id);
return repository.page(normalize(query)); if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
}
public void requireDomestic(long id) {
required(id);
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
} }
public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional @Transactional
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) { public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
@@ -220,12 +222,6 @@ public class ContainerService {
if (from != null && to != null && to.isBefore(from)) if (from != null && to != null && to.isBefore(from))
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确"); throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
} }
private static Query normalize(Query q) {
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
q.estimatedSailingTo(), q.page(), q.size());
}
private static String trim(String value) { private static String trim(String value) {
if (value == null) return null; if (value == null) return null;
String result = value.trim(); return result.isEmpty() ? null : result; String result = value.trim(); return result.isEmpty() ? null : result;
@@ -4,194 +4,193 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos; import com.baiyue.firstleg.dto.DropshipPalletizationDtos;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.*;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.ScanResult;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Task;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.math.BigDecimal;
import java.util.LinkedHashMap; import java.util.*;
import java.util.List;
import java.util.Map;
/** 测量完成即下发的一件代发合托,不创建国内仓库托盘、库位或库存。 */ /** 一件代发测量后的逐箱清点、待绑托和封托,不创建第二套装柜工作托盘。 */
@Service @Service
public class DropshipPalletizationService { public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper; @Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator numbers;
public void createTask(InboundOrderEntity inbound) { public void createTask(InboundOrderEntity inbound) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务");
throw invalid("一件代发测量完成箱数无效,不能下发合托任务"); mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount());
} }
mapper.insertTask(IdWorker.getId(), inbound.getTenantId(), inbound.getId(),
inbound.getWarehouseId(), inbound.getTotalPackageCount());
}
public List<DropshipPalletizationDtos.PendingTask> pending(long operatorId, String keyword) { public List<PendingTask> pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); }
return mapper.pending(operatorId, normalize(keyword));
}
@Transactional @Transactional
public Task claim(long taskId, long warehouseId, long operatorId, String operatorName) { public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){
if (mapper.claim(taskId, warehouseId, operatorId, operatorName) != 1) { if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化");
throw conflict("任务已被其他人员领取或状态已变化"); return task(taskId,warehouseId,operatorId);
} }
return task(taskId, warehouseId, operatorId);
}
public Task task(long taskId, long warehouseId, long operatorId) { public Task task(long taskId,long warehouseId,long operatorId){
TaskRow row = requireTask(taskId, warehouseId); TaskRow row=requireTask(taskId,warehouseId);
if (row.getAssigneeId() != null && row.getAssigneeId() != operatorId) { if(row.getAssigneeId()!=null && row.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
throw conflict("任务已由其他人员领取"); return view(row);
} }
return view(row);
}
@Transactional public Carton resolveCarton(long taskId,long warehouseId,String boxCode,long operatorId){
public Pallet open(long taskId, DropshipPalletizationDtos.OpenPalletRequest request, TaskRow task=requireTask(taskId,warehouseId);
long operatorId, String operatorName) { if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); LabelRow label=requireLabel(taskId,boxCode);
String operationId = request.operationId().trim(); CartonRow counted=mapper.countedByLabel(taskId,label.getCartonLabelId());
PalletRow replay = mapper.palletByOpenOperation(operationId); if(counted!=null) return cartonView(counted,task.getInboundCode(),false,"箱码已清点");
if (replay != null) { String block=null;
if (replay.getTaskId() != taskId) throw conflict("开托幂等号已用于其他任务"); if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务";
return palletView(replay, task.getInboundCode()); else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员";
} else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态";
long id = IdWorker.getId(); return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(),
mapper.insertPallet(id, taskId, task.getTenantId(), task.getInboundId(), task.getWarehouseId(), label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null,
operationId, operatorId, operatorName); block==null,block);
return palletView(mapper.pallet(taskId, id, request.warehouseId()), task.getInboundCode()); }
}
@Transactional @Transactional
public ScanResult scan(long taskId, long palletId, DropshipPalletizationDtos.ScanRequest request, public CountResult countCarton(long taskId,CountCartonRequest request,long operatorId,String operatorName){
long operatorId, String operatorName) { TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); String operation=operation(request.operationId());
PalletRow pallet = requireOpenPallet(taskId, palletId, request.warehouseId()); CartonRow replay=mapper.cartonByCountOperation(operation);
String operationId = request.operationId().trim(); if(replay!=null){
LabelRow scanned = mapper.label(taskId, request.boxCode().trim()); if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim())
if (scanned == null) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前一件代发入仓单"); || !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作");
if (!StringUtils.hasText(scanned.getSkuCode())) throw conflict("箱唛缺少SKU,不能合托"); return countResult(task,replay);
if (mapper.operationCount(operationId) > 0) { }
return scanResult(scanned, request.cartonCount(), task, pallet); LabelRow label=requireLabel(taskId,request.boxCode());
} if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点");
List<LabelRow> labels = mapper.unboundLabels(taskId, task.getTenantId(), task.getInboundId(), int expected=requireExpectedQuantity(label);
scanned.getInboundDetailId(), scanned.getBoxCode(), request.cartonCount()); boolean mismatch=expected!=request.actualSkuQuantity();
if (labels.size() != request.cartonCount()) { String remark=normalize(request.discrepancyRemark());
throw conflict("本SKU剩余未上托箱数不足,请刷新任务后重试"); if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
} if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
for (LabelRow label : labels) { if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
mapper.insertCarton(IdWorker.getId(), taskId, palletId, task.getTenantId(), task.getInboundId(), PalletRow pallet=StringUtils.hasText(request.palletNo())
label.getInboundDetailId(), label.getCartonLabelId(), label.getSkuCode(), label.getCargoName(), ? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null;
label.getBoxCode(), operationId, operatorId, operatorName); long cartonId=IdWorker.getId();
} mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
if (mapper.addAllocated(taskId, operatorId, labels.size()) != 1) { label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
throw conflict("任务箱数已变化,请刷新后重试"); expected,request.actualSkuQuantity(),mismatch?"ACCEPTED_VARIANCE":"MATCH",remark,operation,operatorId,operatorName);
} if(mapper.addCounted(taskId,operatorId,pallet==null?0:1,mismatch?1:0)!=1) throw conflict("任务清点数量已变化,请刷新后重试");
TaskRow refreshed = requireTask(taskId, request.warehouseId()); TaskRow refreshed=requireTask(taskId,request.warehouseId());
return scanResult(scanned, labels.size(), refreshed, return countResult(refreshed,mapper.carton(taskId,cartonId));
mapper.pallet(taskId, palletId, request.warehouseId())); }
}
@Transactional @Transactional
public Task seal(long taskId, long palletId, DropshipPalletizationDtos.SealPalletRequest request, public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){
long operatorId, String operatorName) { TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); String operation=operation(request.operationId());
PalletRow replay = mapper.palletBySealOperation(request.operationId().trim()); if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId()));
if (replay != null) { PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName);
if (replay.getTaskId() != taskId || replay.getId() != palletId) { int expected=mapper.unboundCount(taskId);
throw conflict("封托幂等号已用于其他托盘"); if(expected==0) throw conflict("当前没有待绑托箱");
} int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName);
return view(requireTask(taskId, request.warehouseId())); if(bound!=expected || mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
} return view(requireTask(taskId,request.warehouseId()));
requireOpenPallet(taskId, palletId, request.warehouseId()); }
String palletNo = numbers.next("DP");
if (mapper.seal(taskId, palletId, request.expectedVersion(), palletNo, request.operationId().trim(),
operatorId, operatorName) != 1) {
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
}
TaskRow refreshed = requireTask(taskId, request.warehouseId());
if (refreshed.getAllocatedCartons().equals(refreshed.getTotalCartons())
&& mapper.openPalletCount(taskId) == 0) {
if (mapper.completeTask(taskId) != 1
|| mapper.completeInbound(taskId, refreshed.getTenantId(), refreshed.getInboundId(), operatorId) != 1) {
throw conflict("合托完成状态已变化,请刷新后重试");
}
}
return view(requireTask(taskId, request.warehouseId()));
}
public List<Pallet> inboundPallets(long tenantId, long inboundId) { @Transactional
String inboundCode = mapper.inboundCode(tenantId, inboundId); public Task moveCarton(long taskId,long cartonId,MoveCartonRequest request,long operatorId,String operatorName){
if (inboundCode == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
return mapper.palletsByInbound(tenantId, inboundId).stream() CartonRow carton=mapper.carton(taskId,cartonId);
.map(row -> palletView(row, inboundCode)).toList(); if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在");
} if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId());
PalletRow target=StringUtils.hasText(request.targetPalletNo())
? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null;
if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task);
if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("清点箱已被其他操作修改,请刷新后重试");
int delta=carton.getPalletId()==null?1:(target==null?-1:0);
if(delta!=0 && mapper.adjustBound(taskId,operatorId,delta)!=1) throw conflict("任务绑托数量已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
private ScanResult scanResult(LabelRow label, int added, TaskRow task, PalletRow pallet) { @Transactional
return new ScanResult(label.getSkuCode(), label.getCargoName(), label.getInboundCode(), added, public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){
task.getAllocatedCartons(), task.getTotalCartons() - task.getAllocatedCartons(), TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
palletView(pallet, task.getInboundCode())); PalletRow replay=mapper.palletBySealOperation(operation(request.operationId()));
} if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
requireOpenPallet(taskId,palletId,request.warehouseId());
if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托");
if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
TaskRow refreshed=requireTask(taskId,request.warehouseId());
if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) &&
Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){
if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1)
throw conflict("组托完成状态已变化,请刷新后重试");
}
return view(requireTask(taskId,request.warehouseId()));
}
private Task view(TaskRow row) { public List<Pallet> inboundPallets(long tenantId,long inboundId){
return new Task(row.getId(), row.getInboundId(), row.getInboundCode(), row.getTenantId(), String code=mapper.inboundCode(tenantId,inboundId);
row.getCustomerName(), row.getWarehouseId(), row.getWarehouseName(), row.getTotalCartons(), if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
row.getAllocatedCartons(), row.getTaskStatus(), row.getAssigneeId(), row.getAssigneeName(), return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList();
row.getVersion(), mapper.pallets(row.getId()).stream() }
.map(pallet -> palletView(pallet, row.getInboundCode())).toList());
}
private Pallet palletView(PalletRow row, String inboundCode) { private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){
List<CartonRow> cartons = mapper.cartons(row.getId()); PalletRow existing=mapper.palletByNo(palletNo);
Map<String, List<CartonRow>> groups = new LinkedHashMap<>(); if(existing!=null){
for (CartonRow carton : cartons) { if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用");
groups.computeIfAbsent(carton.getInboundDetailId() + "|" + carton.getSkuCode(), ignored -> new ArrayList<>()) if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
.add(carton); return existing;
} }
List<SkuItem> items = groups.values().stream().map(values -> { try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); }
CartonRow first = values.get(0); catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); }
return new SkuItem(first.getInboundId(), inboundCode, first.getInboundDetailId(), first.getSkuCode(), existing=mapper.palletByNo(palletNo);
first.getCargoName(), values.size(), values.stream().map(CartonRow::getBoxCode).toList()); if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用");
}).toList(); return existing;
return new Pallet(row.getId(), row.getPalletNo(), row.getPalletStatus(), cartons.size(), items.size(), }
row.getVersion(), items, row.getCompletedAt());
}
private TaskRow requireWorkingTask(long taskId, long warehouseId, long operatorId) { private CountResult countResult(TaskRow task,CartonRow carton){
TaskRow task = requireTask(taskId, warehouseId); Pallet p=carton.getPalletId()==null?null:palletView(mapper.pallet(task.getId(),carton.getPalletId(),task.getWarehouseId()),task.getInboundCode());
if (!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待合托任务"); return new CountResult(cartonView(carton,task.getInboundCode(),false,null),task.getCountedCartons(),task.getAllocatedCartons(),
if (task.getAssigneeId() == null || task.getAssigneeId() != operatorId) throw conflict("任务不属于当前操作员"); task.getCountedCartons()-task.getAllocatedCartons(),task.getTotalCartons()-task.getCountedCartons(),p);
if (!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待合托状态"); }
return task;
}
private TaskRow requireTask(long taskId, long warehouseId) { private Task view(TaskRow row){
TaskRow task = mapper.task(taskId, warehouseId); return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(),
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的一件代发合托任务"); row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(),
return task; row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList(),
} mapper.unboundCartons(row.getId()).stream().map(c->cartonView(c,row.getInboundCode(),false,null)).toList());
}
private PalletRow requireOpenPallet(long taskId, long palletId, long warehouseId) { private Pallet palletView(PalletRow row,String inboundCode){
PalletRow pallet = mapper.pallet(taskId, palletId, warehouseId); List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘已完成,不能继续上托"); List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode,
return pallet; first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(),
} values.stream().map(CartonRow::getBoxCode).toList());}).toList();
int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count();
PalletMetrics metrics=mapper.metrics(row.getId());
return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies,
metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items,
cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt());
}
private static String normalize(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(),
private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } row.getExpectedSkuQuantity(),row.getActualSkuQuantity(),row.getDiscrepancyStatus(),row.getDiscrepancyRemark(),
row.getPalletId()==null?"COUNTED_UNBOUND":"BOUND",row.getPalletId(),row.getPalletNo(),canCount,blockReason);
}
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; }
private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); }
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); return task; }
private TaskRow requireTask(long taskId,long warehouseId){ TaskRow task=mapper.task(taskId,warehouseId); if(task==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); return task; }
private PalletRow requireOpenPallet(long taskId,long palletId,long warehouseId){ PalletRow p=mapper.pallet(taskId,palletId,warehouseId); if(p==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在"); if(!"OPEN".equals(p.getPalletStatus())) throw conflict("托盘已封托,不能修改"); return p; }
private static String operation(String v){String n=normalize(v);if(!StringUtils.hasText(n))throw invalid("操作幂等号不能为空");return n;}
private static String normalize(String v){return StringUtils.hasText(v)?v.trim():null;}
private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);}
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
} }
@@ -13,7 +13,10 @@ import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -28,15 +31,21 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
/** FBA 费用试算、确认和发送状态机。 */ /** FBA 费用试算、确认和发送状态机。 */
@Service @Service
public class FbaPricingService { public class FbaPricingService {
private static final int MONEY_SCALE = 2; private static final int MONEY_SCALE = 2;
private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00");
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认"; private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
@Resource @Resource
private FbaPricingRepository repository; private FbaPricingRepository repository;
@Resource @Resource
@@ -49,6 +58,8 @@ public class FbaPricingService {
private InboundFeeDetailService feeDetails; private InboundFeeDetailService feeDetails;
@Resource @Resource
private FinanceFeeSourceOutboxService financeOutbox; private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private ContainerTierAmountAllocator containerTierAllocator;
@Transactional @Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
@@ -114,14 +125,17 @@ public class FbaPricingService {
|| !StringUtils.hasText(item.getCalculationRuleVersion()))); || !StringUtils.hasText(item.getCalculationRuleVersion())));
if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用");
} }
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, pricingTaxMode(inbound), policy.chargeMode(),
selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
RateRow rate = estimate.rate();
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP); BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
estimate = applyContainerCustomerTier(inbound, estimate, weight, volume);
RateRow rate = estimate.rate();
BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable; BigDecimal converted = chargeable;
BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP); BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
@@ -133,6 +147,13 @@ public class FbaPricingService {
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version, InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now); weight, volume, converted, chargeable, subtotal, additional, total, now);
if (policy.rule() != null) {
fee.setChargeRuleId(policy.rule().getId());
fee.setChargeRuleVersion(policy.rule().getRuleVersion());
fee.setRuleCode(policy.rule().getRuleCode());
fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
fee.setRuleEffectiveTo(policy.rule().getEffectiveTo());
}
fee.setMeasurementId(measurement == null ? null : measurement.getId()); fee.setMeasurementId(measurement == null ? null : measurement.getId());
fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource); fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource);
if (published != null) { if (published != null) {
@@ -156,6 +177,210 @@ public class FbaPricingService {
return response(inbound, fee, rate.getUnitPrice(), nextStatus); return response(inbound, fee, rate.getUnitPrice(), nextStatus);
} }
private ActivePriceBookEstimator.Estimate applyContainerCustomerTier(
InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual,
BigDecimal currentWeight, BigDecimal currentVolume) {
if (individual.priceModel() != PriceModel.REGION_WEIGHT_TIER) return individual;
var rows = repository.containerTierMembers(inbound.getTenantId(), inbound.getId());
if (rows == null || rows.isEmpty()) return individual;
long containerId = rows.get(0).containerBookingId();
if (rows.stream().anyMatch(row -> row.containerBookingId() != containerId)) {
throw conflict("同一入仓单已分配到多个柜子,不能生成柜级阶梯费用");
}
Map<Long, ContainerTierAmountAllocator.Member> unique = new LinkedHashMap<>();
for (var row : rows) {
BigDecimal weight = row.inboundId() == inbound.getId() ? currentWeight : row.actualWeightKg();
BigDecimal volume = row.inboundId() == inbound.getId() ? currentVolume : row.actualVolumeCbm();
BigDecimal quantity = chargeableWeight(weight, volume);
unique.putIfAbsent(row.inboundId(), new ContainerTierAmountAllocator.Member(row.inboundId(), quantity));
}
if (!unique.containsKey(inbound.getId()) || unique.size() < 2) return individual;
BigDecimal aggregate = unique.values().stream().map(ContainerTierAmountAllocator.Member::chargeableQuantity)
.reduce(BigDecimal.ZERO, BigDecimal::add);
ActivePriceBookEstimator.Estimate group = priceBookEstimator.estimate(inbound.getTenantId(), new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), "KG", aggregate, BigDecimal.ZERO));
var allocation = containerTierAllocator.allocate(List.copyOf(unique.values()), group.unitPrice());
BigDecimal memberQuantity = unique.get(inbound.getId()).chargeableQuantity();
return new ActivePriceBookEstimator.Estimate(group.priceBookVersionId(), group.channelCode(),
group.priceModel(), group.pricingTaxMode(), group.rate(), memberQuantity, group.chargeUnit(),
group.unitPrice(), allocation.memberAmounts().get(inbound.getId()), group.currency(),
group.conversionFactor(), group.roundingScale(), group.roundingMode());
}
private static BigDecimal chargeableWeight(BigDecimal weight, BigDecimal volume) {
BigDecimal safeWeight = weight == null ? BigDecimal.ZERO : weight;
BigDecimal safeVolume = volume == null ? BigDecimal.ZERO : volume;
return safeWeight.max(safeVolume.multiply(BigDecimal.valueOf(167)))
.setScale(6, RoundingMode.HALF_UP);
}
/**
* 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。
* 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。
*/
@Transactional
public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees(
long containerBookingId, long tenantId, String trigger) {
if (containerBookingId <= 0 || tenantId <= 0 || !StringUtils.hasText(trigger)) {
throw new BizException(ResultCode.BAD_REQUEST, "柜次、客户和重算触发标识必填");
}
String normalizedTrigger = trigger.trim();
String key = "CONTAINER:" + containerBookingId + ":" + normalizedTrigger;
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "重算触发标识不能超过96字符");
String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, normalizedTrigger);
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId,
AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key);
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次");
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中");
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
}
Map<Long, ContainerTierPricingMapper.MemberRow> rows = new LinkedHashMap<>();
for (var row : repository.containerTierMembersForUpdate(containerBookingId, tenantId)) {
rows.putIfAbsent(row.inboundId(), row);
}
LocalDateTime now = LocalDateTime.now(clock);
if (rows.isEmpty()) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
}
List<ContainerFeeMember> members = new ArrayList<>();
boolean notReady = false;
for (var row : rows.values()) {
InboundPricingEntity inbound = repository.findInbound(tenantId, row.inboundId());
if (inbound == null) throw conflict("柜内入库单已变化,请刷新后重试");
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, row.inboundId());
if (current == null) {
notReady = true;
continue;
}
if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) {
throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用");
}
if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) {
throw conflict("柜内费用状态已变化,不能重算阶梯费用");
}
BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量");
BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积");
members.add(new ContainerFeeMember(inbound, current,
weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP),
chargeableWeight(weight, volume)));
}
if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
InboundPricingIdempotencyEntity token = beginContainerRecalculation(
tenantId, containerBookingId, key, normalizedTrigger);
if ("COMPLETED".equals(token.getStatus())) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
}
BigDecimal aggregate = members.stream().map(ContainerFeeMember::chargeableQuantity)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<ContainerTierAmountAllocator.Member> allocationMembers = members.stream()
.map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity()))
.toList();
List<ContainerFeeQuote> quotes = new ArrayList<>();
for (ContainerFeeMember member : members) {
PricingPolicy policy = pricingPolicy(member.inbound(), now);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(),
destinationType(member.inbound()), member.inbound().getDestinationWarehouseId(),
member.inbound().getDestinationName(), null, pricingTaxMode(member.inbound()), policy.chargeMode(),
aggregate, BigDecimal.ZERO));
quotes.add(new ContainerFeeQuote(member, policy, estimate));
}
boolean regionalTier = quotes.stream().anyMatch(quote -> quote.estimate().priceModel() == PriceModel.REGION_WEIGHT_TIER);
if (!regionalTier) {
complete(token, containerBookingId, now);
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
}
if (quotes.stream().anyMatch(quote -> quote.estimate().priceModel() != PriceModel.REGION_WEIGHT_TIER)) {
throw conflict("柜内客户费用报价模型不一致,不能重算阶梯费用");
}
for (ContainerFeeQuote quote : quotes) {
ContainerTierAmountAllocator.Allocation allocation = containerTierAllocator.allocate(
allocationMembers, quote.estimate().unitPrice());
BigDecimal subtotal = allocation.memberAmounts().get(quote.member().inbound().getId());
if (subtotal == null) throw conflict("柜级阶梯分摊结果不完整");
persistContainerRecalculation(tenantId, quote, subtotal, now);
}
complete(token, containerBookingId, now);
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.RECALCULATED, quotes.size());
}
private void persistContainerRecalculation(long tenantId, ContainerFeeQuote quote,
BigDecimal subtotal, LocalDateTime now) {
ContainerFeeMember member = quote.member();
ActivePriceBookEstimator.Estimate estimate = quote.estimate();
RateRow rate = estimate.rate();
int version = repository.nextFeeVersion(tenantId, member.inbound().getId());
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
InboundFeeEntity recalculated = snapshot(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, member.inbound(), estimate,
rate, version, member.weight(), member.volume(), member.chargeableQuantity(),
member.chargeableQuantity(), subtotal.setScale(MONEY_SCALE, RoundingMode.HALF_UP), additional,
subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP), now);
PricingPolicy policy = quote.policy();
if (policy.rule() != null) {
recalculated.setChargeRuleId(policy.rule().getId());
recalculated.setChargeRuleVersion(policy.rule().getRuleVersion());
recalculated.setRuleCode(policy.rule().getRuleCode());
recalculated.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
recalculated.setRuleEffectiveTo(policy.rule().getEffectiveTo());
}
recalculated.setMeasurementId(member.current().getMeasurementId());
recalculated.setMeasurementSource(member.current().getMeasurementSource());
if (repository.supersedeCurrent(tenantId, member.current().getId(), member.current().getFeeStatus()) != 1) {
throw conflict("柜内费用已变化,不能重算阶梯费用");
}
repository.insertFee(recalculated);
repository.insertItem(baseItem(tenantId, recalculated, rate, member.chargeableQuantity(),
recalculated.getSubtotalAmount(), now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
}
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
AUTOMATIC_CONFIRM_OPERATOR_ID, now);
}
private InboundPricingIdempotencyEntity beginContainerRecalculation(long tenantId, long containerBookingId,
String key, String trigger) {
String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, trigger);
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId,
AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key);
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次");
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中");
return existing;
}
InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity();
token.setTenantId(tenantId); token.setOperation(CONTAINER_RECALCULATE_OPERATION);
token.setOperatorId(AUTOMATIC_CONFIRM_OPERATOR_ID); token.setInboundId(0L);
token.setIdempotencyKey(key); token.setRequestHash(requestHash); token.setStatus("PROCESSING");
token.setCreatedAt(LocalDateTime.now(clock)); token.setUpdatedAt(LocalDateTime.now(clock));
repository.insertIdempotency(token);
return token;
}
private static BigDecimal requiredFrozenQuantity(BigDecimal value, String field) {
if (value == null || value.signum() < 0) throw conflict("费用快照缺少冻结" + field + ",不能重算阶梯费用");
return value;
}
private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current,
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { }
private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy,
ActivePriceBookEstimator.Estimate estimate) { }
public enum ContainerFeeRecalculationStatus { RECALCULATED, NOT_READY, NOT_APPLICABLE, REPLAYED }
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
int recalculatedCount) { }
@Transactional @Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) { String idempotencyKey) {
@@ -283,7 +508,8 @@ public class FbaPricingService {
} }
String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":" String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":"
+ request.chargeableQuantity() + ":" + request.unitPrice() + ":" + request.chargeableQuantity() + ":" + request.unitPrice() + ":"
+ request.additionalAmount() + ":" + request.reason().trim(); + request.additionalAmount() + ":" + request.reason().trim() + ":"
+ request.toleranceConfirmed();
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
if ("COMPLETED".equals(token.getStatus())) { if ("COMPLETED".equals(token.getStatus())) {
@@ -297,6 +523,17 @@ public class FbaPricingService {
adjusted.setId(null); adjusted.setId(null);
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion); adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
adjusted.setFeeVersion(nextVersion); adjusted.setFeeVersion(nextVersion);
BigDecimal baseline = baseline(current);
ToleranceRange range = toleranceRange(baseline);
if (request.chargeableQuantity().compareTo(range.lower()) < 0
|| request.chargeableQuantity().compareTo(range.upper()) > 0) {
throw conflict("调整计费量必须在系统原计费量±10%范围内(含边界)");
}
if (!Boolean.TRUE.equals(request.toleranceConfirmed())) {
throw conflict("请二次确认计费量调整范围后再提交");
}
adjusted.setBaselineChargeableQuantity(baseline);
adjusted.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP)); adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP));
adjusted.setConvertedQuantity(adjusted.getChargeableQuantity()); adjusted.setConvertedQuantity(adjusted.getChargeableQuantity());
adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice()) adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice())
@@ -401,6 +638,27 @@ public class FbaPricingService {
} }
} }
private PricingPolicy pricingPolicy(InboundPricingEntity inbound, LocalDateTime at) {
PricingTaxMode taxMode = pricingTaxMode(inbound);
if (taxMode == PricingTaxMode.TAX_INCLUDED) return new PricingPolicy("KG", null);
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> rules = repository.matchRules(inbound, at);
if (rules == null || rules.isEmpty()) {
throw conflict("客户未配置当前不包税报价的计费方式");
}
List<com.baiyue.firstleg.entity.CustomerChargeRuleEntity> defaults = rules.stream()
.filter(rule -> Boolean.TRUE.equals(rule.getDefaultRule())).toList();
com.baiyue.firstleg.entity.CustomerChargeRuleEntity selected = defaults.size() == 1
? defaults.get(0) : rules.size() == 1 ? rules.get(0) : null;
if (selected == null) throw conflict("客户当前不包税计费规则匹配不唯一");
if (!List.of("KG", "CBM").contains(selected.getChargeMode())) {
throw conflict("客户计费方式配置无效");
}
return new PricingPolicy(selected.getChargeMode(), selected);
}
private record PricingPolicy(String chargeMode,
com.baiyue.firstleg.entity.CustomerChargeRuleEntity rule) { }
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId, private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
long inboundId, String operation, long inboundId, String operation,
String key, String hash) { String key, String hash) {
@@ -456,6 +714,8 @@ public class FbaPricingService {
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo()); fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor()); fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor());
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted); fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setBaselineChargeableQuantity(chargeable);
fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency()); fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total); fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
@@ -497,6 +757,8 @@ public class FbaPricingService {
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee, private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
BigDecimal unitPrice, String nextStatus) { BigDecimal unitPrice, String nextStatus) {
BigDecimal baseline = baseline(fee);
ToleranceRange range = toleranceRange(baseline);
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(), return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(), fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
fee.getMeasurementId(), fee.getMeasurementSource(), fee.getMeasurementId(), fee.getMeasurementSource(),
@@ -508,7 +770,9 @@ public class FbaPricingService {
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(), fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
fee.getConversionFactor(), fee.getConversionFactor(),
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(), fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice, fee.getConvertedQuantity(), baseline, range.lower(), range.upper(),
fee.getTolerancePercent() == null ? ADJUST_TOLERANCE_PERCENT : fee.getTolerancePercent(),
fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(), fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(), fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
fee.getSentAt(), nextStatus, fee.getSentAt(), nextStatus,
@@ -516,6 +780,22 @@ public class FbaPricingService {
fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt()); fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt());
} }
private static BigDecimal baseline(InboundFeeEntity fee) {
BigDecimal value = fee.getBaselineChargeableQuantity();
if (value == null) value = fee.getChargeableQuantity();
if (value == null || value.signum() <= 0) throw conflict("系统原计费量必须大于零");
return value.setScale(6, RoundingMode.HALF_UP);
}
private static ToleranceRange toleranceRange(BigDecimal baseline) {
BigDecimal delta = baseline.multiply(ADJUST_TOLERANCE_PERCENT)
.divide(ONE_HUNDRED, 6, RoundingMode.HALF_UP);
return new ToleranceRange(baseline.subtract(delta).setScale(6, RoundingMode.HALF_UP),
baseline.add(delta).setScale(6, RoundingMode.HALF_UP));
}
private record ToleranceRange(BigDecimal lower, BigDecimal upper) { }
private static String hash(String operation, long inboundId, String discriminator) { private static String hash(String operation, long inboundId, String discriminator) {
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PrintableOrderInboundClient;
import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity; import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
@@ -35,6 +36,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
@Resource private InboundCartonLabelRepository repository; @Resource private InboundCartonLabelRepository repository;
@Resource private OrderCargoClient orderCargoClient; @Resource private OrderCargoClient orderCargoClient;
@Resource private PrintableOrderInboundClient printableOrderInboundClient;
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
@@ -54,6 +56,10 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
&& cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) { && cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛"); throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛");
} }
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& cargoes.stream().anyMatch(cargo -> cargo.unitsPerCarton() == null || cargo.unitsPerCarton() < 1)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少每箱SKU数量,不能生成箱唛");
}
existing.forEach(label -> cargoes.stream() existing.forEach(label -> cargoes.stream()
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId())) .filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId, .findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
@@ -101,7 +107,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) { private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) {
try { try {
ApiResponse<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> response = ApiResponse<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> response =
orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId); printableOrderInboundClient.reserve(internalToken, tenantId, orderId);
if (response == null || !response.successful() || response.data() == null) { if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单"); throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单");
} }
@@ -211,6 +217,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
label.setCargoItemNo(cargo.itemNo()); label.setCargoItemNo(cargo.itemNo());
label.setCargoName(cargo.cargoName()); label.setCargoName(cargo.cargoName());
label.setSkuCode(cargo.skuNo()); label.setSkuCode(cargo.skuNo());
label.setUnitsPerCarton(cargo.unitsPerCarton());
label.setDetailPackageCount(cargo.packageCount()); label.setDetailPackageCount(cargo.packageCount());
label.setInboundCode(inbound.getInboundCode()); label.setInboundCode(inbound.getInboundCode());
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
@@ -267,6 +274,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()), String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()), cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
String.valueOf(cargo.unitsPerCarton()),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm()))) decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n")); .collect(java.util.stream.Collectors.joining("\n"));
try { try {
@@ -23,6 +23,7 @@ import java.util.List;
@Service @Service
public class InboundFeeDetailService { public class InboundFeeDetailService {
private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT"; private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT";
private static final String PRIVATE_LAST_MILE_DELIVERY_FEE_CODE = "PRIVATE_LAST_MILE_DELIVERY";
@Resource @Resource
private FeeItemRepository repository; private FeeItemRepository repository;
@Resource @Resource
@@ -379,6 +380,7 @@ public class InboundFeeDetailService {
private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound, private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) { FeeItemEntity item, SaveRequest request, long operatorId) {
ensureDestinationApplicable(item, inbound);
ensureWarehouseApplicable(item, request.direction()); ensureWarehouseApplicable(item, request.direction());
value.setFeeItemId(item.getId()); value.setFeeItemId(item.getId());
value.setInboundCode(inbound.getInboundCode()); value.setInboundCode(inbound.getInboundCode());
@@ -408,6 +410,13 @@ public class InboundFeeDetailService {
value.setUpdatedBy(operatorId); value.setUpdatedBy(operatorId);
} }
private static void ensureDestinationApplicable(FeeItemEntity item, InboundOrderEntity inbound) {
if (PRIVATE_LAST_MILE_DELIVERY_FEE_CODE.equals(item.getFeeCode())
&& !"PRIVATE".equals(inbound.getDestinationType())) {
throw conflict("私人仓尾程派送费只能绑定目的地类型为PRIVATE的入库单");
}
}
private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item, private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) { String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) { if (!"PAYABLE".equals(direction)) {
@@ -48,6 +48,8 @@ public class InboundMeasurementService {
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM"; private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
/** OCR 结果无效或不完整,必须人工录入或修正。 */ /** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of( private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
@@ -100,7 +102,7 @@ public class InboundMeasurementService {
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId, com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId,
java.util.function.Function.identity(), (left, right) -> left)); java.util.function.Function.identity(), (left, right) -> left));
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId()); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight, return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
review, latestDeclaration); review, latestDeclaration);
} }
@@ -122,6 +124,10 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) { InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!MEASUREMENT_DRAFT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT,
"测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交");
}
validateInboundDetails(tenantId, inbound, request.items()); validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
@@ -468,7 +474,8 @@ public class InboundMeasurementService {
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots, List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight, Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
MeasureReviewRecordEntity review, MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) { LatestDeclaration latestDeclaration) {
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
return new InboundMeasurementResponses.Detail( return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
@@ -480,21 +487,22 @@ public class InboundMeasurementService {
inbound.getVersion(), inbound.getVersion(),
measurement == null ? null : measurement.getVersion(), measurement == null ? null : measurement.getVersion(),
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(), ? measurement.getOriginalDocumentNo() : declaration.referenceId(),
measurement != null && measurement.getOriginalGrossWeightKg() != null measurement != null && measurement.getOriginalGrossWeightKg() != null
? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(), ? measurement.getOriginalGrossWeightKg() : declaration.grossWeightKg(),
measurement != null && measurement.getOriginalVolumeCbm() != null measurement != null && measurement.getOriginalVolumeCbm() != null
? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(), ? measurement.getOriginalVolumeCbm() : declaration.volumeCbm(),
latestDeclaration.totalPackageCount(), declaration.totalPackageCount(),
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
inbound.getActualGrossWeightKg(), inbound.getActualGrossWeightKg(),
inbound.getActualNetWeightKg(), inbound.getActualNetWeightKg(),
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) measurement != null && StringUtils.hasText(measurement.getActualGoodsName())
? measurement.getActualGoodsName() : latestDeclaration.goodsName(), ? measurement.getActualGoodsName() : declaration.goodsName(),
latestDeclaration, declaration,
items.stream().map(this::toItem).toList(), weights.stream() items.stream().map(item -> toItem(item, inbound.getInboundCode(), item.getInboundDetailId() == null
? null : latestDeclaration.fbaShipmentNos().get(item.getInboundDetailId()))).toList(), weights.stream()
.map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(), .map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(),
toPalletLocationGuide(allocationSlots)); toPalletLocationGuide(allocationSlots));
} }
@@ -526,10 +534,14 @@ public class InboundMeasurementService {
* Latest customer declaration is a live, read-only Order view. It must never be persisted over * Latest customer declaration is a live, read-only Order view. It must never be persisted over
* the measurement batch, carton-label mapping, weight results or review decision. * the measurement batch, carton-label mapping, weight results or review decision.
*/ */
private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration( private LatestDeclaration loadLatestDeclaration(
InboundOrderEntity inbound) { InboundOrderEntity inbound) {
OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound); OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound);
List<OrderCargoClient.CargoLabelBinding> cargoes = loadDeclarationCargoes(inbound); List<OrderCargoClient.CargoLabelBinding> cargoes = loadDeclarationCargoes(inbound);
if (cargoes.stream().anyMatch(cargo -> cargo == null || cargo.cargoId() == null
|| cargo.cargoId() <= 0 || !java.util.Objects.equals(cargo.orderId(), inbound.getOrderId()))) {
throw new BizException(ResultCode.CONFLICT, "订单服务返回的货物明细归属不一致");
}
long packages = cargoes.stream() long packages = cargoes.stream()
.map(OrderCargoClient.CargoLabelBinding::packageCount) .map(OrderCargoClient.CargoLabelBinding::packageCount)
.filter(java.util.Objects::nonNull) .filter(java.util.Objects::nonNull)
@@ -538,7 +550,7 @@ public class InboundMeasurementService {
if (packages > Integer.MAX_VALUE) { if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限"); throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限");
} }
return new InboundMeasurementResponses.DeclarationSnapshot( InboundMeasurementResponses.DeclarationSnapshot snapshot = new InboundMeasurementResponses.DeclarationSnapshot(
aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(), aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(),
aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(), aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(),
cargoes.stream() cargoes.stream()
@@ -550,6 +562,19 @@ public class InboundMeasurementService {
cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(), cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(),
cargo.lengthCm(), cargo.widthCm(), cargo.heightCm())) cargo.lengthCm(), cargo.widthCm(), cargo.heightCm()))
.toList()); .toList());
Map<Long, String> fbaShipmentNos = cargoes.stream()
.filter(cargo -> cargo.cargoId() != null && StringUtils.hasText(cargo.shipmentId()))
.collect(java.util.stream.Collectors.toMap(
OrderCargoClient.CargoLabelBinding::cargoId,
cargo -> cargo.shipmentId().trim(),
(left, right) -> {
if (!java.util.Objects.equals(left, right)) {
throw new BizException(ResultCode.CONFLICT,
"订单同一货物明细返回多个FBA货件编号");
}
return left;
}, java.util.LinkedHashMap::new));
return new LatestDeclaration(snapshot, Map.copyOf(fbaShipmentNos));
} }
private List<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) { private List<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) {
@@ -601,9 +626,11 @@ public class InboundMeasurementService {
return value; return value;
} }
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item,
String inboundCode,
String fbaShipmentNo) {
return new InboundMeasurementResponses.Item( return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getId(), inboundCode, item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitVolumeCbm(), item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
@@ -612,11 +639,17 @@ public class InboundMeasurementService {
item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(), item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(),
item.getCalculationRuleVersion(), item.getCalculationRuleVersion(),
item.getChargeRuleId(), item.getCoefficientMode(), item.getChargeRuleId(), item.getCoefficientMode(),
item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), item.getRemark(), item.getBoxCode(), fbaShipmentNo, item.getScannedShipmentId(),
item.getPackageCount(), item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
} }
private record LatestDeclaration(
InboundMeasurementResponses.DeclarationSnapshot snapshot,
Map<Long, String> fbaShipmentNos
) {
}
private void requireConfirmedPackageTotal(InboundOrderEntity inbound, private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
List<InboundMeasurementItemEntity> items) { List<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
@@ -56,10 +56,8 @@ public class LoadingListService {
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
public static final String PICKUP_ORDER = "PICKUP_ORDER";
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource @Resource
private LoadingListRepository repository; private LoadingListRepository repository;
@Resource @Resource
@@ -74,6 +72,8 @@ public class LoadingListService {
@Resource @Resource
private LoadingCompletedOutboxService loadingCompletedOutboxService; private LoadingCompletedOutboxService loadingCompletedOutboxService;
@Resource @Resource
private FbaPricingService fbaPricingService;
@Resource
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Resource @Resource
private LoadingDocumentFileClient documentFiles; private LoadingDocumentFileClient documentFiles;
@@ -188,8 +188,8 @@ public class LoadingListService {
} }
} }
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); } private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; } private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
@Transactional @Transactional
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER", @BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
@@ -501,6 +501,7 @@ public class LoadingListService {
AggregateRow before = requirePlan(planId); AggregateRow before = requirePlan(planId);
List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId); List<Link> links = loadingPickService.requirePreloadConfirmationReady(planId);
requireContainerFeesReadyForPreloadConfirmation(links, operationId);
if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(),
operatorId, trim(operatorName)) != 1) { operatorId, trim(operatorName)) != 1) {
throw conflict("预装计划已被其他操作修改,请刷新后重试"); throw conflict("预装计划已被其他操作修改,请刷新后重试");
@@ -610,6 +611,30 @@ public class LoadingListService {
} }
} }
/**
* Pre-review/pre-measurement members may be reserved in an editing plan. Confirmation is the
* hard boundary: every customer group in the cabinet must have a ready, freshly recomputed fee.
*/
private void requireContainerFeesReadyForPreloadConfirmation(List<Link> links, String operationId) {
Map<Long, Set<Long>> tenantsByContainer = new LinkedHashMap<>();
for (Link link : links) {
if (link.containerBookingId() == null || link.tenantId() == null) {
throw conflict("预装关联缺少柜次或客户归属,不能确认预装");
}
tenantsByContainer.computeIfAbsent(link.containerBookingId(), ignored -> new java.util.LinkedHashSet<>())
.add(link.tenantId());
}
for (Map.Entry<Long, Set<Long>> entry : tenantsByContainer.entrySet()) {
for (Long tenantId : entry.getValue()) {
var result = fbaPricingService.recalculateUnlockedContainerCustomerFees(entry.getKey(), tenantId,
sha256("CONFIRM_PRELOAD_FEE|" + operationId + '|' + entry.getKey() + '|' + tenantId));
if (result.status() == FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY) {
throw conflict("柜内存在尚未完成初始费用的入仓单,不能确认预装");
}
}
}
}
private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) {
if (from != null && to != null && from.isAfter(to)) { if (from != null && to != null && from.isAfter(to)) {
throw badRequest("创建开始时间不能晚于结束时间"); throw badRequest("创建开始时间不能晚于结束时间");
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
@@ -106,6 +107,8 @@ public class LoadingPickService {
private InboundPalletAllocationRepository palletAllocations; private InboundPalletAllocationRepository palletAllocations;
@Resource @Resource
private InboundHoldGuard inboundHoldGuard; private InboundHoldGuard inboundHoldGuard;
@Resource
private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPalletizationMapper;
/** Opens the field task after planning and ocean release; the real container number may be filled later. */ /** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional @Transactional
@@ -326,6 +329,7 @@ public class LoadingPickService {
if (item == null) { if (item == null) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在");
} }
requirePackageUnit(item);
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
throw conflict("拣货数量超过计划箱数或明细版本已变化"); throw conflict("拣货数量超过计划箱数或明细版本已变化");
} }
@@ -344,6 +348,41 @@ public class LoadingPickService {
return view(requiredTask(taskId), request.warehouseId()); return view(requiredTask(taskId), request.warehouseId());
} }
/** 一件代发只扫描已封托托盘码,并在同一事务把该托全部计划行结算为已拣。 */
@Transactional
public Task pickPallet(long taskId,PalletPickRequest request,long operatorId,String operatorName){
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId=operation(request.operationId());
String requestHash=hash("PICK_PALLET|"+taskId+'|'+request.warehouseId()+'|'+request.palletNo()
+'|'+request.expectedTaskVersion());
Task replay=replayTaskOperation(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay;
LoadingPickTaskEntity task=requiredAssignedLoadingTaskForUpdate(taskId,operatorId);
replay=replayTaskOperationForUpdate(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay;
if(!Objects.equals(task.getVersion(),request.expectedTaskVersion()))throw conflict("PDA任务版本已变化,请刷新后重试");
List<LoadingPlanItemEntity> planItems=smartLoadingRepository.items(task.getLoadingPlanId()).stream()
.filter(item->request.palletNo().equals(item.getPalletNo()) && item.getPalletId()!=null
&& Boolean.TRUE.equals(item.getIndivisible())).toList();
if(planItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"托盘不属于当前装柜任务");
Long palletId=planItems.get(0).getPalletId();
if(planItems.stream().anyMatch(item->!Objects.equals(item.getPalletId(),palletId)))throw conflict("托盘装柜快照不一致");
var physical=dropshipPalletizationMapper.sealedPalletLines(palletId);
if(physical.isEmpty()||physical.stream().anyMatch(line->!"SEALED".equals(line.getPalletStatus())))throw conflict("托盘已失效或尚未封托");
for(LoadingPlanItemEntity planItem:planItems){
LoadingPickItemEntity pickItem=repository.itemByPlanItem(planItem.getId());
if(pickItem==null||!"PENDING".equals(pickItem.getItemStatus())||count(pickItem.getPickedPackageCount())!=0)
throw conflict("托盘已有部分作业或明细已变化,禁止拆托处理");
if(repository.pick(pickItem.getId(),pickItem.getPlannedPackageCount(),pickItem.getVersion(),request.warehouseId())!=1)
throw conflict("托盘明细版本已变化,请刷新后重试");
insertHistory(taskId,pickItem.getId(),"PAL-"+hash(operationId+'|'+pickItem.getId()).substring(0,48),
requestHash,"PICK_PALLET",pickItem.getPlannedPackageCount(),null,operatorId,operatorName);
}
startPickingIfNeeded(task,operatorId,operatorName);
if(repository.touchTask(taskId,request.expectedTaskVersion())!=1)throw conflict("PDA任务版本已变化,请刷新后重试");
insertOperation(taskId,operationId,requestHash,"PICK_PALLET",operatorId,operatorName);
settleAndFinish(task,operatorId,operatorName); repository.syncInboundProjections(taskId,operatorId,operatorName);
return view(requiredTask(taskId),request.warehouseId());
}
@Transactional @Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request, public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
@@ -364,7 +403,8 @@ public class LoadingPickService {
assertReplay(replay, taskId, itemId, requestHash); assertReplay(replay, taskId, itemId, requestHash);
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
requireWarehouseItem(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) { request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
@@ -397,6 +437,7 @@ public class LoadingPickService {
return view(task, request.warehouseId()); return view(task, request.warehouseId());
} }
LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId());
requirePackageUnit(item);
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
} }
@@ -443,6 +484,7 @@ public class LoadingPickService {
List<BatchLine> lines = validateBatchLines(task, request); List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines); validateInventorySnapshot(task, request, lines);
lines.forEach(line -> requirePackageUnit(line.item()));
for (BatchLine line : lines) { for (BatchLine line : lines) {
applyBatchLine(taskId, line, operationId, operatorId, operatorName); applyBatchLine(taskId, line, operationId, operatorId, operatorName);
} }
@@ -1213,6 +1255,16 @@ public class LoadingPickService {
/** /**
* 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。 * 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。
*/ */
private void requirePackageUnit(LoadingPickItemEntity item) {
if (item.getLoadingPlanItemId() == null) return;
LoadingPlanItemEntity planItem = smartLoadingRepository.item(
requiredTask(item.getPickTaskId()).getLoadingPlanId(), item.getLoadingPlanItemId());
if (planItem != null && (Boolean.TRUE.equals(planItem.getIndivisible())
|| planItem.getPalletId() != null)) {
throw conflict("一件代发已封托货物必须扫描托盘码整体拣货,不能按箱数处理");
}
}
private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) { private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
if (repository.touchTask(task.getId(), task.getVersion()) != 1) { if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化"); throw conflict("PDA任务版本已变化");
@@ -1,210 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Carton;
import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet;
import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest;
import com.baiyue.firstleg.dto.LoadingPalletDtos.SkuSummary;
import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity;
import com.baiyue.firstleg.entity.LoadingWorkPalletEntity;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper;
import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 一件代发现场打托旁路;不改变既有拣货数量和仓库资源结算。 */
@Service
public class LoadingWorkPalletService {
@Resource private LoadingWorkPalletMapper pallets;
@Resource private LoadingWorkPalletCartonMapper cartons;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional
public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId);
String code = request.palletCode().trim();
String operationId = request.operationId().trim();
LoadingWorkPalletEntity replay = pallets.selectByOpenOperation(operationId);
if (replay != null) {
if (replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) {
throw conflict("开托幂等号已用于其他请求");
}
return view(replay);
}
LoadingWorkPalletEntity value = new LoadingWorkPalletEntity();
value.setPickTaskId(taskId);
value.setLoadingPlanId(context.loadingPlanId());
value.setWarehouseId(request.warehouseId());
value.setWarehousePalletId(request.warehousePalletId());
value.setPalletCode(code);
value.setOpenOperationId(operationId);
value.setPalletStatus("OPEN");
value.setCreatedBy(operatorId);
value.setCreatedByName(operatorName);
value.setVersion(0);
try {
pallets.insert(value);
} catch (DuplicateKeyException exception) {
replay = pallets.selectByOpenOperation(operationId);
if (replay == null || replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) {
throw conflict("托盘号已存在或开托操作重复");
}
value = replay;
}
return view(value);
}
@Transactional
public Pallet scan(long taskId, long palletId, ScanRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId());
String operationId = request.operationId().trim();
LoadingWorkPalletCartonEntity replay = cartons.selectByOperation(operationId);
if (replay != null) {
if (replay.getPickTaskId() != taskId || replay.getWorkPalletId() != palletId
|| !replay.getBoxCode().equals(request.boxCode().trim())) {
throw conflict("箱唛操作幂等号已用于其他请求");
}
return view(target);
}
List<CartonCandidate> candidates = cartons.selectCandidates(taskId, request.warehouseId(),
request.boxCode().trim());
if (candidates.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前任务的一件代发货物");
if (candidates.size() > 1) throw conflict("箱唛在当前多客户任务中不唯一,请检查箱唛数据");
CartonCandidate candidate = candidates.get(0);
if (candidate.getSkuCode() == null || candidate.getSkuCode().isBlank()) {
throw conflict("一件代发箱唛缺少SKU,不能上托");
}
LoadingWorkPalletCartonEntity current = cartons.selectBinding(taskId, candidate.getCartonLabelId());
if (current == null) {
current = binding(taskId, palletId, candidate, operationId, operatorId, operatorName);
try {
cartons.insert(current);
} catch (DuplicateKeyException exception) {
LoadingWorkPalletCartonEntity concurrent = cartons.selectBinding(taskId, candidate.getCartonLabelId());
if (concurrent == null || concurrent.getWorkPalletId() != palletId) {
throw conflict("箱唛已被其他托盘绑定");
}
}
return view(target);
}
if (current.getWorkPalletId() == palletId) return view(target);
LoadingWorkPalletEntity source = pallets.selectScoped(taskId, current.getWorkPalletId(), request.warehouseId());
if (source == null || !"OPEN".equals(source.getPalletStatus())) {
throw conflict("来源托盘已封托,不能直接移箱");
}
if (cartons.move(current.getId(), current.getVersion(), palletId, operatorId, operatorName, operationId) != 1) {
throw conflict("箱唛绑定已变化,请刷新后重试");
}
return view(target);
}
@Transactional
public Pallet seal(long taskId, long palletId, SealRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim());
if (replay != null) {
if (replay.getId() != palletId || replay.getPickTaskId() != taskId) {
throw conflict("封托幂等号已用于其他请求");
}
return view(replay);
}
requireOpenPallet(taskId, palletId, request.warehouseId());
if (cartons.countPalletCartons(palletId) == 0) throw conflict("空托盘不能封托");
if (cartons.countUnreconciled(taskId) > 0) {
throw conflict("现场上托箱数超过已提交拣货箱数,请先完成原PDA拣货提交");
}
if (pallets.seal(taskId, palletId, request.warehouseId(), request.expectedVersion(),
request.operationId().trim(), operatorId, operatorName) != 1) {
throw conflict("托盘状态或版本已变化,请刷新后重试");
}
return view(pallets.selectScoped(taskId, palletId, request.warehouseId()));
}
@Transactional(readOnly = true)
public List<Pallet> list(long taskId, long warehouseId) {
TaskContext context = pallets.selectTaskContext(taskId, warehouseId);
if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务");
return pallets.selectTaskPallets(taskId, warehouseId).stream().map(this::view).toList();
}
private TaskContext requireWorkTask(long taskId, long warehouseId, long operatorId) {
TaskContext context = pallets.selectTaskContext(taskId, warehouseId);
if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务");
if (!"LOADING".equals(context.taskStatus())) throw conflict("PDA任务已完成,不能继续打托");
if (context.dropshipItemCount() == null || context.dropshipItemCount() == 0) {
throw conflict("当前任务没有一件代发货物");
}
if (context.assigneeId() == null || context.assigneeId() != operatorId) {
throw conflict("请先领取PDA任务后再执行打托");
}
return context;
}
private LoadingWorkPalletEntity requireOpenPallet(long taskId, long palletId, long warehouseId) {
LoadingWorkPalletEntity pallet = pallets.selectScoped(taskId, palletId, warehouseId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "现场托盘不存在");
if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘不是打托中状态");
return pallet;
}
private static LoadingWorkPalletCartonEntity binding(long taskId, long palletId,
CartonCandidate source, String operationId,
long operatorId, String operatorName) {
LoadingWorkPalletCartonEntity value = new LoadingWorkPalletCartonEntity();
value.setPickTaskId(taskId);
value.setWorkPalletId(palletId);
value.setLoadingPlanItemId(source.getLoadingPlanItemId());
value.setPickItemId(source.getPickItemId());
value.setTenantId(source.getTenantId());
value.setInboundId(source.getInboundId());
value.setInboundDetailId(source.getInboundDetailId());
value.setCartonLabelId(source.getCartonLabelId());
value.setBoxCode(source.getBoxCode());
value.setSkuCode(source.getSkuCode());
value.setUnitsPerCarton(source.getUnitsPerCarton());
value.setBoundBy(operatorId);
value.setBoundByName(operatorName);
value.setOperationId(operationId);
value.setVersion(0);
return value;
}
private Pallet view(LoadingWorkPalletEntity pallet) {
List<LoadingWorkPalletCartonEntity> values = cartons.selectPalletCartons(pallet.getId());
Map<String, List<LoadingWorkPalletCartonEntity>> bySku = values.stream().collect(
java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<SkuSummary> skus = bySku.entrySet().stream().map(entry -> new SkuSummary(entry.getKey(),
entry.getValue().size(), entry.getValue().stream().map(LoadingWorkPalletCartonEntity::getUnitsPerCarton)
.filter(Objects::nonNull).mapToLong(Integer::longValue).sum())).toList();
List<Carton> cartonViews = values.stream().map(value -> new Carton(value.getId(), value.getBoxCode(),
value.getSkuCode(), value.getTenantId(), value.getInboundId(), value.getInboundDetailId(),
value.getUnitsPerCarton())).toList();
return new Pallet(pallet.getId(), pallet.getPalletCode(), pallet.getWarehousePalletId(),
pallet.getPalletStatus(), pallet.getVersion(), values.size(), bySku.size(),
(int) values.stream().map(LoadingWorkPalletCartonEntity::getTenantId).distinct().count(),
skus, cartonViews);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -42,6 +42,8 @@ public class MeasurementBatchService {
static final String STATUS_DRAFT = "DRAFT"; static final String STATUS_DRAFT = "DRAFT";
/** 已接收第三方测量数据,待平台提交审核。 */ /** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED"; static final String STATUS_RECEIVED = "RECEIVED";
private static final Set<String> EDITABLE_INBOUND_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
@@ -60,6 +62,10 @@ public class MeasurementBatchService {
if (inbound == null) { if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
} }
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT,
"当前入库状态不允许保存测量批次,已提交批次必须保持冻结");
}
validateSource(command); validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems( List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId())); command, dimensionWeighting.load(command.tenantId()));
@@ -71,12 +77,7 @@ public class MeasurementBatchService {
repository.insertMeasurement(measurement); repository.insertMeasurement(measurement);
} else { } else {
measurement = requireEditableBatch(command); measurement = requireEditableBatch(command);
// 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料, measurement.setStatus(STATUS_DRAFT);
// 不回退其主状态,也不把已经审核的批次重新标成待审核。
if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
measurement.setStatus(STATUS_DRAFT);
}
applyHeader(measurement, command); applyHeader(measurement, command);
applyTotals(measurement, calculatedItems); applyTotals(measurement, calculatedItems);
if (!repository.updateMeasurement(measurement)) { if (!repository.updateMeasurement(measurement)) {
@@ -23,6 +23,7 @@ import java.math.BigDecimal;
@Service @Service
public class MerchantInboundBillingService { public class MerchantInboundBillingService {
private static final Set<String> PUBLISHED_BILLING_PROCESS_STATUSES = Set.of( private static final Set<String> PUBLISHED_BILLING_PROCESS_STATUSES = Set.of(
InboundProcessStatus.WAIT_PALLETIZE,
InboundProcessStatus.WAIT_SPLIT, InboundProcessStatus.WAIT_SPLIT,
InboundProcessStatus.LOADING, InboundProcessStatus.LOADING,
InboundProcessStatus.LOADED, InboundProcessStatus.LOADED,
@@ -32,7 +32,7 @@ public class MerchantPriceBookService {
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) { public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
requireMerchant(merchantTenantId); requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId(); Long versionId = repository.effectiveVersionId(merchantTenantId);
if (versionId == null) return List.of(); if (versionId == null) return List.of();
List<ChannelRow> channels = repository.channels(versionId, originWarehouseId); List<ChannelRow> channels = repository.channels(versionId, originWarehouseId);
if (channels.isEmpty()) return List.of(); if (channels.isEmpty()) return List.of();
@@ -42,7 +42,11 @@ public class MerchantPriceBookService {
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) { public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
requireMerchant(merchantTenantId); requireMerchant(merchantTenantId);
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request); if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED
&& request.chargeMode() != null && !"KG".equals(request.chargeMode())) {
throw validation("包税报价必须按KG计费");
}
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(merchantTenantId, request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(), return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(), estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
estimate.amount(), estimate.currency(), DISCLAIMER); estimate.amount(), estimate.currency(), DISCLAIMER);
@@ -52,7 +56,7 @@ public class MerchantPriceBookService {
String destinationType, boolean requireInboundCodePrefix) { String destinationType, boolean requireInboundCodePrefix) {
requireMerchant(merchantTenantId); requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId(); Long versionId = repository.effectiveVersionId(merchantTenantId);
String normalized = normalizeChannel(channelCode); String normalized = normalizeChannel(channelCode);
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) { if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) {
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择");
@@ -117,7 +117,7 @@ public class OrderChannelChangePricingService {
request.channelCode(), request.destinationType(), false); request.channelCode(), request.destinationType(), false);
BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT); BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT);
BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME); BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME);
return estimator.estimate(new EstimateRequest(request.originWarehouseId(), request.channelCode(), return estimator.estimate(request.tenantId(), new EstimateRequest(request.originWarehouseId(), request.channelCode(),
request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()), request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()),
request.destinationWarehouseId(), inbound.getDestinationName(), null, request.destinationWarehouseId(), inbound.getDestinationName(), null,
PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume)); PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume));
@@ -63,8 +63,9 @@ public class OverseasContainerEventConsumer {
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt());
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt,
unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
row.setStatus("PROCESSED"); row.setStatus("PROCESSED");
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(row); inbox.updateById(row);
@@ -72,13 +72,26 @@ public class PlatformPriceBookService {
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) { public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) {
return list(PriceBookVersionEntity.PLATFORM_SCOPE, status);
}
public List<PriceBookDtos.PriceBookVersionSummary> list(long customerId, PriceBookStatus status) {
requireScope(customerId);
String persistedStatus = status == null ? null : status.name(); String persistedStatus = status == null ? null : status.name();
return repository.listVersions(persistedStatus).stream().map(this::summary).toList(); return (customerId == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listVersions(persistedStatus) : repository.listVersions(customerId, persistedStatus))
.stream().map(this::summary).toList();
} }
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) { public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) {
return upload(operatorId, PriceBookVersionEntity.PLATFORM_SCOPE, file);
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, long customerId, MultipartFile file) {
requireScope(customerId);
requireFile(file); requireFile(file);
PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename())); PriceBookVersionEntity version = transactions.execute(status -> createVersion(
operatorId, customerId, file.getOriginalFilename()));
if (version == null) throw internal("价格版本创建失败"); if (version == null) throw internal("价格版本创建失败");
Path temp = null; Path temp = null;
try { try {
@@ -123,7 +136,8 @@ public class PlatformPriceBookService {
List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId); List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId);
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson())); List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson()));
if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) { if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) {
PriceBookVersionEntity active = repository.findActive(); PriceBookVersionEntity active = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.findActive() : repository.findActive(version.getTenantId());
if (active != null && !Objects.equals(active.getId(), versionId)) { if (active != null && !Objects.equals(active.getId(), versionId)) {
Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet()); Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet());
repository.listChannels(active.getId()).stream() repository.listChannels(active.getId()).stream()
@@ -140,7 +154,9 @@ public class PlatformPriceBookService {
@Transactional(noRollbackFor = BizException.class) @Transactional(noRollbackFor = BizException.class)
public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId, public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId,
PriceBookConfirmationRequest confirmation) { PriceBookConfirmationRequest confirmation) {
repository.lockPlatformVersions(); PriceBookVersionEntity candidate = requireVersion(versionId);
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
else repository.lockVersions(candidate.getTenantId());
PriceBookVersionEntity version = requirePendingConfirmation(versionId); PriceBookVersionEntity version = requirePendingConfirmation(versionId);
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList(); List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList(); List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
@@ -158,7 +174,8 @@ public class PlatformPriceBookService {
version.setChannelCount(channels.size()); version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size()); version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
repository.listActiveForUpdate().stream() (version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream()
.filter(active -> !Objects.equals(active.getId(), versionId)) .filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId)); .forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId); repository.activate(version, now, operatorId);
@@ -182,10 +199,11 @@ public class PlatformPriceBookService {
} }
} }
private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) { private PriceBookVersionEntity createVersion(long operatorId, long customerId, String originalFileName) {
PriceBookVersionEntity value = new PriceBookVersionEntity(); PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE); value.setTenantId(customerId);
value.setVersionNo(repository.nextVersionNo()); value.setVersionNo(customerId == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.nextVersionNo() : repository.nextVersionNo(customerId));
value.setOriginalFileName(safeFileName(originalFileName)); value.setOriginalFileName(safeFileName(originalFileName));
value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name()); value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0); value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0);
@@ -228,7 +246,7 @@ public class PlatformPriceBookService {
private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate, private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate,
long operatorId, int number, LocalDateTime now) { long operatorId, int number, LocalDateTime now) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity(); CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId()); value.setTenantId(version.getTenantId()); value.setPriceBookVersionId(version.getId());
value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1); value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1);
value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim()); value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim());
value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA"); value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA");
@@ -540,12 +558,16 @@ public class PlatformPriceBookService {
private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) { private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) {
PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus()); PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus());
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(), return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getTenantId(), value.getVersionNo(), value.getOriginalFileName(),
value.getSourceFileId(), value.getSourceSha256(), status, value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(), value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(), value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
value.getPublishedAt(), value.getSupersededAt()); value.getPublishedAt(), value.getSupersededAt());
} }
private static void requireScope(long customerId) {
if (customerId < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "客户ID不能为负数");
}
private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) { private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) {
return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()), return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()),
value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(), value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(),
@@ -54,6 +54,7 @@ public class SmartLoadingService {
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK") "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
); );
@Resource private SmartLoadingRepository repository; @Resource private SmartLoadingRepository repository;
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient; @Resource private OrderCargoClient orderCargoClient;
@Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -62,6 +63,7 @@ public class SmartLoadingService {
@Resource private LoadingModelBridgeService loadingModelBridge; @Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService; @Resource private SplitPreloadService splitPreloadService;
@Resource private ColoadGroupAssignmentPort coloadGroups; @Resource private ColoadGroupAssignmentPort coloadGroups;
@Resource private FbaPricingService fbaPricingService;
@Resource private PriceBookQueryUseCase priceBooks; @Resource private PriceBookQueryUseCase priceBooks;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@@ -111,7 +113,7 @@ public class SmartLoadingService {
Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream() Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList())); java.util.stream.Collectors.toList()));
return grouped.values().stream().map(this::inboundCandidate).toList(); return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList();
} }
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking) { private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking) {
@@ -121,7 +123,7 @@ public class SmartLoadingService {
repository.planByBooking(booking.getId()), values).stream() repository.planByBooking(booking.getId()), values).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList())); java.util.stream.Collectors.toList()));
return grouped.values().stream().map(this::inboundCandidate).toList(); return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList();
} }
/** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */ /** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
@@ -175,7 +177,7 @@ public class SmartLoadingService {
List<InboundCandidate> all = repository.globalCandidatesByUnits(unitPage.records()).stream() List<InboundCandidate> all = repository.globalCandidatesByUnits(unitPage.records()).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList())) java.util.stream.Collectors.toList()))
.values().stream().map(this::inboundCandidate).toList(); .values().stream().flatMap(details -> inboundCandidates(details).stream()).toList();
Map<String, InboundCandidate> units = new HashMap<>(); Map<String, InboundCandidate> units = new HashMap<>();
Map<Long, List<InboundCandidate>> grouped = all.stream() Map<Long, List<InboundCandidate>> grouped = all.stream()
.filter(candidate -> candidate.coLoadGroupId() != null) .filter(candidate -> candidate.coLoadGroupId() != null)
@@ -216,6 +218,7 @@ public class SmartLoadingService {
new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(), new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(),
row.warehouseId(), row.channelCode(), row.channelName(), null, null, null, row.warehouseId(), row.channelCode(), row.channelName(), null, null, null,
row.customerName(), null, row.totalPackageCount(), null, null, null, null, row.customerName(), null, row.totalPackageCount(), null, null, null, null,
"PACKAGE", null, null, false,
null, null, null, null, false, true, null, null, null, null, null, false, true, null,
"PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList(); "PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
@@ -266,7 +269,7 @@ public class SmartLoadingService {
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
} }
private InboundCandidate inboundCandidate(List<Candidate> details) { private List<InboundCandidate> inboundCandidates(List<Candidate> details) {
Candidate first = details.get(0); Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount) int packages = details.stream().map(Candidate::availablePackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
@@ -279,13 +282,23 @@ public class SmartLoadingService {
BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText) String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、")); .map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、"));
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), List<PalletCandidate> pallets=dropshipPallets.palletCandidates(first.inboundId());
if(!pallets.isEmpty()) return pallets.stream().map(pallet -> new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, pallet.cartonCount(),
pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.weightKg().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
first.unitNetWeightKg(), pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.volumeCbm().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP),
pallet.volumeCbm(),"SEALED_PALLET",pallet.palletId(),pallet.palletNo(),true,
first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first),
first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of())).toList();
return List.of(new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(), averageVolume, volume,"PACKAGE",null,null,false, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), first.orderStatusSnapshot(), first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), first.orderStatusSnapshot(),
first.expectedArrivalAt(), List.of()); first.expectedArrivalAt(), List.of()));
} }
private InboundCandidate globalGroupCandidate(List<InboundCandidate> availableMembers) { private InboundCandidate globalGroupCandidate(List<InboundCandidate> availableMembers) {
@@ -317,7 +330,7 @@ public class SmartLoadingService {
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(), customers, goods, packages, gross, net, averageVolume, volume,"PACKAGE",null,null,false, metadata.coLoadGroupId(), metadata.groupNo(),
metadata.memberCount(), metadata.groupTotalBoxes(), metadata.memberCount(), metadata.groupTotalBoxes(),
occupied, occupied,
ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())), ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())),
@@ -418,11 +431,60 @@ public class SmartLoadingService {
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细") resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
return assign(planId, request, operatorId, operatorName, false); return assign(planId, request, operatorId, operatorName, false, null, null, null, false, null, true);
} }
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment) { boolean wholeGroupAssignment) {
return assign(planId, request, operatorId, operatorName, wholeGroupAssignment,
null, null, null, false, null, false);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_PALLET_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "按已封托托盘分配智能装柜明细")
public Plan assignPallet(long planId, AssignPalletRequest request, long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_PALLET|" + planId + '|' + request.palletId() + '|'
+ request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_PALLET", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity original = editing(planId, request.expectedVersion(), operatorId, operatorName);
List<com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletLine> lines =
dropshipPallets.sealedPalletLines(request.palletId());
if (lines.isEmpty() || lines.stream().anyMatch(line -> !"SEALED".equals(line.getPalletStatus()))) {
throw conflict("托盘不存在或尚未封托");
}
var first = lines.get(0);
inboundHoldGuard.requireInboundNotHeld(first.getAccountId(), first.getInboundId());
if (!repository.activeItemsByPallet(request.palletId()).isEmpty()) throw conflict("托盘已被有效柜次占用");
int sequence = 0;
for (var line : lines) {
LoadingPlanEntity current = repository.plan(planId);
Candidate candidate = new Candidate(line.getAccountId(), line.getInboundId(), line.getInboundDetailId(),
line.getOrderId(), line.getOrderNo(), line.getInboundCode(), line.getWarehouseId(), line.getChannelCode(),
line.getChannelName(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(),
line.getDestinationWarehouseName(), line.getCustomerName(), line.getCargoName(), line.getCartonCount(),
line.getUnitGrossWeightKg(), line.getUnitNetWeightKg(), line.getUnitVolumeCbm(), null, null, null, null,
false, null, "WAIT_SPLIT", null);
String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48);
assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(),
current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(),
line.getPalletNo(), line.getActualSkuQuantity(), sequence++ == 0, candidate, false);
}
LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation,
json(values("palletId", request.palletId(), "version", request.expectedVersion())),
json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())),
operatorId, operatorName);
recalculateAfterRelationshipChange(assigned.getContainerBookingId(), Set.of(first.getAccountId()), operation);
return view(assigned, requireBooking(assigned.getContainerBookingId()));
}
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment, Long palletId, String palletNo,
Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate,
boolean recalculateFees) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|' String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
@@ -431,8 +493,14 @@ public class SmartLoadingService {
if (replay != null) return replay; if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId()); Candidate candidate = frozenCandidate == null
? repository.candidate(planId, request.inboundId(), request.inboundDetailId()) : frozenCandidate;
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
InboundOrderEntity candidateInbound = repository.inbound(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment && candidateInbound != null
&& "ONE_CLICK_DROPSHIP".equals(candidateInbound.getFulfillmentType())) {
throw conflict("一件代发必须按已封托托盘整体加入,不能按箱数拆分");
}
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId()); inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate); requireSameCustomerScope(booking, candidate);
@@ -445,7 +513,9 @@ public class SmartLoadingService {
operatorId, operatorName); operatorId, operatorName);
} }
OrderCargoClient.CargoBinding cargo = loadCargo(candidate); OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); LoadingPlanItemEntity existing = palletId == null
? repository.activeItem(planId, candidate.accountId(), request.inboundDetailId())
: repository.activePalletItem(planId, palletId, request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
int availableIncludingCurrent = candidate.availablePackageCount() int availableIncludingCurrent = candidate.availablePackageCount()
+ (existing == null ? 0 : existing.getPackageCount()); + (existing == null ? 0 : existing.getPackageCount());
@@ -457,6 +527,14 @@ public class SmartLoadingService {
LoadingPlanItemEntity changed; LoadingPlanItemEntity changed;
if (existing == null) { if (existing == null) {
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1); LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1);
if (palletId != null) {
item.setUnitType("SEALED_PALLET");
item.setPalletId(palletId);
item.setPalletNo(palletNo);
item.setIndivisible(true);
item.setPalletCount(primaryPalletLine ? 1 : 0);
item.setProductQuantity(actualSkuQuantity);
}
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); } try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
changed = item; changed = item;
} else { } else {
@@ -475,6 +553,9 @@ public class SmartLoadingService {
loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(), loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(),
operatorId, operatorName, operation); operatorId, operatorName, operation);
loadingPickService.sync(planId, changed); loadingPickService.sync(planId, changed);
if (recalculateFees) {
recalculateAfterRelationshipChange(booking.getId(), Set.of(candidate.accountId()), operation);
}
return view(repository.plan(planId), booking); return view(repository.plan(planId), booking);
} }
@@ -488,7 +569,7 @@ public class SmartLoadingService {
resourceId = "#planId", description = "按入仓码分配智能装柜箱数") resourceId = "#planId", description = "按入仓码分配智能装柜箱数")
public Plan assignInbound(long planId, AssignInboundRequest request, public Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
return assignInbound(planId, request, operatorId, operatorName, false); return assignInbound(planId, request, operatorId, operatorName, false, null, true);
} }
/** /**
@@ -568,17 +649,24 @@ public class SmartLoadingService {
loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId, loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId,
operatorId, operatorName, operation); operatorId, operatorName, operation);
created.forEach(item -> loadingPickService.sync(planId, item)); created.forEach(item -> loadingPickService.sync(planId, item));
recalculateAfterRelationshipChange(booking.getId(), Set.of(request.customerId()), operation);
return view(repository.plan(planId), booking); return view(repository.plan(planId), booking);
} }
private Plan assignInbound(long planId, AssignInboundRequest request, private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment) { long operatorId, String operatorName, boolean wholeGroupAssignment) {
return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null); return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null, false);
} }
private Plan assignInbound(long planId, AssignInboundRequest request, private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment, long operatorId, String operatorName, boolean wholeGroupAssignment,
List<Candidate> frozenDetails) { List<Candidate> frozenDetails) {
return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, frozenDetails, false);
}
private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment,
List<Candidate> frozenDetails, boolean recalculateFees) {
String operation = operation(request.operationId()); String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|'
+ request.packageCount() + '|' + request.expectedVersion()); + request.packageCount() + '|' + request.expectedVersion());
@@ -592,8 +680,13 @@ public class SmartLoadingService {
if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE",
"MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING",
"FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) {
return assignDeclaredInbound(planId, request, projection, operation, requestHash, Plan assigned = assignDeclaredInbound(planId, request, projection, operation, requestHash,
operatorId, operatorName); operatorId, operatorName);
if (recalculateFees) {
recalculateAfterRelationshipChange(requireBooking(repository.plan(planId).getContainerBookingId()).getId(),
Set.of(projection.getTenantId()), operation);
}
return assigned;
} }
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
@@ -617,7 +710,7 @@ public class SmartLoadingService {
.substring(0, 48); .substring(0, 48);
assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(),
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName, allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName,
wholeGroupAssignment); wholeGroupAssignment, null, null, null, false, null, false);
sequence++; sequence++;
} }
LoadingPlanEntity assigned = repository.plan(planId); LoadingPlanEntity assigned = repository.plan(planId);
@@ -626,6 +719,9 @@ public class SmartLoadingService {
json(values("requestHash", requestHash, "packageCount", request.packageCount(), json(values("requestHash", requestHash, "packageCount", request.packageCount(),
"specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")), "specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")),
operatorId, operatorName); operatorId, operatorName);
if (recalculateFees) {
recalculateAfterRelationshipChange(booking.getId(), Set.of(first.accountId()), operation);
}
return view(assigned, booking); return view(assigned, booking);
} }
@@ -756,6 +852,8 @@ public class SmartLoadingService {
json(values("requestHash", requestHash, "groupId", locked.groupId(), json(values("requestHash", requestHash, "groupId", locked.groupId(),
"memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(), "memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(),
"candidateInboundCount", memberCandidates.size())), operatorId, operatorName); "candidateInboundCount", memberCandidates.size())), operatorId, operatorName);
recalculateAfterRelationshipChange(booking.getId(), locked.members().stream()
.map(Member::tenantId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation);
return view(assigned, booking); return view(assigned, booking);
} }
@@ -795,6 +893,8 @@ public class SmartLoadingService {
json(values("version", request.expectedPlanVersion(), "groupId", groupId)), json(values("version", request.expectedPlanVersion(), "groupId", groupId)),
json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)), json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)),
operatorId, operatorName); operatorId, operatorName);
recalculateAfterRelationshipChange(booking.getId(), group.members().stream()
.map(Member::tenantId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation);
return view(repository.plan(planId), booking); return view(repository.plan(planId), booking);
} }
@@ -859,6 +959,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (Boolean.TRUE.equals(item.getIndivisible()) || item.getPalletId() != null) {
throw conflict("已封托托盘不可调整箱数,只能整托移除");
}
inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId()); inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId());
requireStandalone(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId());
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
@@ -971,6 +1074,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (Boolean.TRUE.equals(item.getIndivisible()) || item.getPalletId() != null) {
throw conflict("已封托托盘不可删除单条明细,只能整托移除");
}
requireStandalone(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId());
loadingPickService.remove(itemId); loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName); touch(plan, operatorId, operatorName);
@@ -979,9 +1085,38 @@ public class SmartLoadingService {
history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
recalculateAfterRelationshipChange(plan.getContainerBookingId(), Set.of(item.getAccountId()), operation);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
} }
@Transactional
@BusinessAudit(event = "SMART_LOADING_PALLET_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "整托移除一件代发装柜明细")
public Plan removePallet(long planId,long palletId,RemovePalletRequest request,long operatorId,String operatorName){
String operation=operation(request.operationId());
String requestHash=sha256("REMOVE_PALLET|"+planId+'|'+palletId+'|'+request.expectedPlanVersion());
Plan replay=replay(operation,planId,"REMOVE_PALLET",null,requestHash); if(replay!=null)return replay;
LoadingPlanEntity plan=editing(planId,request.expectedPlanVersion(),operatorId,operatorName);
List<LoadingPlanItemEntity> palletItems=repository.activeItemsByPallet(palletId).stream()
.filter(item->Objects.equals(item.getLoadingPlanId(),planId)).toList();
if(palletItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"装柜计划中不存在该托盘");
LoadingPickService.MutationSnapshot picks=loadingPickService.mutationSnapshot(planId);
if(palletItems.stream().anyMatch(item->picks.pickedByPlanItem().getOrDefault(item.getId(),0)>0))
throw conflict("托盘已发生实物拣货,不能移除");
for(LoadingPlanItemEntity item:palletItems)loadingPickService.remove(item.getId());
touch(plan,operatorId,operatorName);
for(LoadingPlanItemEntity item:palletItems){
if(repository.remove(planId,item.getId(),item.getVersion(),operatorId,operatorName)!=1)
throw conflict("整托装柜明细已被并发修改");
}
LoadingPlanItemEntity first=palletItems.get(0);
loadingModelBridge.removeIfUnallocated(planId,first.getAccountId(),first.getInboundId());
history(planId,null,"REMOVE_PALLET",plan.getPlanStatus(),plan.getPlanStatus(),operation,
json(values("palletId",palletId,"version",request.expectedPlanVersion())),
json(values("requestHash",requestHash,"removedLineCount",palletItems.size())),operatorId,operatorName);
return view(repository.plan(planId),requireBooking(plan.getContainerBookingId()));
}
@Transactional @Transactional
@BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN", @BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "批量移除智能装柜未拣明细") resourceId = "#planId", description = "批量移除智能装柜未拣明细")
@@ -1057,6 +1192,10 @@ public class SmartLoadingService {
json(values("version", request.expectedPlanVersion(), "itemCount", items.size())), json(values("version", request.expectedPlanVersion(), "itemCount", items.size())),
json(values("requestHash", requestHash, "removedItemCount", removable.size(), json(values("requestHash", requestHash, "removedItemCount", removable.size(),
"removedGroupCount", removableGroupIds.size())), operatorId, operatorName); "removedGroupCount", removableGroupIds.size())), operatorId, operatorName);
if (!removable.isEmpty()) {
recalculateAfterRelationshipChange(booking.getId(), removable.stream().map(LoadingPlanItemEntity::getAccountId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation);
}
return view(repository.plan(planId), booking); return view(repository.plan(planId), booking);
} }
@@ -1461,7 +1600,40 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),items); task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),collapsePalletUnits(items));
}
private List<LoadingPlanItemEntity> collapsePalletUnits(List<LoadingPlanItemEntity> items) {
List<LoadingPlanItemEntity> result = new ArrayList<>();
Map<Long,List<LoadingPlanItemEntity>> pallets = items.stream().filter(i -> i.getPalletId() != null)
.collect(java.util.stream.Collectors.groupingBy(LoadingPlanItemEntity::getPalletId,
LinkedHashMap::new, java.util.stream.Collectors.toList()));
items.stream().filter(i -> i.getPalletId() == null).forEach(result::add);
for (List<LoadingPlanItemEntity> lines : pallets.values()) {
LoadingPlanItemEntity unit = lines.get(0);
unit.setPackageCount(lines.stream().map(LoadingPlanItemEntity::getPackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setProductQuantity(lines.stream().map(LoadingPlanItemEntity::getProductQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setPalletCount(1);
unit.setGrossWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getGrossWeightKg));
unit.setNetWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getNetWeightKg));
unit.setVolumeCbm(sumDecimal(lines, LoadingPlanItemEntity::getVolumeCbm));
unit.setPickedPackageCount(lines.stream().map(LoadingPlanItemEntity::getPickedPackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
unit.setTotalPackageCount(unit.getPackageCount());
unit.setRemainingPackageCount(0);
unit.setCanAdjust(false);
unit.setCanRemove(lines.stream().allMatch(i -> Boolean.TRUE.equals(i.getCanRemove())));
result.add(unit);
}
return result.stream().sorted(Comparator
.comparing(LoadingPlanItemEntity::getLoadSequence, Comparator.nullsLast(Integer::compareTo))
.thenComparing(LoadingPlanItemEntity::getId, Comparator.nullsLast(Long::compareTo)))
.toList();
}
private static BigDecimal sumDecimal(List<LoadingPlanItemEntity> lines,
java.util.function.Function<LoadingPlanItemEntity,BigDecimal> getter) {
return lines.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
} }
private int adjustmentTotal(LoadingPlanItemEntity item) { private int adjustmentTotal(LoadingPlanItemEntity item) {
int current = item.getPackageCount() == null ? 0 : item.getPackageCount(); int current = item.getPackageCount() == null ? 0 : item.getPackageCount();
@@ -1526,6 +1698,17 @@ public class SmartLoadingService {
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden(); if (!sameCustomerScope(booking, candidate)) throw forbidden();
} }
/**
* A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot.
* The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and
* rolls this relation mutation back with the surrounding transaction.
*/
private void recalculateAfterRelationshipChange(long containerBookingId, Set<Long> tenantIds,
String operation) {
tenantIds.stream().filter(Objects::nonNull).filter(tenantId -> tenantId > 0).sorted()
.forEach(tenantId -> fbaPricingService.recalculateUnlockedContainerCustomerFees(
containerBookingId, tenantId, sha256("SMART_LOADING_RELATION|" + operation + '|' + tenantId)));
}
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
LoadingPlanHistoryEntity history = repository.operation(operation); LoadingPlanHistoryEntity history = repository.operation(operation);
@@ -0,0 +1,121 @@
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:terminal-drayage: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.authorize(service.views(containerBookingIds), user.permissions()));
}
@GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.authorize(service.detail(containerBookingId), user.permissions()));
}
@GetMapping("/{containerBookingId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage: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:terminal-drayage:fleet: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.authorize(service.selectFleet(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/pickup-appointment")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup-appointment: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.authorize(service.schedulePickup(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/pickup")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')")
@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.authorize(service.recordPickup(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/empty-return")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')")
@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.authorize(service.recordEmptyReturn(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
/** 国内、国外货柜工作台共享只读模型。 */
public final class ContainerWorkbenchDtos {
private ContainerWorkbenchDtos() {}
@Schema(name = "ContainerWorkbenchStatusSummary", description = "柜级各履约域只读状态摘要")
public record StatusSummary(String loadingStatus, String customsStatus, String outboundStatus,
String destinationClearanceStatus, String deliveryOrderStatus,
String pickupCodeStatus, String latestActualNode,
LocalDateTime latestActualAt, String terminalDrayageStatus,
String emptyReturnWarningStatus) {}
@Schema(name = "ContainerWorkbenchItem", description = "同一containerBookingId的国内或国外工作台读模型")
public record WorkbenchItem(ContainerBookingEntity container, String workbenchStage,
StatusSummary statusSummary, List<String> allowedActions,
ContainerActualFacts actualFacts, View terminalDrayage) {}
}
@@ -0,0 +1,43 @@
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;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
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, ContainerActualFacts actualFacts,
LocalDateTime updatedAt) {}
public record History(String action, String fromStatus, String toStatus,
Integer fromVersion, Integer toVersion, String reason,
Long operatorId, String operatorName, LocalDateTime createdAt) {}
}
@@ -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;
}
@@ -0,0 +1,113 @@
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
FROM fl_container_booking b
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
FROM fl_container_booking b
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
FROM fl_container_booking b
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) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
}
@@ -0,0 +1,165 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.StatusSummary;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 基于同一货柜主档聚合国内、国外工作台读模型。 */
@Service
public class ContainerWorkbenchService {
@Resource private ContainerRepository repository;
@Resource private CarrierScheduleQueryService carrierSchedules;
@Resource private TerminalDrayageService terminalDrayages;
public PageResult<WorkbenchItem> domesticPage(Query query, Set<String> permissions) {
return page("DOMESTIC", query, permissions);
}
public PageResult<WorkbenchItem> overseasPage(Query query, Set<String> permissions) {
return page("OVERSEAS", query, permissions);
}
public WorkbenchItem domesticDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id);
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
ContainerActualFacts facts = actualFacts(List.of(id)).get(id);
return item("DOMESTIC", value, facts, null, permissions);
}
public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id);
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions);
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions);
}
private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
Query normalized = normalize(query);
PageResult<ContainerBookingEntity> source = repository.page(workbench, normalized);
List<Long> ids = source.records().stream().map(ContainerBookingEntity::getId).toList();
Map<Long, ContainerActualFacts> facts = new HashMap<>();
Map<Long, View> drayages = new HashMap<>();
if (!ids.isEmpty() && "OVERSEAS".equals(workbench)) {
terminalDrayages.authorize(terminalDrayages.views(ids), permissions).forEach(value -> {
drayages.put(value.containerBookingId(), value);
facts.put(value.containerBookingId(), value.actualFacts());
});
} else if (!ids.isEmpty()) {
facts.putAll(actualFacts(ids));
}
List<WorkbenchItem> records = source.records().stream()
.map(value -> item(workbench, value, facts.get(value.getId()), drayages.get(value.getId()), permissions))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
if (from != null && to != null && to.isBefore(from)) {
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
}
}
private static Query normalize(Query query) {
return new Query(trim(query.containerNo()), trim(query.cabinetNo()), trim(query.billOfLadingNo()),
trim(query.keyword()), query.customerId(), query.warehouseId(), trim(query.containerType()),
query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()),
trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(),
query.estimatedSailingTo(), query.scope(), query.page(), query.size());
}
private Map<Long, ContainerActualFacts> actualFacts(List<Long> ids) {
Map<Long, ContainerActualFacts> result = new HashMap<>();
carrierSchedules.containerActualFacts(ids).forEach(value -> result.put(value.containerBookingId(), value));
return result;
}
private WorkbenchItem item(String workbench, ContainerBookingEntity container, ContainerActualFacts facts,
View drayage, Set<String> permissions) {
String stage = "DOMESTIC".equals(workbench) ? domesticStage(container) : overseasStage(container, facts, drayage);
StatusSummary summary = new StatusSummary(container.getPlanStatus(),
container.getCustomsDeclarationDocumentStatus(), container.getOutboundDispatchStatus(),
container.getDestinationClearanceDocumentStatus(), container.getDeliveryOrderDocumentStatus(),
trim(container.getPickupCode()) == null ? "MISSING" : "UPLOADED",
facts == null ? null : facts.latestActualNode(), facts == null ? null : facts.latestActualAt(),
drayage == null ? null : drayage.status(),
drayage == null || drayage.emptyReturnWarning() == null ? null : drayage.emptyReturnWarning().status());
List<String> actions = "DOMESTIC".equals(workbench)
? domesticActions(container, permissions) : overseasActions(drayage, permissions);
return new WorkbenchItem(container, stage, summary, actions, facts, drayage);
}
private static String domesticStage(ContainerBookingEntity value) {
if (value.getOutboundConfirmedAt() != null) return "DEPARTED_WAREHOUSE";
if (value.getPlanStatus() == null || "WAIT_LOADING".equals(value.getPlanStatus())) return "WAIT_LOADING";
if (!"COMPLETED".equals(value.getPlanStatus())) return "LOADING";
if (!"COMPLETED".equals(value.getCustomsDeclarationDocumentStatus())) return "WAIT_CUSTOMS";
return "WAIT_OUTBOUND";
}
private static String overseasStage(ContainerBookingEntity value, ContainerActualFacts facts, View drayage) {
if (drayage != null && "EMPTY_RETURNED".equals(drayage.status())) return "EMPTY_RETURNED";
if (value.getUnloadingCompletedAt() != null) return "UNLOADED";
if (drayage != null && "PICKED_UP".equals(drayage.status())) return "PICKED_UP";
if (facts != null && facts.pickupReadyAt() != null) return "PICKUP_READY";
if (facts != null && facts.arrivedDestinationPortAt() != null) return "ARRIVED_DESTINATION_PORT";
if (facts != null && facts.departedOriginPortAt() != null) return "IN_TRANSIT";
return "DEPARTED_WAREHOUSE";
}
private static List<String> domesticActions(ContainerBookingEntity value, Set<String> permissions) {
Set<String> granted = permissions == null ? Set.of() : permissions;
List<String> actions = new ArrayList<>();
if (granted.contains("platform:firstleg:container:edit")
&& ("WAIT_LOADING".equals(value.getPlanStatus()) || "EDITING".equals(value.getPlanStatus()))) {
actions.add("EDIT_CONTAINER");
}
if (granted.contains("platform:firstleg:customs:manage") && "COMPLETED".equals(value.getPlanStatus())
&& value.getOutboundConfirmedAt() == null) actions.add("MANAGE_CUSTOMS");
if (granted.contains("platform:firstleg:outbound-dispatch:confirm")
&& "COMPLETED".equals(value.getPlanStatus())
&& "COMPLETED".equals(value.getCustomsDeclarationDocumentStatus())
&& value.getOutboundConfirmedAt() == null) actions.add("CONFIRM_OUTBOUND");
return List.copyOf(actions);
}
private static List<String> overseasActions(View drayage, Set<String> permissions) {
Set<String> granted = permissions == null ? Set.of() : permissions;
List<String> actions = new ArrayList<>();
boolean returned = drayage != null && "EMPTY_RETURNED".equals(drayage.status());
if (!returned && granted.contains("platform:firstleg:terminal-drayage:document:edit")) {
actions.add("MANAGE_DESTINATION_DOCUMENTS");
}
if (drayage != null) actions.addAll(drayage.allowedActions());
return List.copyOf(actions);
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim();
return result.isEmpty() ? null : result;
}
}
@@ -0,0 +1,386 @@
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.ContainerActualFacts;
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;
import java.util.Set;
@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));
Map<Long, ContainerActualFacts> facts = actualFacts(ids);
return mapper.contexts(ids).stream().map(value -> context(value, facts.get(value.containerBookingId())))
.filter(Objects::nonNull)
.map(value -> view(value, rows.get(value.base().containerBookingId()))).toList();
}
public List<History> histories(long containerBookingId) {
requiredContext(containerBookingId);
return mapper.histories(containerBookingId);
}
public List<View> authorize(List<View> values, Set<String> permissions) {
return values.stream().map(value -> authorize(value, permissions)).toList();
}
public View authorize(View value, Set<String> permissions) {
Set<String> granted = permissions == null ? Set.of() : permissions;
List<String> actions = value.allowedActions().stream()
.filter(action -> granted.contains(permission(action))).toList();
return new View(value.containerBookingId(), value.containerNo(), value.oceanBookingId(), value.status(),
value.version(), value.fleetSupplierId(), value.fleetSupplierName(), value.pickupAppointmentAt(),
value.actualPickupAt(), value.estimatedEmptyReturnAt(), value.actualEmptyReturnAt(),
value.unloadingCompletedAt(), value.emptyReturnWarning(), actions, value.actualFacts(), value.updatedAt());
}
@Transactional
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
FleetSelectionRequest request) {
String action = "SELECT_FLEET";
WorkContext 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";
WorkContext 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.facts().arrivedDestinationPortAt() != null
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
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";
WorkContext 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.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().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";
WorkContext 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.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
LocalDateTime earliest = pickupAt.isAfter(context.base().unloadingCompletedAt())
? pickupAt : context.base().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(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) {
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= target.sequence()) return;
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
private View finish(WorkContext context, TerminalDrayageEntity value, String action, String key, Object request,
String fromStatus, int fromVersion, String reason,
long operatorId, String operatorName) {
TerminalDrayageEntity stored = mapper.find(context.base().containerBookingId());
WorkContext currentContext = requiredContext(context.base().containerBookingId());
String toStatus = status(currentContext, stored);
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.base().containerBookingId(), action,
fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName);
mapper.insertOperation(IdWorker.getId(), context.base().containerBookingId(), action, key.trim(), hash(request),
version(stored), operatorId);
return view(currentContext, stored);
}
private View replay(WorkContext context, String action, String key, Object request) {
if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符");
Operation operation = mapper.operation(context.base().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.base().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(WorkContext context, TerminalDrayageEntity value) {
if (context.base().unloadingCompletedAt() != null) {
value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
}
}
private View view(WorkContext context, TerminalDrayageEntity value) {
String status = status(context, value);
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
: context.base().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.base().containerBookingId(), context.base().containerNo(), context.base().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.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(),
value == null ? null : value.getUpdatedAt());
}
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
CarrierScheduleNode current = node(context.facts().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.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
return List.copyOf(result);
}
private String status(WorkContext context, TerminalDrayageEntity value) {
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
if (context.base().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.facts().latestActualNode());
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
}
private void requireArrived(WorkContext context) {
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
}
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.facts().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(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
}
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
}
private WorkContext requiredContext(long id) {
Context value = mapper.context(id);
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
WorkContext context = context(value, actualFacts(List.of(id)).get(id));
if (context == null) throw notFound();
return context;
}
private WorkContext requiredLockedContext(long id) {
Context value = mapper.lockContext(id);
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
WorkContext context = context(value, actualFacts(List.of(id)).get(id));
if (context == null) throw notFound();
return context;
}
private Map<Long, ContainerActualFacts> actualFacts(List<Long> ids) {
Map<Long, ContainerActualFacts> result = new HashMap<>();
carrierSchedules.containerActualFacts(ids).forEach(value -> result.put(value.containerBookingId(), value));
return result;
}
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|| facts.departedWarehouseAt() == null) return null;
return new WorkContext(base, facts);
}
private record WorkContext(Context base, ContainerActualFacts facts) {}
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 String permission(String action) {
return switch (action) {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
default -> "";
};
}
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; }
}
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -209,6 +209,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数',
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
@@ -533,7 +534,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
CREATE TABLE IF NOT EXISTS fl_price_book_version ( CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属',
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
@@ -672,6 +673,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
baseline_chargeable_quantity DECIMAL(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量',
tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT '人工调整允许的最大偏差百分比,边界包含',
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
currency CHAR(3) NOT NULL COMMENT '币种', currency CHAR(3) NOT NULL COMMENT '币种',
@@ -701,6 +704,32 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
SET @fee_baseline_quantity_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'),
'SELECT 1',
'ALTER TABLE fl_inbound_fee ADD COLUMN baseline_chargeable_quantity DECIMAL(18,6) NULL COMMENT ''系统按权威测量与生效报价计算的原始计费量'' AFTER converted_quantity'
);
PREPARE fee_baseline_quantity_stmt FROM @fee_baseline_quantity_sql;
EXECUTE fee_baseline_quantity_stmt;
DEALLOCATE PREPARE fee_baseline_quantity_stmt;
UPDATE fl_inbound_fee
SET baseline_chargeable_quantity = chargeable_quantity
WHERE baseline_chargeable_quantity IS NULL;
ALTER TABLE fl_inbound_fee
MODIFY COLUMN baseline_chargeable_quantity DECIMAL(18,6) NOT NULL
COMMENT '系统按权威测量与生效报价计算的原始计费量';
SET @fee_tolerance_percent_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_inbound_fee' AND column_name='tolerance_percent'),
'SELECT 1',
'ALTER TABLE fl_inbound_fee ADD COLUMN tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT ''人工调整允许的最大偏差百分比,边界包含'' AFTER baseline_chargeable_quantity'
);
PREPARE fee_tolerance_percent_stmt FROM @fee_tolerance_percent_sql;
EXECUTE fee_tolerance_percent_stmt;
DEALLOCATE PREPARE fee_tolerance_percent_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -920,7 +949,9 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成',
total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数',
counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已逐箱确认实收数量的箱数',
allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数',
discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数',
assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID',
assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照', assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照',
claimed_at DATETIME(3) NULL COMMENT '任务领取时间', claimed_at DATETIME(3) NULL COMMENT '任务领取时间',
@@ -933,7 +964,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task (
KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at),
CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id),
CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')),
CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons)
) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; ) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
@@ -942,8 +973,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码',
pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托',
open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID', seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID',
@@ -960,29 +991,39 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet (
UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id),
KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status),
CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED'))
) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID', id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID',
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID', task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID', pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码',
operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量',
discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE',
discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明',
count_operation_id VARCHAR(128) NOT NULL COMMENT '逐箱清点幂等操作ID',
bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID',
last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号',
counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID',
counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照',
counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间',
bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照', bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照',
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', bound_at DATETIME(3) NULL COMMENT '上托时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id),
KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id),
KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id),
KEY idx_dropship_carton_operation (operation_id), UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id),
KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id),
CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id),
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
@@ -471,6 +471,15 @@ SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL
0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000'
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT');
INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name,
pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable,
allocation_fee, description, status, created_by, created_by_name, created_at,
updated_by, updated_by_name, updated_at, deleted, version)
SELECT 193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY', '尾程派送费', 'INTERNATIONAL',
'私人仓尾程派送费', 0, 0, 1, 1, 0, '仅用于destinationType=PRIVATE的入库单',
'ACTIVE', 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 0
WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'PRIVATE_LAST_MILE_DELIVERY');
DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema; DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE phase2_publish_assert_fee_schema() CREATE PROCEDURE phase2_publish_assert_fee_schema()
@@ -491,11 +500,15 @@ BEGIN
OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
AND id BETWEEN 193000000001000001 AND 193000000001000110 AND id BETWEEN 193000000001000001 AND 193000000001000110
AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110
OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT'
AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费'
AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0
AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'PRIVATE_LAST_MILE_DELIVERY'
AND category = '尾程派送费' AND trade_scope = 'INTERNATIONAL'
AND fee_type_name = '私人仓尾程派送费' AND warehouse_payable = 1
AND warehouse_receivable = 1 AND status = 'ACTIVE' AND deleted = 0) <> 1
OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id
WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验';
END IF; END IF;
@@ -74,9 +74,13 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)', unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)',
unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)', unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)',
estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)', estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)',
actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)', actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)',
empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖', empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖',
pickup_code VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零',
pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID',
pickup_code_updated_by_name VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照',
pickup_code_updated_at DATETIME(3) NULL COMMENT 'PU码最后填写时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -96,6 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0), CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
CONSTRAINT chk_container_booking_iso6346 CHECK CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$') (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
@@ -162,7 +167,7 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN
ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL
COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)' AFTER unloading_photo_uploaded_at;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN
@@ -174,6 +179,26 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL
COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='pickup_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code VARCHAR(64) NULL
COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER empty_report_source_version;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by') THEN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_by BIGINT UNSIGNED NULL
COMMENT 'PU码最后填写人ID' AFTER pickup_code;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by_name') THEN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_by_name VARCHAR(64) NULL
COMMENT 'PU码最后填写人名称快照' AFTER pickup_code_updated_by;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_at') THEN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
@@ -197,6 +222,11 @@ BEGIN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346 ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'); CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$');
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_container_real_unit_columns(); CALL phase3_container_real_unit_columns();
@@ -267,6 +297,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照',
package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数',
product_quantity INT UNSIGNED NULL COMMENT '产品总数量', product_quantity INT UNSIGNED NULL COMMENT '产品总数量',
unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托',
pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空',
pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照',
indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数',
pallet_count INT UNSIGNED NULL COMMENT '托盘数', pallet_count INT UNSIGNED NULL COMMENT '托盘数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg',
@@ -287,7 +321,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细查询键;删除后返回NULL',
active_assignment_key VARCHAR(96) GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:', pallet_id) END, ':', inbound_detail_id) ELSE NULL END) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -296,12 +331,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key),
KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id),
KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id),
KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_account (account_id, created_at, id),
KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_inbound (account_id, inbound_id, deleted),
KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted), KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted),
KEY idx_loading_item_pallet (pallet_id, deleted),
KEY idx_loading_item_order (account_id, order_id, deleted), KEY idx_loading_item_order (account_id, order_id, deleted),
CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_item_account CHECK (account_id > 0), CONSTRAINT chk_loading_item_account CHECK (account_id > 0),
@@ -318,11 +354,11 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
-- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。 -- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
CREATE TABLE IF NOT EXISTS fl_container_document ( CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
@@ -341,8 +377,17 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
CONSTRAINT chk_container_document_type CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; ) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联';
SET @has_container_document_type_check=(SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema=DATABASE() AND table_name='fl_container_document'
AND constraint_name='chk_container_document_type' AND constraint_type='CHECK');
SET @ddl=IF(@has_container_document_type_check=0,'SELECT 1',
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER'));
CREATE TABLE IF NOT EXISTS fl_container_exception_history ( CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
@@ -669,66 +714,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id)
) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID',
warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号',
open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号',
seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号',
pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照',
sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID',
sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照',
sealed_at DATETIME(3) NULL COMMENT '封托时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code),
UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id),
UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id),
KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id),
CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED'))
) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单';
CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton (
id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID',
work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID',
loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID',
box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照',
bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID',
bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id),
UNIQUE KEY uk_loading_work_carton_operation (operation_id),
KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id),
KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id),
CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id),
CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id),
CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id),
CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0)
) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托';
DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys()
@@ -1206,7 +1191,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id; WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF; END IF;
END$$ END$$
@@ -1222,7 +1207,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id; WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED' IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF; END IF;
END$$ END$$
@@ -1478,12 +1463,13 @@ BEGIN
'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg', 'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN 'unit_type', 'pallet_id', 'pallet_no', 'indivisible', 'active_assignment_key',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
@@ -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='目的港码头拖车成功幂等操作';
@@ -10,6 +10,25 @@ paths:
x-permission: platform:firstleg:port-schedule-query:list x-permission: platform:firstleg:port-schedule-query:list
responses: responses:
'200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'}
/api/platform/v1/carrier-schedule-queries/container-actual-facts:
get:
summary: Batch query actual carrier-node facts for container workbenches
description: Returns at most one row for each container that has a carrier-schedule query. Missing rows mean that no query exists. Estimated facts are never returned or inferred.
x-permission: platform:firstleg:port-schedule-query:list
parameters:
- name: containerBookingIds
in: query
required: true
schema:
type: array
minItems: 1
maxItems: 200
uniqueItems: true
items: {type: integer, format: int64, minimum: 1}
responses:
'200':
description: Actual-only summaries ordered by containerBookingId
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerActualFactsListEnvelope'}}}
/api/platform/v1/carrier-schedule-queries: /api/platform/v1/carrier-schedule-queries:
get: get:
summary: Query carrier schedule tasks summary: Query carrier schedule tasks
@@ -175,6 +194,30 @@ components:
properties: properties:
query: {$ref: '#/components/schemas/Task'} query: {$ref: '#/components/schemas/Task'}
expired: {type: boolean} expired: {type: boolean}
ContainerActualFacts:
type: object
required: [containerBookingId, queryId, queryVersion, queryStatus]
description: Actual facts only. All node times are null until an immutable fact with actualAt exists; estimatedAt is intentionally absent.
properties:
containerBookingId: {type: integer, format: int64}
queryId: {type: integer, format: int64}
queryVersion: {type: integer, minimum: 0}
queryStatus: {$ref: '#/components/schemas/QueryStatus'}
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
latestActualAt: {type: string, format: date-time, nullable: true}
departedWarehouseAt: {type: string, format: date-time, nullable: true}
departedOriginPortAt: {type: string, format: date-time, nullable: true}
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
pickupReadyAt: {type: string, format: date-time, nullable: true}
pickedUpAt: {type: string, format: date-time, nullable: true}
emptyReturnedAt: {type: string, format: date-time, nullable: true}
ContainerActualFactsListEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {type: array, items: {$ref: '#/components/schemas/ContainerActualFacts'}}
NodeFact: NodeFact:
type: object type: object
required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt]
@@ -1,54 +1,153 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: 一件代发 PDA 合托 API title: 一件代发国内清点组托 API
version: 1.0.0 version: 2.0.0
description: 测量完成后独立下发;不创建国内库存。PDA 扫箱唛识别 SKU,并按箱数合托。 description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。
paths: paths:
/api/platform/v1/first-leg/pda-dropship/tasks/pending: /api/platform/v1/first-leg/pda-dropship/tasks/pending:
get: get:
summary: 查询待领取或本人已领取的一件代发合托任务 summary: 查询待领取或本人已领取任务
responses: { '200': { description: OK } } x-required-authority: platform:firstleg:pda:view
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}:
get: get:
summary: 查询任务、托盘、入仓单、SKU 和箱数明细 summary: 查询任务、已清点待绑箱及托盘结果
x-required-authority: platform:firstleg:pda:view
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/resolve:
get:
summary: 扫箱码解析入仓码、SKU 和应收数量,不返回全部未清点箱
x-required-authority: platform:firstleg:pda:view
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/TaskId'}
- { name: warehouseId, in: query, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/WarehouseId'}
responses: { '200': { description: OK } } - {name: boxCode, in: query, required: true, schema: {type: string, maxLength: 80}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/claim: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/claim:
post: post:
summary: 领取合托任务 summary: 领取任务
parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] x-required-authority: platform:firstleg:pda:work
responses: { '200': { description: OK } } parameters: [{$ref: '#/components/parameters/TaskId'}]
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/open: requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count:
post: post:
summary: 开启一个现场托盘 summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘
parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
responses: { '200': { description: OK } } x-required-authority: platform:firstleg:pda:work
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/scan: parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/bind-pending:
post: post:
summary: 扫箱唛识别 SKU 并填写上托箱数 summary: 扫托盘码并把当前任务全部待绑箱绑定到该托盘
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
post:
summary: 换托,targetPalletNo 为空时退回待绑区
x-required-authority: platform:firstleg:pda:work
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/TaskId'}
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - {name: cartonId, in: path, required: true, schema: {type: integer, format: int64}}
responses: { '200': { description: OK } } requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MoveCartonRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal: /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal:
post: post:
summary: 完成合托并生成 DP 托盘号 summary: 封托;禁止空托、待绑箱、版本冲突和重复归托
x-required-authority: platform:firstleg:pda:work
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - {$ref: '#/components/parameters/TaskId'}
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
responses: { '200': { description: OK } } requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
get: get:
summary: 入仓单查看绑定的一个或多个托盘及各自箱数 summary: Admin 查询入仓单权威托盘结果
parameters: [{ name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }] x-required-authority: firstleg:inbound:list
responses: { '200': { description: OK } } responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
get: get:
summary: 打印托盘号、入仓码和 SKU 箱数托盘唛 summary: 打印托盘码、入仓码和 SKU 实收汇总
parameters: x-required-authority: firstleg:inbound:list
- { name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } } responses: {'200': {description: PDF}}
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } components:
responses: parameters:
'200': { description: PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64}}
WarehouseId: {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64}}
schemas:
Carton:
type: object
properties:
cartonId: {type: integer, format: int64, nullable: true}
boxCode: {type: string}
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string}
cargoName: {type: string, nullable: true}
expectedSkuQuantity: {type: integer, minimum: 1}
actualSkuQuantity: {type: integer, minimum: 1, nullable: true}
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
discrepancyRemark: {type: string, nullable: true}
countStatus: {type: string, enum: [UNCOUNTED, COUNTED_UNBOUND, BOUND]}
palletId: {type: integer, format: int64, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
canCount: {type: boolean}
blockReason: {type: string, nullable: true}
Pallet:
type: object
properties:
palletId: {type: integer, format: int64}
palletNo: {$ref: '#/components/schemas/PalletNo'}
status: {type: string, enum: [OPEN, SEALED]}
cartonCount: {type: integer, minimum: 0}
skuCount: {type: integer, minimum: 0}
discrepancyCartons: {type: integer, minimum: 0}
weightKg: {type: number, minimum: 0}
volumeCbm: {type: number, minimum: 0}
version: {type: integer, minimum: 1}
items: {type: array, items: {type: object}}
cartons: {type: array, items: {$ref: '#/components/schemas/Carton'}, description: 含稳定 cartonId,换托必须使用该 ID}
sealedAt: {type: string, format: date-time, nullable: true}
ClaimRequest:
type: object
required: [warehouseId]
properties: {warehouseId: {type: integer, format: int64}}
CountCartonRequest:
type: object
required: [warehouseId, boxCode, actualSkuQuantity, discrepancyAction, operationId]
properties:
warehouseId: {type: integer, format: int64}
boxCode: {type: string, maxLength: 80}
actualSkuQuantity: {type: integer, minimum: 1}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
operationId: {type: string, maxLength: 128}
BindPendingRequest:
type: object
required: [warehouseId, palletNo, operationId]
properties:
warehouseId: {type: integer, format: int64}
palletNo: {$ref: '#/components/schemas/PalletNo'}
operationId: {type: string, maxLength: 128}
MoveCartonRequest:
type: object
required: [warehouseId, operationId]
properties:
warehouseId: {type: integer, format: int64}
targetPalletNo: {$ref: '#/components/schemas/NullablePalletNo'}
operationId: {type: string, maxLength: 128}
SealPalletRequest:
type: object
required: [warehouseId, expectedVersion, operationId]
properties:
warehouseId: {type: integer, format: int64}
expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128}
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
@@ -72,6 +72,7 @@ paths:
summary: 查询船期价格表版本 summary: 查询船期价格表版本
description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。 description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。
parameters: parameters:
- {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}, description: 0 表示公共报价;正数表示客户专属报价}
- {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}} - {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}}
responses: responses:
'200': '200':
@@ -84,7 +85,9 @@ paths:
'403': {description: 无权限} '403': {description: 无权限}
post: post:
summary: 上传船期价格表并生成待确认记录 summary: 上传船期价格表并生成待确认记录
description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;需要 firstleg:fba-pricing:rate:edit 权限。 description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;customerId=0 为公共报价,customerId>0 为该客户的专属报价;需要 firstleg:fba-pricing:rate:edit 权限。
parameters:
- {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}}
requestBody: requestBody:
required: true required: true
content: content:
@@ -105,7 +108,7 @@ paths:
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish: /api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:
post: post:
summary: 确认并立即生效船期价格表版本 summary: 确认并立即生效船期价格表版本
description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。 description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务只替换同 customerId 作用域的上一 ACTIVE 版本。计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本。
parameters: [{$ref: '#/components/parameters/VersionId'}] parameters: [{$ref: '#/components/parameters/VersionId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
@@ -165,7 +168,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/published: /api/platform/v1/inbounds/{inboundId}/fees/published:
put: put:
summary: 调整初算或已发布费用 summary: 调整初算或已发布费用
description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 description: 需要 firstleg:fba-pricing:adjust 权限;计费量只能在系统原计费量上下10%范围内(含边界),请求必须携带当前费用版本、必填原因及二次确认标志,越界或并发冲突返回A0409。小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -184,7 +187,7 @@ paths:
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}}
post: post:
summary: 新增 MANUAL 其他费用 summary: 新增 MANUAL 其他费用
description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。 description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。费用项目 PRIVATE_LAST_MILE_DELIVERY 是独立的私人仓尾程派送费,仅 destinationType=PRIVATE 的入库单可创建;服务端按稳定 feeCode 校验,不按中文名称判断。
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}] parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}}
@@ -330,6 +333,10 @@ components:
actualWeightKg: {type: number, nullable: true} actualWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true}
convertedQuantity: {type: number, nullable: true} convertedQuantity: {type: number, nullable: true}
baselineChargeableQuantity: {type: number, description: 系统按权威测量与生效报价计算的原计费量,人工调整后保持不变}
lowerChargeableQuantity: {type: number, description: 允许人工调整的计费量下界,包含边界}
upperChargeableQuantity: {type: number, description: 允许人工调整的计费量上界,包含边界}
tolerancePercent: {type: number, enum: [10.00], description: 相对系统原计费量允许的上下偏差百分比}
chargeableQuantity: {type: number, nullable: true} chargeableQuantity: {type: number, nullable: true}
chargeUnit: {type: string, nullable: true} chargeUnit: {type: string, nullable: true}
unitPrice: {type: number, nullable: true} unitPrice: {type: number, nullable: true}
@@ -466,16 +473,22 @@ components:
timestamp: {type: string, format: date-time, nullable: true} timestamp: {type: string, format: date-time, nullable: true}
AdjustFeeRequest: AdjustFeeRequest:
type: object type: object
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed]
properties: properties:
expectedFeeVersion: {type: integer, minimum: 1} expectedFeeVersion: {type: integer, minimum: 1}
chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit}
unitPrice: {type: number, minimum: 0, description: 人工确认单价} unitPrice: {type: number, minimum: 0, description: 人工确认单价}
additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
reason: {type: string, minLength: 1, maxLength: 500} reason: {type: string, minLength: 1, maxLength: 500}
toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本}
CalculateRequest: CalculateRequest:
type: object type: object
properties: {pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}} properties:
pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}
selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威重量快照}
selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威体积快照}
weightSource: {type: string, maxLength: 32, nullable: true}
volumeSource: {type: string, maxLength: 32, nullable: true}
CustomerRuleVersionRequest: CustomerRuleVersionRequest:
type: object type: object
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
@@ -535,6 +548,7 @@ components:
type: object type: object
properties: properties:
id: {type: string, pattern: '^[1-9][0-9]*$'} id: {type: string, pattern: '^[1-9][0-9]*$'}
customerId: {type: string, pattern: '^(0|[1-9][0-9]*)$', description: 0 为公共报价,正数为客户专属报价}
versionNo: {type: integer} versionNo: {type: integer}
originalFileName: {type: string, maxLength: 255} originalFileName: {type: string, maxLength: 255}
sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
@@ -668,5 +682,6 @@ components:
destinationName: {type: string, maxLength: 128, nullable: true} destinationName: {type: string, maxLength: 128, nullable: true}
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]} destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致}
actualWeightKg: {type: number, minimum: 0} actualWeightKg: {type: number, minimum: 0}
actualVolumeCbm: {type: number, minimum: 0} actualVolumeCbm: {type: number, minimum: 0}
@@ -296,7 +296,7 @@ paths:
post: post:
summary: 按统一订单条件分页查询履约摘要(Order 内部) summary: 按统一订单条件分页查询履约摘要(Order 内部)
description: >- description: >-
mainStatus 接受六态产品状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态。 mainStatus 接受七态产品状态;WAIT_PALLETIZE 为一件代发测量计费后的待清点组托状态,WAIT_SPLIT 为待分柜状态。
计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。
security: security:
- internalServiceToken: [] - internalServiceToken: []
@@ -406,25 +406,6 @@ paths:
'200': {description: 成功或业务失败,按响应体 code 判断} '200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无分配权限} '403': {description: 无分配权限}
/api/platform/v1/containers: /api/platform/v1/containers:
get:
summary: 分页查询平台货柜
description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account
parameters:
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: status, in: query, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
'403': {description: 无货柜查询权限}
post: post:
summary: 新增内部模拟柜 summary: 新增内部模拟柜
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱 description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
@@ -436,6 +417,43 @@ paths:
responses: responses:
'200': {description: 成功或重复号、参数校验等业务失败} '200': {description: 成功或重复号、参数校验等业务失败}
'403': {description: 无新增按钮权限} '403': {description: 无新增按钮权限}
/api/platform/v1/containers/domestic:
get:
summary: 分页查询国内货柜工作台
description: 需要 platform:firstleg:container:list;ACTIVE仅真实国内离仓前,HISTORY仅已真实离仓
parameters: &ContainerWorkbenchQueryParameters
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
- {name: scope, in: query, required: true, schema: {type: string, enum: [ACTIVE, HISTORY]}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: status, in: query, description: 仅筛选装柜子状态,不作为出仓或船节点状态, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
responses:
'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchPageEnvelope'}}}}
'403': {description: 无国内货柜查询权限}
/api/platform/v1/containers/overseas:
get:
summary: 分页查询国外货柜工作台
description: 需要 platform:firstleg:terminal-drayage:list;ACTIVE从真实DEPARTED_WAREHOUSE至EMPTY_RETURNED前,HISTORY仅已还空柜
parameters: *ContainerWorkbenchQueryParameters
responses:
'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchPageEnvelope'}}}}
'403': {description: 无国外货柜查询权限}
/api/platform/v1/containers/domestic/{id}:
get:
summary: 查询国内货柜工作台详情
description: 需要 platform:firstleg:container:list;装柜清单只返回唯一loadingPlanId/planNo/planStatus关联
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchEnvelope'}}}}}
/api/platform/v1/containers/overseas/{id}:
get:
summary: 查询国外货柜工作台详情
description: 需要 platform:firstleg:terminal-drayage:list;同一containerBookingId只读聚合柜内组成和海外履约
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchEnvelope'}}}}}
/api/platform/v1/containers/{id}/ocean-booking-link: /api/platform/v1/containers/{id}/ocean-booking-link:
post: post:
summary: 将内部模拟柜关联到已发布订舱需求 summary: 将内部模拟柜关联到已发布订舱需求
@@ -444,14 +462,6 @@ paths:
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}} responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
/api/platform/v1/containers/{id}: /api/platform/v1/containers/{id}:
get:
summary: 查询平台货柜详情
description: 需要 platform:firstleg:container:list 权限
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
responses:
'200': {description: 成功或货柜不存在}
'403': {description: 无货柜查询权限}
put: put:
summary: 草稿阶段编辑货柜基础资料 summary: 草稿阶段编辑货柜基础资料
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口 description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
@@ -465,16 +475,23 @@ paths:
responses: responses:
'200': {description: 成功或状态、版本、重复号冲突} '200': {description: 成功或状态、版本、重复号冲突}
'403': {description: 无货柜编辑权限} '403': {description: 无货柜编辑权限}
/api/platform/v1/containers/{id}/pickup-code:
put:
summary: 填写或更新PU码
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;仅国外工作台货柜;PU资料为1至64位数字码,按字符串保存以保留前导零
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerPickupCodeRequest'}}}}
responses: {'200': {description: 返回最新货柜履约概览}, '403': {description: 无货柜编辑权限}}
/api/platform/v1/containers/{id}/clearance: /api/platform/v1/containers/{id}/clearance:
get: get:
summary: 查询货柜目的港清关资料 summary: 查询货柜目的港清关资料
description: 需要 platform:firstleg:customs:manage 权限 description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {description: 成功或货柜不存在}} responses: {'200': {description: 成功或货柜不存在}}
/api/platform/v1/containers/{id}/clearance/document: /api/platform/v1/containers/{id}/clearance/document:
post: post:
summary: 上传或替换货柜目的港清关资料 summary: 上传或替换货柜目的港清关资料
description: 需要 platform:firstleg:customs:manage 权限,fileId 必须严格绑定当前货柜 description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -483,7 +500,7 @@ paths:
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download: /api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
get: get:
summary: 下载货柜目的港清关附件 summary: 下载货柜目的港清关附件
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
parameters: parameters:
- {$ref: '#/components/parameters/ContainerBookingId'} - {$ref: '#/components/parameters/ContainerBookingId'}
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -493,7 +510,7 @@ paths:
/api/platform/v1/containers/{id}/clearance/document/upload: /api/platform/v1/containers/{id}/clearance/document/upload:
post: post:
summary: 直接选择文件上传或替换货柜目的港清关资料 summary: 直接选择文件上传或替换货柜目的港清关资料
description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。 description: 需要 platform:firstleg:terminal-drayage:document:edit;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -504,7 +521,7 @@ paths:
/api/platform/v1/containers/{id}/clearance/complete: /api/platform/v1/containers/{id}/clearance/complete:
post: post:
summary: 点击完成目的港清关 summary: 点击完成目的港清关
description: 需要 platform:firstleg:customs:manage 权限 description: 需要 platform:firstleg:terminal-drayage:document:edit 权限
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -916,6 +933,8 @@ paths:
grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。
actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重; actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重;
两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。
inboundCode 是当前入库详情唯一入仓码;每条 items 按 inboundDetailId 返回 Order 权威
fbaShipmentNo,并保留独立 scannedShipmentId,匹配失败返回空且禁止两者互相回退或覆盖。
parameters: parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -931,7 +950,9 @@ paths:
summary: 保存测量草稿 summary: 保存测量草稿
description: >- description: >-
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。 actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING
或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
measurement/items/{itemId}/confirmed-package-count 明细专用接口。
parameters: parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1133,7 +1154,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve: /api/platform/v1/inbounds/{inboundId}/measurements/approve:
post: post:
summary: 审核通过测量 summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。
parameters: parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1150,7 +1171,7 @@ paths:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
reason: {type: string, maxLength: 1000} reason: {type: string, maxLength: 1000}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} '200': {description: 成功或业务失败,按响应体 code 判断;审核成功后标准头程 data.processStatus=WAIT_SPLIT,一件代发为 WAIT_PALLETIZE;initialPricingSucceeded 表示是否同时生成初算费用,未覆盖时 feeStatus=UNPRICED 且主流程仍成功}
/api/platform/v1/inbounds/{inboundId}/measurements/submit: /api/platform/v1/inbounds/{inboundId}/measurements/submit:
post: post:
summary: 提交测量完成 summary: 提交测量完成
@@ -1164,7 +1185,7 @@ paths:
/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:
get: get:
summary: 客户查询已完成入库结果和最新已发布费用 summary: 客户查询已完成入库结果和最新已发布费用
description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。入库流程达到 WAIT_SPLIT 后,费用在 LOADING、LOADED、DELIVERED 仍持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。latestFee.customerConfirmationStatus 表示客户是否已确认当前费用版本。 description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。一件代发在 WAIT_PALLETIZE 清点组托阶段即可查看并确认已发布费用,之后在 WAIT_SPLIT、LOADING、LOADED、DELIVERED 持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。
parameters: parameters:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: responses:
@@ -1383,8 +1404,8 @@ components:
invalidatedFeeId: {type: integer, format: int64, nullable: true} invalidatedFeeId: {type: integer, format: int64, nullable: true}
UnifiedMainStatus: UnifiedMainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
description: 六态产品主状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态 description: 七态产品主状态;WAIT_PALLETIZE 仅表示一件代发待清点组托
UnifiedInboundQuery: UnifiedInboundQuery:
type: object type: object
properties: properties:
@@ -1393,7 +1414,7 @@ components:
orderNo: {type: string, maxLength: 64, nullable: true} orderNo: {type: string, maxLength: 64, nullable: true}
mainStatus: mainStatus:
type: string type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
nullable: true nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值 description: WAIT_SPLIT 为唯一待分柜筛选值
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
@@ -1511,6 +1532,7 @@ components:
deliveryWarehouseName: {type: string, nullable: true} deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true} businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true} channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true} channelName: {type: string, nullable: true}
taxMode: {type: string, nullable: true} taxMode: {type: string, nullable: true}
@@ -1749,10 +1771,12 @@ components:
type: object type: object
description: >- description: >-
单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的 单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的
尺寸测量原图,不得与 weightResults.fileId 混用。 尺寸测量原图,不得与 weightResults.fileId 混用。fbaShipmentNo 是按 inboundDetailId
从 Order 货物明细实时读取的权威货件编号;scannedShipmentId 是独立设备实扫证据,二者不得回退或覆盖。
required: [id] required: [id]
properties: properties:
id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId} id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId}
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一权威入仓码,与根对象inboundCode一致}
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
itemNo: {type: string, nullable: true} itemNo: {type: string, nullable: true}
boxSpecification: {type: string, nullable: true} boxSpecification: {type: string, nullable: true}
@@ -1781,7 +1805,8 @@ components:
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true}
remark: {type: string, nullable: true} remark: {type: string, nullable: true}
boxCode: {type: string, nullable: true} boxCode: {type: string, nullable: true}
scannedShipmentId: {type: string, nullable: true} fbaShipmentNo: {type: string, nullable: true, description: Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空}
scannedShipmentId: {type: string, nullable: true, description: 设备实扫Shipment ID;仅作现场证据,不覆盖或回退fbaShipmentNo}
confirmedPackageCount: {type: integer, nullable: true} confirmedPackageCount: {type: integer, nullable: true}
machineNo: {type: string, nullable: true} machineNo: {type: string, nullable: true}
deviceMeasuredAt: {type: string, format: date-time, nullable: true} deviceMeasuredAt: {type: string, format: date-time, nullable: true}
@@ -1793,7 +1818,7 @@ components:
required: [inboundId, inboundVersion, items, weightResults] required: [inboundId, inboundVersion, items, weightResults]
properties: properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'} inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, nullable: true} inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段}
companyName: {type: string, nullable: true} companyName: {type: string, nullable: true}
channelCode: {type: string, nullable: true} channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true} channelName: {type: string, nullable: true}
@@ -1979,7 +2004,7 @@ components:
publicRemark: {type: string, nullable: true} publicRemark: {type: string, nullable: true}
expressCompanyName: {type: string, nullable: true} expressCompanyName: {type: string, nullable: true}
expressTrackingNo: {type: string, nullable: true} expressTrackingNo: {type: string, nullable: true}
ContainerBookingPageEnvelope: ContainerWorkbenchPageEnvelope:
type: object type: object
properties: properties:
code: {type: string, example: '200'} code: {type: string, example: '200'}
@@ -1987,14 +2012,78 @@ components:
data: data:
type: object type: object
properties: properties:
records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}} records: {type: array, items: {$ref: '#/components/schemas/ContainerWorkbenchItem'}}
page: {type: string, pattern: '^[1-9][0-9]*$'} page: {type: string, pattern: '^[1-9][0-9]*$'}
size: {type: string, pattern: '^[1-9][0-9]*$'} size: {type: string, pattern: '^[1-9][0-9]*$'}
total: {type: string, pattern: '^[0-9]+$'} total: {type: string, pattern: '^[0-9]+$'}
pages: {type: string, pattern: '^[0-9]+$'} pages: {type: string, pattern: '^[0-9]+$'}
ContainerWorkbenchEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/ContainerWorkbenchItem'}
ContainerWorkbenchItem:
type: object
required: [container, workbenchStage, statusSummary, allowedActions]
properties:
container: {$ref: '#/components/schemas/ContainerBooking'}
workbenchStage: {type: string, enum: [WAIT_LOADING, LOADING, WAIT_CUSTOMS, WAIT_OUTBOUND, DEPARTED_WAREHOUSE, IN_TRANSIT, ARRIVED_DESTINATION_PORT, PICKUP_READY, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
statusSummary: {$ref: '#/components/schemas/ContainerWorkbenchStatusSummary'}
allowedActions:
type: array
description: 已同时按真实业务状态与当前用户权限裁剪
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
ContainerWorkbenchStatusSummary:
type: object
properties:
loadingStatus: {type: string, nullable: true}
customsStatus: {type: string, nullable: true}
outboundStatus: {type: string, nullable: true}
destinationClearanceStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
deliveryOrderStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
pickupCodeStatus: {type: string, enum: [MISSING, UPLOADED]}
latestActualNode: {type: string, nullable: true}
latestActualAt: {type: string, format: date-time, nullable: true}
terminalDrayageStatus: {type: string, nullable: true}
emptyReturnWarningStatus: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED], nullable: true}
ContainerActualFacts:
type: object
nullable: true
description: 仅来自最新船节点任务的actual_at;无任务时为null,不用预计时间推断
properties:
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
queryId: {type: string, pattern: '^[1-9][0-9]*$'}
queryVersion: {type: integer, minimum: 0}
queryStatus: {type: string}
latestActualNode: {type: string, nullable: true}
latestActualAt: {type: string, format: date-time, nullable: true}
departedWarehouseAt: {type: string, format: date-time, nullable: true}
departedOriginPortAt: {type: string, format: date-time, nullable: true}
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
pickupReadyAt: {type: string, format: date-time, nullable: true}
pickedUpAt: {type: string, format: date-time, nullable: true}
emptyReturnedAt: {type: string, format: date-time, nullable: true}
ContainerTerminalDrayage:
type: object
nullable: true
properties:
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: unloadingCompletedAt加72小时}
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {type: object, nullable: true}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
ContainerBooking: ContainerBooking:
type: object type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, pickupOrderDocumentStatus, version] required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties: properties:
id: {type: string, pattern: '^[1-9][0-9]*$'} id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
@@ -2036,7 +2125,7 @@ components:
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间} unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间}
unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间} unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间}
estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计报空时间,由卸柜照片上传成功时间加两天计算} estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计还空时间,统一按卸柜完成时间加72小时计算}
actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间} actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间}
emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本} emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本}
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
@@ -2044,12 +2133,17 @@ components:
destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
deliveryOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} deliveryOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
pickupOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} outboundDispatchStatus: {type: string, nullable: true, description: 国内出仓聚合状态,不使用planStatus推断}
outboundConfirmedAt: {type: string, format: date-time, nullable: true, description: 真实国内离仓确认时间}
pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true, description: PU数字码;字符串保存以保留前导零}
pickupCodeUpdatedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
pickupCodeUpdatedByName: {type: string, nullable: true}
pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
ContainerDocumentStatus: ContainerDocumentStatus:
type: string type: string
enum: [MISSING, UPLOADED, COMPLETED] enum: [MISSING, UPLOADED, COMPLETED]
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED description: 单证聚合状态;DO 无完成动作,因此只返回 MISSING 或 UPLOADED
ContainerSaveRequest: ContainerSaveRequest:
type: object type: object
required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg] required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
@@ -2359,6 +2453,12 @@ components:
file: {type: string, format: binary, description: 用户直接选择的清关资料} file: {type: string, format: binary, description: 用户直接选择的清关资料}
expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0} expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0}
operationReason: {type: string, maxLength: 500, nullable: true} operationReason: {type: string, maxLength: 500, nullable: true}
ContainerPickupCodeRequest:
type: object
required: [pickupCode, expectedVersion]
properties:
pickupCode: {type: string, pattern: '^[0-9]{1,64}$'}
expectedVersion: {type: integer, minimum: 0}
MerchantInboundBillingEnvelope: MerchantInboundBillingEnvelope:
type: object type: object
properties: properties:
@@ -361,6 +361,8 @@ components:
itemId: {$ref: '#/components/schemas/LongId'} itemId: {$ref: '#/components/schemas/LongId'}
accountId: {$ref: '#/components/schemas/LongId'} accountId: {$ref: '#/components/schemas/LongId'}
customerName: {type: string, nullable: true, description: 客户名称快照} customerName: {type: string, nullable: true, description: 客户名称快照}
customsTaxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION], nullable: true, description: 订单/入仓片段冻结的报关方式;不根据退税文件推断}
taxRefundFlag: {type: boolean, nullable: true, description: customsTaxMode为TAX_REFUND时为true、BUY_DECLARATION时为false;缺少权威事实时为null}
orderId: {$ref: '#/components/schemas/LongId'} orderId: {$ref: '#/components/schemas/LongId'}
orderNo: {type: string, description: 订单号快照} orderNo: {type: string, description: 订单号快照}
inboundId: {$ref: '#/components/schemas/LongId'} inboundId: {$ref: '#/components/schemas/LongId'}
@@ -7,6 +7,19 @@ paths:
description: '权限:platform:firstleg:container:list' description: '权限:platform:firstleg:container:list'
parameters: [{$ref: '#/components/parameters/ContainerId'}] parameters: [{$ref: '#/components/parameters/ContainerId'}]
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}} responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
/api/platform/v1/containers/overseas/{id}/overview:
get:
operationId: getOverseasContainerOverview
description: '权限:platform:firstleg:terminal-drayage:list;仅真实国内离仓后的国外工作台货柜'
parameters: [{$ref: '#/components/parameters/ContainerId'}]
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
/api/platform/v1/containers/{id}/pickup-code:
put:
operationId: updateContainerPickupCode
description: '权限:platform:firstleg:terminal-drayage:document:edit;PU资料为1至64位数字码,以字符串保存并保留前导零'
parameters: [{$ref: '#/components/parameters/ContainerId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupCodeRequest'}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
/api/platform/v1/containers/{id}/exception: /api/platform/v1/containers/{id}/exception:
parameters: [{$ref: '#/components/parameters/ContainerId'}] parameters: [{$ref: '#/components/parameters/ContainerId'}]
post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}} post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}}
@@ -20,32 +33,32 @@ paths:
/api/platform/v1/containers/{id}/documents/{documentType}: /api/platform/v1/containers/{id}/documents/{documentType}:
get: get:
operationId: getContainerDocument operationId: getContainerDocument
description: '权限:platform:firstleg:container:list;仅DO/PU,completed恒为false' description: '权限:platform:firstleg:terminal-drayage:list;仅国外工作台DO,completed恒为false'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}] parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}]
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}} responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
/api/platform/v1/containers/{id}/documents/{documentType}/upload: /api/platform/v1/containers/{id}/documents/{documentType}/upload:
post: post:
operationId: uploadContainerDocument operationId: uploadContainerDocument
description: '权限:platform:firstleg:container:edit;仅DO/PU,无完成动作' description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}] parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}} requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}} responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download: /api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
get: get:
operationId: downloadContainerDocument operationId: downloadContainerDocument
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌' description: '权限:platform:firstleg:terminal-drayage:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}} responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download: /api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
get: get:
operationId: downloadContainerDestinationClearance operationId: downloadContainerDestinationClearance
description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载' description: '权限:platform:firstleg:terminal-drayage:list;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}} responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
components: components:
parameters: parameters:
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'} DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER]}, description: '通用单证仅支持DO;PU资料使用 /pickup-code;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'}
requestBodies: requestBodies:
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}} ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
schemas: schemas:
@@ -57,16 +70,17 @@ components:
container: {$ref: '#/components/schemas/Container'} container: {$ref: '#/components/schemas/Container'}
loadingPlanId: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64}
pda: {$ref: '#/components/schemas/PdaSummary'} pda: {$ref: '#/components/schemas/PdaSummary'}
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}} documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER 的当前唯一记录和真实 version;PU码位于 container.pickupCode;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}}
exception: {$ref: '#/components/schemas/ExceptionState'} exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}} Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
ExceptionRequest: {type: object, required: [expectedVersion, reason], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 500}}} ExceptionRequest: {type: object, required: [expectedVersion, reason], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 500}}}
PickupCodeRequest: {type: object, required: [pickupCode, expectedVersion], properties: {pickupCode: {type: string, pattern: '^[0-9]{1,64}$'}, expectedVersion: {type: integer, minimum: 0}}}
@@ -170,104 +170,11 @@ paths:
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500} reason: {type: string, minLength: 1, maxLength: 500}
responses: {'200': {description: 作废后柜级证据列表}} responses: {'200': {description: 作废后柜级证据列表}}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets:
get:
summary: 查询一件代发现场实际托盘、SKU汇总和逐箱明细
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses:
'200':
description: 当前任务未取消的现场托盘
content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/WorkPallet'}}}}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/open:
post:
summary: 为已领取任务开启一个一件代发现场托盘
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/OpenPalletRequest'}}}
responses:
'200': {description: 已开启托盘或相同operationId的幂等结果}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/cartons/scan:
post:
summary: 扫描一件代发箱唛上托;箱唛已在其他未封托托盘时按箱移托
description: 本操作只记录实际箱到托关系,不推进既有拣货数量、订单状态或仓库资源结算。
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ScanCartonRequest'}}}
responses:
'200': {description: 包含最新SKU与箱唛汇总的目标托盘}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/seal:
post:
summary: 封托并校验现场上托箱数未超过既有PDA已提交拣货箱数
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}
responses:
'200': {description: 已封托托盘或A0409数量/版本冲突}
components: components:
parameters: parameters:
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas: schemas:
OpenPalletRequest:
type: object
required: [warehouseId, palletCode, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
warehousePalletId: {type: integer, format: int64, minimum: 1, nullable: true}
palletCode: {type: string, maxLength: 64}
operationId: {type: string, maxLength: 128}
ScanCartonRequest:
type: object
required: [warehouseId, boxCode, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
boxCode: {type: string, maxLength: 80}
operationId: {type: string, maxLength: 128}
SealPalletRequest:
type: object
required: [warehouseId, expectedVersion, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
WorkPallet:
type: object
required: [id, palletCode, status, version, cartonCount, skuCount, customerCount, skus, cartons]
properties:
id: {type: integer, format: int64}
palletCode: {type: string}
warehousePalletId: {type: integer, format: int64, nullable: true}
status: {type: string, enum: [OPEN, SEALED]}
version: {type: integer, minimum: 0}
cartonCount: {type: integer, minimum: 0}
skuCount: {type: integer, minimum: 0}
customerCount: {type: integer, minimum: 0}
skus: {type: array, items: {$ref: '#/components/schemas/WorkPalletSku'}}
cartons: {type: array, items: {$ref: '#/components/schemas/WorkPalletCarton'}}
WorkPalletSku:
type: object
required: [skuCode, cartonCount, productQuantity]
properties:
skuCode: {type: string}
cartonCount: {type: integer, minimum: 1}
productQuantity: {type: integer, format: int64, minimum: 0}
WorkPalletCarton:
type: object
required: [id, boxCode, skuCode, tenantId, inboundId, inboundDetailId]
properties:
id: {type: integer, format: int64}
boxCode: {type: string}
skuCode: {type: string}
tenantId: {type: integer, format: int64}
inboundId: {type: integer, format: int64}
inboundDetailId: {type: integer, format: int64}
unitsPerCarton: {type: integer, minimum: 1, nullable: true}
Task: Task:
type: object type: object
required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items]
@@ -0,0 +1,144 @@
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:terminal-drayage: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:terminal-drayage: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:terminal-drayage: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:terminal-drayage:fleet: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:terminal-drayage:pickup-appointment: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:terminal-drayage:pickup:confirm
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:terminal-drayage:empty-return:confirm
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, actualFacts]
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]}}
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
updatedAt: {type: string, format: date-time, nullable: true}
ContainerActualFacts:
type: object
required: [containerBookingId, queryId, queryVersion, queryStatus]
properties:
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
queryId: {type: string, pattern: '^[1-9][0-9]*$'}
queryVersion: {type: integer, minimum: 0}
queryStatus: {type: string}
latestActualNode: {type: string, nullable: true}
latestActualAt: {type: string, format: date-time, nullable: true}
departedWarehouseAt: {type: string, format: date-time, nullable: true}
departedOriginPortAt: {type: string, format: date-time, nullable: true}
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
pickupReadyAt: {type: string, format: date-time, nullable: true}
pickedUpAt: {type: string, format: date-time, nullable: true}
emptyReturnedAt: {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'}}}}
@@ -18,30 +18,35 @@ class ContainerControllerContractTest {
@Test @Test
@DisplayName("查询、新增和编辑按钮权限彼此独立") @DisplayName("查询、新增和编辑按钮权限彼此独立")
void separatesButtonPermissions() { void separatesButtonPermissions() {
assertPermission("page", "platform:firstleg:container:list"); assertPermission("domesticPage", "platform:firstleg:container:list");
assertPermission("detail", "platform:firstleg:container:list"); assertPermission("overseasPage", "platform:firstleg:terminal-drayage:list");
assertPermission("domesticDetail", "platform:firstleg:container:list");
assertPermission("overseasDetail", "platform:firstleg:terminal-drayage:list");
assertPermission("create", "platform:firstleg:container:create"); assertPermission("create", "platform:firstleg:container:create");
assertPermission("linkOceanBooking", "platform:firstleg:container:edit"); assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
assertPermission("updateDraft", "platform:firstleg:container:edit"); assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage"); assertPermission("uploadClearance", "platform:firstleg:terminal-drayage:document:edit");
assertPermission("overview", "platform:firstleg:container:list"); assertPermission("overview", "platform:firstleg:container:list");
assertPermission("overseasOverview", "platform:firstleg:terminal-drayage:list");
assertPermission("updatePickupCode", "platform:firstleg:terminal-drayage:document:edit");
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list"); assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
assertPermission("markException", "platform:firstleg:container:edit"); assertPermission("markException", "platform:firstleg:container:edit");
assertPermission("cancelException", "platform:firstleg:container:edit"); assertPermission("cancelException", "platform:firstleg:container:edit");
assertPermission("logisticsDocument", "platform:firstleg:container:list"); assertPermission("logisticsDocument", "platform:firstleg:terminal-drayage:list");
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit"); assertPermission("uploadLogisticsDocument", "platform:firstleg:terminal-drayage:document:edit");
assertPermission("downloadDocument", "platform:firstleg:container:list"); assertPermission("downloadDocument", "platform:firstleg:terminal-drayage:list");
assertPermission("downloadClearance", "platform:firstleg:customs:manage"); assertPermission("downloadClearance", "platform:firstleg:terminal-drayage:list");
} }
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception { @Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml") String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/api/platform/v1/containers/overseas/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"/clearance/document/{fileId}/download:", "/clearance/document/{fileId}/download:",
"DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER", "/api/platform/v1/containers/{id}/pickup-code:", "PickupCodeRequest",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "enum: [DELIVERY_ORDER]", "Idempotency-Key", "photoCounts:",
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
"downloadPath: {type: string, nullable: true", "downloadPath: {type: string, nullable: true",
@@ -59,6 +64,9 @@ class ContainerControllerContractTest {
assertThat(section) assertThat(section)
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:") .contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("/api/platform/v1/containers/domestic:")
.contains("/api/platform/v1/containers/overseas:")
.contains("scope, in: query, required: true")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜") .contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId") .contains("RELEASED oceanBookingId")
.contains("未满配 requirementId") .contains("未满配 requirementId")
@@ -72,7 +80,7 @@ class ContainerControllerContractTest {
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:") .contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data") .contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest") .contains("ContainerDocumentUploadRequest")
.contains("platform:firstleg:container:list") .contains("platform:firstleg:terminal-drayage:list")
.contains("EDITING") .contains("EDITING")
.contains("WAIT_LOADING") .contains("WAIT_LOADING")
.contains("LOADING") .contains("LOADING")
@@ -80,6 +88,7 @@ class ContainerControllerContractTest {
.doesNotContain("tenantId") .doesNotContain("tenantId")
.doesNotContain("accountId"); .doesNotContain("accountId");
assertThat(openapi) assertThat(openapi)
.contains("ContainerWorkbenchItem:", "allowedActions")
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:") .contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
.contains("Content-Disposition 与 Cache-Control no-store") .contains("Content-Disposition 与 Cache-Control no-store")
.contains("required: [oceanBookingId, requirementId, expectedVersion]") .contains("required: [oceanBookingId, requirementId, expectedVersion]")

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