diff --git a/AGENTS.md b/AGENTS.md index 4f91787f8..2bde3d69b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ | 任务关键词 | 首选服务 | 先看入口 | |---|---|---| -| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | @@ -27,7 +27,7 @@ | 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | | 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | | 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 | -| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。首张称重确认分配整票块,测量提交只复用;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | @@ -36,8 +36,9 @@ | 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | | 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | +| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | -| 客户资金账户、充值、授信与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 Finance `FinanceWalletService` 拥有唯一 CNY 账本;平台/客户入口分别见 `PlatformFinanceWalletController`、`ClientFinanceWalletController`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | | 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | @@ -50,13 +51,13 @@ | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | -| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只分页返回仍有入仓码待生成有效独立计划的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | | 海外异常退回与客户回访 | `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/` | | 海外仓 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 候选进入可收货 | @@ -112,6 +113,7 @@ - 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 +- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 9894151dd..8d8cd4021 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -230,9 +230,9 @@ SELECT ## 海外排车、出库与派送权限 - 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。 -- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建和客户回访列表/编辑十四项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。 +- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建/处理和客户回访列表/编辑十五项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。 - 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。 -- `WAREHOUSE_OPERATOR` 获得十四项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。 +- `WAREHOUSE_OPERATOR` 获得十五项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。 ## 海外排车与出库结构升级 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 816cb5eaa..808530f7a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2860,6 +2860,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', @@ -2877,7 +2878,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( PRIMARY KEY (id), 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), - 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), CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) 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_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', 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 '最终计费量', charge_unit VARCHAR(16) 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 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 ( 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 ( 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 '整表版本号', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', 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 '产品图片访问地址快照', package_count INT UNSIGNED NOT 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 '每箱产品数', pallet_count INT UNSIGNED NULL COMMENT '托盘数', 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_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', - active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( - CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END - ) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + 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_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 '装柜明细最后更新时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + 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_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_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), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), 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) ) 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; DELIMITER $$ @@ -6276,12 +6249,13 @@ BEGIN 'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'product_image_file_id', 'pallet_count', 'net_weight_kg', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', - 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + '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 = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; 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' 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:三期分柜、预装、拣货、装柜和派送当前基线 -- ============================================================================ @@ -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'); +-- 已存在库必须先把装柜分配唯一键收敛到“普通明细或封托托盘”,再执行三期断言。 +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; 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_unit', '计费单位'); 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_by', '确认人ID'); 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', 'tolerance_percent', '人工调整允许的最大偏差百分比,边界包含'); 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', 'customer_id', '客户ID快照'); @@ -15153,6 +15189,15 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_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 WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' AND column_name='dimension_initialization_status') <> 1 @@ -15607,6 +15652,11 @@ BEGIN AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' 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 + 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 ( SELECT 1 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 = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list') 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 = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list') 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: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:process' AND id <> 5107073) 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: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), (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), + (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), (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), @@ -17032,6 +17085,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', '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:exception-order:list'), ('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:status'), ('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:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', @@ -17131,12 +17187,13 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 16 THEN + 'merchant:warehouse:delivery:create')) <> 17 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -17154,10 +17211,11 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', '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='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; IF EXISTS (SELECT 1 FROM iam_role role @@ -17178,17 +17236,18 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', '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='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; END IF; SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count 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 SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; @@ -17207,6 +17266,7 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', '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', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', 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 '已上托箱数', + discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_name VARCHAR(64) 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), 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_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合托任务'; 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 '客户隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', - pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', - pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', + pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', seal_operation_id VARCHAR(128) 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), 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 chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) + CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED')) ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; 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 ( 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 '客户隔离键', 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', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', - box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', - operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', - bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码', + expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量', + 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_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', + bound_at DATETIME(3) NULL COMMENT '上托时间', PRIMARY KEY (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_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_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) ) 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。 USE baiyue_auth; DROP PROCEDURE IF EXISTS auth_add_login_phone; @@ -18116,6 +18332,249 @@ END$$ DELIMITER ; CALL 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(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 37998e162..8f92cd0ce 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -219,6 +219,7 @@ SET @ddl = (SELECT IF(EXISTS ( WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' ), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET @ddl = (SELECT IF(EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' @@ -517,6 +518,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; +ALTER TABLE ll_order + MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空'; CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', @@ -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_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 '拣货状态', - 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), 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) @@ -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 '实际派送时间', 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', + 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), 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 '关联出库单号', 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 '退回物流单号', - 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', - 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), 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) ) 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 ( 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 '客户联系人姓名快照', @@ -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_message'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_message VARCHAR(500) NULL COMMENT ''资金错误说明'' AFTER wallet_error_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_command_event_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_command_event_id VARCHAR(64) NULL COMMENT ''当前资金指令事件ID'' AFTER wallet_error_message')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -1786,3 +1815,176 @@ SET @ddl = (SELECT IF( 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)''')); 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; diff --git a/file/AGENTS.md b/file/AGENTS.md index 8b9d22a1a..f0fe0b229 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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/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 精确校验 | +| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 入库扣货凭证 | 通用内部业务文件入口;固定 `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/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index cd5b1f630..ae0b8cc5a 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -21,6 +21,7 @@ enum BusinessFileCategory { CONTAINER_PICKUP_ORDER(false, 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_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_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 7c11d291a..2a4a61cb0 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -6,7 +6,7 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花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', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', diff --git a/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml new file mode 100644 index 000000000..858cef8d5 --- /dev/null +++ b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml @@ -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} diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 4aeac5255..0c1f0ee6a 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -315,4 +315,45 @@ class InternalBusinessFileServiceTest { .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("归属不匹配"); + } + } diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java new file mode 100644 index 000000000..f2f826bf9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -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); + } + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index ebb81a42e..28c98fb0e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -43,6 +43,7 @@ | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 | diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index 302d466c7..cdcc88bd8 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -16,7 +16,7 @@ const sliceRules = [ ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], - ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ac30d0298..ee11c2d60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -63,12 +63,20 @@ public interface OrderCargoClient { } record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, - String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, - BigDecimal heightCm) { + String skuNo, Integer packageCount, Integer unitsPerCarton, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + String shipmentId) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { - this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); + 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); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java new file mode 100644 index 000000000..b28eedff7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java @@ -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 reserve( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index dd10cd1a4..e4e43d904 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.service.ContainerService; +import com.baiyue.firstleg.terminaldrayage.service.ContainerWorkbenchService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingListService; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; @@ -39,21 +41,50 @@ import org.springframework.web.multipart.MultipartFile; @RequestMapping("/api/platform/v1/containers") public class PlatformContainerController { @Resource private ContainerService service; + @Resource private ContainerWorkbenchService workbenches; @Resource private LoadingListService loadingLists; @Resource private ContainerOverviewService overview; - @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, - @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); + @GetMapping("/domestic") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> domesticPage(@AuthenticationPrincipal AuthenticatedUser user, + @Valid Query query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticPage(query, user.permissions())); } - @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long id) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id)); + @GetMapping("/overseas") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ApiResponse> overseasPage(@AuthenticationPrincipal AuthenticatedUser user, + @Valid Query query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasPage(query, user.permissions())); + } + @GetMapping("/domestic/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse 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 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')") public ApiResponse 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 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 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") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @@ -61,6 +92,7 @@ public class PlatformContainerController { @PathVariable @Positive long id, @PathVariable @Positive long fileId) { FirstlegScopeGuard.platform(user); + service.requireDomestic(id); var value = overview.downloadPhoto(id, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline() @@ -71,20 +103,20 @@ public class PlatformContainerController { public ApiResponse markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @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')") public ApiResponse cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @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')") - public ApiResponse logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);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')") - public ApiResponse 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));} - @GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ResponseEntity 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());} + @GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ApiResponse 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:terminal-drayage:document:edit')") + public ApiResponse 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:terminal-drayage:list')") + public ResponseEntity 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')") @BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @@ -104,21 +136,23 @@ public class PlatformContainerController { return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); } @GetMapping("/{id}/clearance") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") @Operation(summary = "查询货柜目的港清关资料") public ApiResponse clearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.containerClearance(id)); } @GetMapping("/{id}/clearance/document/{fileId}/download") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") public ResponseEntity downloadClearance( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @PathVariable @Positive long fileId) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); var download = loadingLists.downloadContainerClearance(id, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() @@ -128,19 +162,20 @@ public class PlatformContainerController { } @PostMapping("/{id}/clearance/document") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") @Operation(summary = "上传或替换货柜目的港清关资料") public ApiResponse attachClearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody DocumentAttachRequest request) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.attachContainerClearance( id, user.userId(), user.username(), key, request)); } @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 = "直接上传或替换货柜目的港清关资料") public ApiResponse uploadClearance( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @@ -149,18 +184,20 @@ public class PlatformContainerController { @RequestParam("expectedVersion") @Min(0) int expectedVersion, @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.uploadContainerClearance( id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); } @PostMapping("/{id}/clearance/complete") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") @Operation(summary = "确认货柜目的港清关完成") public ApiResponse completeClearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody DocumentCompleteRequest request) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.completeContainerClearance( id, user.userId(), user.username(), key, request)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index 337dc2fb0..62f93446e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -62,23 +62,44 @@ public class PlatformDropshipPalletizationController { return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username())); } - @PostMapping("/tasks/{taskId}/pallets/open") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse open(@AuthenticationPrincipal AuthenticatedUser user, + @GetMapping("/tasks/{taskId}/cartons/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse resolveCarton( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long taskId, - @Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) { + @RequestParam @Positive long warehouseId, + @RequestParam @Size(max = 80) String boxCode) { 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')") - public ApiResponse scan( + public ApiResponse countCarton( @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, @PathVariable @Positive long palletId, - @Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) { + @PathVariable @Positive long taskId, + @Valid @RequestBody DropshipPalletizationDtos.CountCartonRequest request) { 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 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 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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index c83eb68ad..569cfa5e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -49,7 +49,8 @@ public class PlatformInboundMeasurementController { @Resource private MeasurementSubmissionService submission; @Resource private CartonMeasurementService cartonMeasurements; - @Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。") + @Operation(summary = "查询入库测量详情", description = "返回唯一入仓码、原单、实测箱规、审核快照和 OCR 重量结果;" + + "箱规行按 inboundDetailId 返回 Order 权威 FBA 货件编号,并保留独立设备实扫值。") @GetMapping("/measurement") @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')") public ApiResponse detail( @@ -78,7 +79,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") - @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") + @Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + + "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。") @PutMapping("/measurement/draft") @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," + " 'platform:firstleg:inbound:measure:edit')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 0449991c8..81cedd891 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; @@ -24,11 +25,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; 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.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -56,8 +52,6 @@ public class PlatformLoadingPdaController { private LoadingPickService service; @Resource private CabinetLoadingEvidenceService evidenceService; - @Resource - private LoadingWorkPalletService palletService; @GetMapping("/tasks") @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())); } + @PostMapping("/tasks/{taskId}/pallets/pick") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse 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") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse partial(@AuthenticationPrincipal AuthenticatedUser user, @@ -226,44 +229,4 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.operations(taskId)); } - @GetMapping("/tasks/{taskId}/pallets") - @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") - public ApiResponse> 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 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 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 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())); - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java index ecb4991ea..af13b112d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java @@ -38,9 +38,10 @@ public class PlatformPriceBookController { @Operation(summary = "查询船期价格表版本") public ApiResponse> list( @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, + @RequestParam(defaultValue = "0") long customerId, @RequestParam(required = false) PriceBookStatus status) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.list(status)); + return ApiResponse.success(service.list(customerId, status)); } @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 = "上传船期价格表并等待确认") public ApiResponse upload( @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, + @RequestParam(defaultValue = "0") long customerId, @RequestPart("file") MultipartFile file) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.upload(user.userId(), file)); + return ApiResponse.success(service.upload(user.userId(), customerId, file)); } @GetMapping("/{versionId}") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index 0772819e6..b1cf08c53 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -24,6 +24,10 @@ public class PlatformSmartLoadingController { public ApiResponse 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')") public ApiResponse 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 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 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')") public ApiResponse 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 4a1cbb272..b20580354 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -19,6 +19,8 @@ import java.time.LocalDateTime; public final class ContainerDtos { private ContainerDtos() {} + public enum WorkbenchScope { ACTIVE, HISTORY } + @Schema(name = "ContainerQuery", description = "货柜分页筛选条件") public record Query( @Size(max = 64) String containerNo, @@ -35,6 +37,7 @@ public final class ContainerDtos { SmartLoadingStatus status, LocalDateTime estimatedSailingFrom, LocalDateTime estimatedSailingTo, + @NotNull WorkbenchScope scope, @Min(1) long page, @Min(1) @Max(200) long size ) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 07375297d..0a742df39 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; import java.time.LocalDateTime; import java.util.List; @@ -21,7 +22,8 @@ public final class ContainerOverviewDtos { LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt, LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt, - Long emptyReportSourceVersion, + Long emptyReportSourceVersion, String pickupCode, Long pickupCodeUpdatedBy, + String pickupCodeUpdatedByName, LocalDateTime pickupCodeUpdatedAt, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, 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 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 PickupCodeRequest(@NotBlank @Size(max=64) @Pattern(regexp="^[0-9]+$", message="PU码只能包含数字") String pickupCode, + @NotNull @Min(0) Integer expectedVersion) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java index f03398ba2..75027404b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -1,7 +1,8 @@ package com.baiyue.firstleg.dto; -import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -14,13 +15,23 @@ public final class DropshipPalletizationDtos { public record ClaimRequest(@Positive long warehouseId) {} - public record OpenPalletRequest(@Positive long warehouseId, - @NotBlank @Size(max = 128) String operationId) {} + public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE } - public record ScanRequest(@Positive long warehouseId, - @NotBlank @Size(max = 64) String boxCode, - @Positive @Max(2000) int cartonCount, - @NotBlank @Size(max = 128) String operationId) {} + public record CountCartonRequest(@Positive long warehouseId, + @NotBlank @Size(max = 80) String boxCode, + @Positive int actualSkuQuantity, + @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, @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, 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) {} public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId, 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, - List pallets) {} + List pallets, List unbound) {} - public record Pallet(Long palletId, String palletNo, String palletStatus, - Integer cartonCount, Integer skuCount, Integer version, - List items, LocalDateTime completedAt) {} + public record Pallet(Long palletId, String palletNo, String status, + Integer cartonCount, Integer skuCount, Integer discrepancyCartons, + java.math.BigDecimal weightKg, java.math.BigDecimal volumeCbm, + Integer version, List items, List cartons, + LocalDateTime sealedAt) {} public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId, - String skuCode, String cargoName, Integer cartonCount, + String skuCode, String cargoName, Integer cartonCount, Integer skuQuantity, List cartonMarks) {} - public record ScanResult(String skuCode, String cargoName, String inboundCode, - int addedCartons, int allocatedCartons, int remainingCartons, - Pallet pallet) {} + public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode, + Long inboundDetailId, String skuCode, String cargoName, + 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index 6ceff5682..84aa64bde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -80,7 +81,8 @@ public final class FbaPricingDtos { @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @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( @@ -96,7 +98,9 @@ public final class FbaPricingDtos { @NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale, @NotBlank String roundingMode, 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 totalAmount, String currency, LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 8d118da90..7934f7b4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -75,6 +75,8 @@ public final class InboundMeasurementResponses { public record Item( Long id, + @Schema(description = "当前测量详情唯一权威入仓码,与根对象 inboundCode 一致") + String inboundCode, Long inboundDetailId, String itemNo, String boxSpecification, @@ -99,6 +101,9 @@ public final class InboundMeasurementResponses { String coefficientMode, String remark, String boxCode, + @Schema(description = "Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空,禁止回退设备实扫值") + String fbaShipmentNo, + @Schema(description = "设备从Amazon FBA箱标实扫的Shipment ID;仅作现场证据,不覆盖或回退权威货件编号") String scannedShipmentId, Integer confirmedPackageCount, String machineNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 58dbe8e4e..91925fea7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -177,6 +177,8 @@ public final class LoadingListDtos { Long itemId, Long accountId, String customerName, + String customsTaxMode, + Boolean taxRefundFlag, Long orderId, String orderNo, Long inboundId, @@ -188,6 +190,10 @@ public final class LoadingListDtos { String chineseName, String englishName, String goodsImageUrl, + String unitType, + Long palletId, + String palletNo, + Boolean indivisible, int packageCount, int palletCount, BigDecimal grossWeightKg, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java deleted file mode 100644 index 3bb836838..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java +++ /dev/null @@ -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 skus, List cartons) {} -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index bb7818725..436094503 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -23,6 +23,13 @@ public final class LoadingPickDtos { @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 明细的最终实际拣出箱数,不是增量。 */ public record PartialPickRequest( @NotNull @Min(1) Long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index cad3a507c..f0e0f4317 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -23,6 +23,7 @@ public final class PriceBookDtos { public record PriceBookVersionSummary( Long id, + Long customerId, Integer versionNo, String originalFileName, Long sourceFileId, @@ -119,9 +120,20 @@ public final class PriceBookDtos { @Size(max = 128) String destinationName, PriceRegion destinationRegion, PricingTaxMode pricingTaxMode, + @Pattern(regexp = "KG|CBM") String chargeMode, @DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg, @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( Long priceBookVersionId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index d8b8048fc..acca35f80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -22,11 +22,14 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, String customerName, Integer availablePackageCount, 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, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, + String unitType,Long palletId,String palletNo,Boolean indivisible, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Boolean locked, Boolean canAssign, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt, @@ -66,6 +69,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record AssignGroupRequest(@NotNull @Positive Long inboundId, @NotNull @Min(0) Integer expectedVersion, @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, @NotBlank @Size(max=128) String operationId) {} public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 16956b682..0a82a59ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -10,12 +10,12 @@ import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; -/** 船司 FBA 目的仓价格的不可变版本。 */ +/** 公共或客户专属的头程报价不可变版本。 */ @Getter @Setter @TableName("fl_carrier_fba_rate") public class CarrierFbaRateEntity extends BaseEntity { - /** 船司价格是平台公共主数据,不绑定任何客户。 */ + /** 0 为平台公共报价,正数为客户专属报价作用域。 */ public static final long PLATFORM_SCOPE = 0L; @TableId(type = IdType.ASSIGN_ID) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 1279f8a48..6f6244d5c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -60,6 +60,10 @@ public class ContainerBookingEntity { private LocalDateTime estimatedEmptyReportedAt; private LocalDateTime actualEmptyReportedAt; private Long emptyReportSourceVersion; + private String pickupCode; + private Long pickupCodeUpdatedBy; + private String pickupCodeUpdatedByName; + private LocalDateTime pickupCodeUpdatedAt; private Long createdBy; private String createdByName; @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 customsDeclarationDocumentStatus; @TableField(exist = false) private String deliveryOrderDocumentStatus; - @TableField(exist = false) private String pickupOrderDocumentStatus; + @TableField(exist = false) private String outboundDispatchStatus; + @TableField(exist = false) private LocalDateTime outboundConfirmedAt; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java index 6e9d7d252..7703c74d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java @@ -23,6 +23,7 @@ public class InboundCartonLabelEntity { private Integer cargoItemNo; private String cargoName; private String skuCode; + private Integer unitsPerCarton; private Integer detailPackageCount; private String inboundCode; private String boxCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java index 667d3a527..bf8158474 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -54,6 +54,10 @@ public class InboundFeeEntity { private BigDecimal actualWeightKg; private BigDecimal actualVolumeCbm; private BigDecimal convertedQuantity; + /** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */ + private BigDecimal baselineChargeableQuantity; + /** 人工计费量相对系统基准允许的最大偏差百分比。 */ + private BigDecimal tolerancePercent; private BigDecimal chargeableQuantity; private String chargeUnit; private String currency; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java index 0f12680ca..967b686da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -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 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 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 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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java deleted file mode 100644 index a0072c7bc..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java +++ /dev/null @@ -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; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java deleted file mode 100644 index 169f984e2..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java +++ /dev/null @@ -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; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 138e5e138..16335a476 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -40,8 +40,14 @@ public interface ContainerBookingMapper extends BaseMapper """) - Page selectBookingPage(Page page, @Param("query") Query query); + Page selectBookingPage(Page page, + @Param("query") Query query, + @Param("workbench") String workbench); @Select(""" SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no, @@ -100,8 +126,14 @@ public interface ContainerBookingMapper extends BaseMapper 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 documents(@Param("id") long id); @Select(""" SELECT history.id,plan.container_booking_id containerBookingId, JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java new file mode 100644 index 000000000..7afc62583 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java @@ -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(""" + + """) + List 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 membersForUpdate(@Param("containerBookingId") long containerBookingId, + @Param("customerId") long customerId); + + record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg, + BigDecimal actualVolumeCbm) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index 86d5266e7..10bf417c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -1,203 +1,182 @@ package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask; +import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate; import lombok.Getter; import lombok.Setter; -import org.apache.ibatis.annotations.Insert; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.*; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; -/** 一件代发合托持久化;任务、托盘和逐箱绑定均独立于装柜拣货。 */ +/** 一件代发逐箱清点、待绑托与封托事实。 */ @Mapper public interface DropshipPalletizationMapper { - @Insert(""" - 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) - VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) - """) - int insertTask(@Param("id") long id, @Param("tenantId") long tenantId, - @Param("inboundId") long inboundId, @Param("warehouseId") long warehouseId, - @Param("totalCartons") int totalCartons); + @Insert(""" + INSERT IGNORE INTO fl_dropship_pallet_task + (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,0,0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""") + int insertTask(@Param("id") long id,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId, + @Param("warehouseId") long warehouseId,@Param("totalCartons") int totalCartons); - @Select(""" - 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 - 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.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},'%') - OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) - ORDER BY t.created_at,t.id - """) - List pending(@Param("operatorId") long operatorId, @Param("keyword") String keyword); + @Select(""" + 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.counted_cartons,t.allocated_cartons bound_cartons, + t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at + 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.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},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) + ORDER BY t.created_at,t.id""") + List pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword); - @Select(""" - 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 - WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId} - """) - TaskRow task(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId); + @Select(""" + 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 + WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""") + TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId); - @Update(""" - UPDATE fl_dropship_pallet_task - 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) - WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') - 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); + @Update(""" + UPDATE fl_dropship_pallet_task 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) + WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') + 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); - @Insert(""" - 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) - VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},'OPEN',#{operationId},1, - #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) - """) - int insertPallet(@Param("id") long id, @Param("taskId") long taskId, - @Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("warehouseId") long warehouseId, @Param("operationId") String operationId, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Insert(""" + INSERT INTO fl_dropship_pallet + (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},#{palletNo},'OPEN',#{operationId},1, + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""") + int insertPallet(@Param("id") long id,@Param("taskId") long taskId,@Param("tenantId") long tenantId, + @Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo, + @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}") - PalletRow palletByOpenOperation(@Param("operationId") String operationId); + @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo); + @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 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 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}") - PalletRow palletBySealOperation(@Param("operationId") String operationId); + @Select(""" + 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}") - PalletRow pallet(@Param("taskId") long taskId, @Param("palletId") long palletId, - @Param("warehouseId") long warehouseId); + @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, + 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") - List pallets(@Param("taskId") long taskId); + @Update(""" + 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#{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(""" - 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 palletsByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Update(""" + UPDATE fl_dropship_pallet SET pallet_status='SEALED',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}) + 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") - 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.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List cartons(@Param("palletId") long palletId); + @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 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 palletCandidates(@Param("inboundId") long inboundId); - @Select(""" - SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, - 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 p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_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 destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name, + c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count, + 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, + 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 sealedPalletLines(@Param("palletId") long palletId); - @Select(""" - SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, - l.inbound_code,l.box_code - FROM fl_inbound_carton_label l - WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} - AND l.inbound_detail_id=#{detailId} AND l.batch_status='ACTIVE' - 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 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 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; - } + @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; } + @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } + @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; } + @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } + record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} + @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; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index dc62041cb..9d4493422 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -91,7 +91,7 @@ public interface LoadingCompletedEventMapper { 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.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 """) List actualPalletRows(@Param("planId") long planId); @@ -99,7 +99,7 @@ public interface LoadingCompletedEventMapper { @Select(""" 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 - 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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 97c77cfaa..23ff26071 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -168,10 +168,18 @@ public interface LoadingListQueryMapper { @Param("operatorName") String operatorName); @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.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), CEIL(COALESCE(NULLIF(inbound.pallet_count,0), (SELECT COUNT(*) FROM fl_inbound_weight_result weight @@ -409,6 +417,8 @@ public interface LoadingListQueryMapper { private Long itemId; private Long accountId; private String customerName; + private String customsTaxMode; + private Boolean taxRefundFlag; private Long orderId; private String orderNo; private Long inboundId; @@ -420,6 +430,10 @@ public interface LoadingListQueryMapper { private String productNameZh; private String productNameEn; private String productImageUrl; + private String unitType; + private Long palletId; + private String palletNo; + private Boolean indivisible; private Integer packageCount; private Integer palletCount; private BigDecimal grossWeightKg; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java deleted file mode 100644 index d8caf8f89..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java +++ /dev/null @@ -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 { - @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 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 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; - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java deleted file mode 100644 index e1b6ba90c..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java +++ /dev/null @@ -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 { - @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 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) {} -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 418095224..e85a6daa8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -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(*), COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) 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 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 @@ -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(*), COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) 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 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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 4b76ab3a0..de6190798 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; 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.Detail; 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.validation.Valid; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.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; @@ -47,6 +50,17 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.carriers()); } + @Operation(summary = "按货柜批量查询实际船节点摘要", + description = "最多200柜;仅返回实际节点时间,不返回或推断预计时间;没有船节点任务的货柜不返回行。") + @GetMapping("/container-actual-facts") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") + public ApiResponse> 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 @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 6ff80c834..0bbfb2713 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -80,6 +80,17 @@ public final class CarrierScheduleQueryDtos { 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, String providerNodeCode, String standardNode, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 274bff0d6..283efa2e0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -46,6 +47,33 @@ public interface CarrierScheduleQueryMapper extends BaseMapper + 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 + #{id} + 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 + + """) + List selectContainerActualFacts( + @Param("containerBookingIds") List containerBookingIds); + @Select(""" SELECT * FROM fl_carrier_schedule_query WHERE status IN ('READY','MANUAL_REQUIRED') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index 7df760374..c83cb6239 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.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.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; @@ -40,6 +41,9 @@ public class CarrierScheduleQueryRepository { public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); } + public List containerActualFacts(List containerBookingIds) { + return queries.selectContainerActualFacts(containerBookingIds); + } public List due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { return queries.claimLease(id, owner, now, leaseUntil) == 1; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 9a0bc0916..308f71969 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; 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.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; @@ -48,6 +49,7 @@ import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HexFormat; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -76,6 +78,18 @@ public class CarrierScheduleQueryService { source.page(), source.size(), source.total(), source.pages()); } + public List containerActualFacts(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) { + throw badRequest("containerBookingIds数量必须在1到200之间"); + } + Set 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 List history(long id) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index 8b708f74d..7c01a22c5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -32,6 +32,16 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { """) 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 c.price_book_version_id AS priceBookVersionId, 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 WHERE r.price_book_version_id = c.price_book_version_id 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 ) 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 FROM fl_carrier_fba_rate r 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.pricing_tax_mode IS NOT NULL 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 FROM fl_carrier_fba_rate r 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 UPPER(r.destination_code) = #{destinationCode} 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 FROM fl_carrier_fba_rate r 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.destination_warehouse_id = #{destinationWarehouseId} 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 FROM fl_carrier_fba_rate r 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.price_model = 'REGION_WEIGHT_TIER' AND r.region_code = #{regionCode} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java index 094003ec2..e3f07440e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -16,6 +16,9 @@ public class PriceBookQueryService implements PriceBookQueryUseCase { @Resource private PriceBookQueryReader reader; @Override public Long activeVersionId() { return reader.findActiveVersionId(); } + @Override public Long effectiveVersionId(long customerId) { + return reader.findEffectiveVersionId(customerId); + } @Override public List channels(long versionId, long originWarehouseId) { return reader.findChannels(versionId, originWarehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java index 980c86ad7..681279a2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryUseCase { Long activeVersionId(); + Long effectiveVersionId(long customerId); List channels(long versionId, long originWarehouseId); List channelTaxModes(long versionId, long originWarehouseId); List fbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java index 04d852aab..d12366820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryReader { Long findActiveVersionId(); + Long findEffectiveVersionId(long customerId); List findChannels(long versionId, long originWarehouseId); List findChannelTaxModes(long versionId, long originWarehouseId); List findFbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 9eba2b66d..6b7930f75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -27,10 +27,14 @@ public class ActivePriceBookEstimator { @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; 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) { throw validation("请选择有效起运仓"); } - Long versionId = repository.activeVersionId(); + Long versionId = repository.effectiveVersionId(customerId); if (versionId == null) { throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本"); } @@ -65,6 +69,8 @@ public class ActivePriceBookEstimator { } else { throw validation("私人地址暂不支持参考估价"); } + String chargeMode = requiredChargeMode(request); + rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList(); RateRow rate = uniqueRate(rates); requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE); var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm()); @@ -81,6 +87,9 @@ public class ActivePriceBookEstimator { if (request.pricingTaxMode() == null) { throw validation("请选择包税或不包税报价"); } + if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) { + throw validation("区域重量阶梯仅支持KG计费"); + } PriceRegion region = request.destinationRegion() == null ? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion(); List tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode, @@ -113,6 +122,19 @@ public class ActivePriceBookEstimator { 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) { if (rate == null || !expected.name().equals(rate.getPriceModel())) { throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java new file mode 100644 index 000000000..e8a23645c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java @@ -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 source, BigDecimal unitPrice) { + if (source == null || source.isEmpty() || unitPrice == null || unitPrice.signum() < 0) { + throw validation("柜级阶梯计费参数无效"); + } + List 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 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 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 memberAmounts) {} + private record Share(long inboundId, BigDecimal amount, BigDecimal remainder) { + private Share add(BigDecimal increment) { + return new Share(inboundId, amount.add(increment), remainder); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index e6024bc4c..cfc60b9d2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -12,15 +12,16 @@ import org.springframework.stereotype.Repository; @Repository public class ContainerRepository { @Resource private ContainerBookingMapper mapper; - public PageResult page(Query query) { + public PageResult page(String workbench, Query query) { Page result = mapper.selectBookingPage( - new Page<>(query.page(), query.size()), query); + new Page<>(query.page(), query.size()), query, workbench); result.getRecords().forEach(this::resolveLoadingTime); return PageResult.from(result); } public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); } public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(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) { if (no == null) return false; return mapper.selectCount(new LambdaQueryWrapper() @@ -45,6 +46,9 @@ public class ContainerRepository { public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String 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 hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; } public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 26c6ffcda..64afbbf5c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -18,6 +18,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -40,6 +41,17 @@ public class FbaPricingRepository { private InboundPricingIdempotencyMapper idempotencies; @Resource private InboundOutboxMapper outbox; + @Resource + private ContainerTierPricingMapper containerTierPricing; + + public List containerTierMembers(long customerId, long inboundId) { + return containerTierPricing.members(customerId, inboundId); + } + + public List containerTierMembersForUpdate( + long containerBookingId, long customerId) { + return containerTierPricing.membersForUpdate(containerBookingId, customerId); + } public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } @@ -171,7 +183,7 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType()) - .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode()) + .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode()) .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") .le(CustomerChargeRuleEntity::getEffectiveFrom, at) .and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo) @@ -304,6 +316,19 @@ public class FbaPricingRepository { return inbounds.update(null, update); } + public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus, + String chargeMode, java.math.BigDecimal chargeable) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .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) { return fees.update(null, new LambdaUpdateWrapper() .eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 33289ea30..0d4a61a4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -184,10 +184,12 @@ public class LoadingListRepository { private List items(List rows) { 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.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.getDimensionalWeightKg()), value(row.getProductQuantity()), integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java index 188b90172..7c03889ce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java @@ -32,39 +32,50 @@ public class PriceBookRepository { public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); } public int updateVersion(PriceBookVersionEntity value) { return versions.update(value, new LambdaUpdateWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, value.getTenantId()) .eq(PriceBookVersionEntity::getId, value.getId())); } public PriceBookVersionEntity findVersion(long id) { return versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getId, id) .eq(PriceBookVersionEntity::getDeleted, false)); } public List listVersions(String status) { + return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + public List listVersions(long customerId, String status) { return versions.selectList(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status) .eq(PriceBookVersionEntity::getDeleted, false) .orderByDesc(PriceBookVersionEntity::getCreatedAt)); } public PriceBookVersionEntity findActiveForUpdate() { + return findActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public PriceBookVersionEntity findActiveForUpdate(long customerId) { return versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public List listActiveForUpdate() { + return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public List listActiveForUpdate(long customerId) { return versions.selectList(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public PriceBookVersionEntity findActive() { + return findActive(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public PriceBookVersionEntity findActive(long customerId) { return versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false)); } @@ -74,14 +85,20 @@ public class PriceBookRepository { * ACTIVE version exists (both transactions could otherwise pass the check). */ public void lockPlatformVersions() { + lockVersions(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public void lockVersions(long customerId) { versions.selectList(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public int nextVersionNo() { + return nextVersionNo(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public int nextVersionNo(long customerId) { PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE")); return latest == null ? 1 : latest.getVersionNo() + 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index e542d0273..5ebe83126 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -97,6 +97,11 @@ public class SmartLoadingRepository { .eq(LoadingPlanItemEntity::getInboundId, inboundId) .eq(LoadingPlanItemEntity::getDeleted, false)); } + public List activeItemsByPallet(long palletId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getPalletId, palletId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity planByBooking(long bookingId) { return plans.selectOne(new LambdaQueryWrapper() @@ -121,6 +126,13 @@ public class SmartLoadingRepository { .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) .eq(LoadingPlanItemEntity::getDeleted, false)); } + public LoadingPlanItemEntity activePalletItem(long planId, long palletId, long detailId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getPalletId, palletId) + .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } public LoadingPlanHistoryEntity operation(String operationId) { return histories.selectOne(new LambdaQueryWrapper() .eq(LoadingPlanHistoryEntity::getOperationId, operationId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 8192fba2b..6556b6a4c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -51,7 +51,9 @@ public class ContainerOverviewService { booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(), booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(), 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, new ExceptionState(latestException != null && "MARK".equals(latestException.action()), latestException == null ? null : latestException.reason(), @@ -72,6 +74,18 @@ public class ContainerOverviewService { 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, ExceptionRequest request, String action) { String operationId = operationId(rawKey); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index afa4f201a..4fbae5f56 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -9,7 +8,6 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; 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.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; @@ -41,11 +39,15 @@ public class ContainerService { @Resource private SmartLoadingService smartLoadingService; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; - public PageResult page(Query query) { - validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); - return repository.page(normalize(query)); + public void requireOverseas(long id) { + required(id); + 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 public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) { @@ -220,12 +222,6 @@ public class ContainerService { if (from != null && to != null && to.isBefore(from)) throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确"); } - private static Query normalize(Query q) { - return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), - q.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) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index 08253dbb3..7394ba725 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -4,194 +4,193 @@ 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.dto.DropshipPalletizationDtos; -import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; -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.dto.DropshipPalletizationDtos.*; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*; import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.math.BigDecimal; +import java.util.*; -/** 测量完成即下发的一件代发合托,不创建国内仓库托盘、库位或库存。 */ +/** 一件代发测量后的逐箱清点、待绑托和封托,不创建第二套装柜工作托盘。 */ @Service public class DropshipPalletizationService { - @Resource private DropshipPalletizationMapper mapper; - @Resource private FirstlegDocumentNumberGenerator numbers; + @Resource private DropshipPalletizationMapper mapper; - public void createTask(InboundOrderEntity inbound) { - if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; - if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { - throw invalid("一件代发测量完成箱数无效,不能下发合托任务"); - } - mapper.insertTask(IdWorker.getId(), inbound.getTenantId(), inbound.getId(), - inbound.getWarehouseId(), inbound.getTotalPackageCount()); - } + public void createTask(InboundOrderEntity inbound) { + if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; + if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务"); + mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount()); + } - public List pending(long operatorId, String keyword) { - return mapper.pending(operatorId, normalize(keyword)); - } + public List pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); } - @Transactional - public Task claim(long taskId, long warehouseId, long operatorId, String operatorName) { - if (mapper.claim(taskId, warehouseId, operatorId, operatorName) != 1) { - throw conflict("任务已被其他人员领取或状态已变化"); - } - return task(taskId, warehouseId, operatorId); - } + @Transactional + public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){ + if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化"); + return task(taskId,warehouseId,operatorId); + } - public Task task(long taskId, long warehouseId, long operatorId) { - TaskRow row = requireTask(taskId, warehouseId); - if (row.getAssigneeId() != null && row.getAssigneeId() != operatorId) { - throw conflict("任务已由其他人员领取"); - } - return view(row); - } + public Task task(long taskId,long warehouseId,long operatorId){ + TaskRow row=requireTask(taskId,warehouseId); + if(row.getAssigneeId()!=null && row.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取"); + return view(row); + } - @Transactional - public Pallet open(long taskId, DropshipPalletizationDtos.OpenPalletRequest request, - long operatorId, String operatorName) { - TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); - String operationId = request.operationId().trim(); - PalletRow replay = mapper.palletByOpenOperation(operationId); - if (replay != null) { - if (replay.getTaskId() != taskId) throw conflict("开托幂等号已用于其他任务"); - return palletView(replay, task.getInboundCode()); - } - long id = IdWorker.getId(); - mapper.insertPallet(id, taskId, task.getTenantId(), task.getInboundId(), task.getWarehouseId(), - operationId, operatorId, operatorName); - return palletView(mapper.pallet(taskId, id, request.warehouseId()), task.getInboundCode()); - } + public Carton resolveCarton(long taskId,long warehouseId,String boxCode,long operatorId){ + TaskRow task=requireTask(taskId,warehouseId); + if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取"); + LabelRow label=requireLabel(taskId,boxCode); + CartonRow counted=mapper.countedByLabel(taskId,label.getCartonLabelId()); + if(counted!=null) return cartonView(counted,task.getInboundCode(),false,"箱码已清点"); + String block=null; + if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务"; + else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员"; + else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态"; + return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(), + label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null, + block==null,block); + } - @Transactional - public ScanResult scan(long taskId, long palletId, DropshipPalletizationDtos.ScanRequest request, - long operatorId, String operatorName) { - TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); - PalletRow pallet = requireOpenPallet(taskId, palletId, request.warehouseId()); - String operationId = request.operationId().trim(); - LabelRow scanned = mapper.label(taskId, request.boxCode().trim()); - if (scanned == null) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前一件代发入仓单"); - if (!StringUtils.hasText(scanned.getSkuCode())) throw conflict("箱唛缺少SKU,不能合托"); - if (mapper.operationCount(operationId) > 0) { - return scanResult(scanned, request.cartonCount(), task, pallet); - } - List labels = mapper.unboundLabels(taskId, task.getTenantId(), task.getInboundId(), - scanned.getInboundDetailId(), scanned.getBoxCode(), request.cartonCount()); - if (labels.size() != request.cartonCount()) { - throw conflict("本SKU剩余未上托箱数不足,请刷新任务后重试"); - } - for (LabelRow label : labels) { - mapper.insertCarton(IdWorker.getId(), taskId, palletId, task.getTenantId(), task.getInboundId(), - label.getInboundDetailId(), label.getCartonLabelId(), label.getSkuCode(), label.getCargoName(), - label.getBoxCode(), operationId, operatorId, operatorName); - } - if (mapper.addAllocated(taskId, operatorId, labels.size()) != 1) { - throw conflict("任务箱数已变化,请刷新后重试"); - } - TaskRow refreshed = requireTask(taskId, request.warehouseId()); - return scanResult(scanned, labels.size(), refreshed, - mapper.pallet(taskId, palletId, request.warehouseId())); - } + @Transactional + public CountResult countCarton(long taskId,CountCartonRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + String operation=operation(request.operationId()); + CartonRow replay=mapper.cartonByCountOperation(operation); + if(replay!=null){ + if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim()) + || !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作"); + return countResult(task,replay); + } + LabelRow label=requireLabel(taskId,request.boxCode()); + if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点"); + int expected=requireExpectedQuantity(label); + boolean mismatch=expected!=request.actualSkuQuantity(); + String remark=normalize(request.discrepancyRemark()); + if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH"); + if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果"); + if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明"); + PalletRow pallet=StringUtils.hasText(request.palletNo()) + ? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null; + long cartonId=IdWorker.getId(); + mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(), + label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(), + 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()); + return countResult(refreshed,mapper.carton(taskId,cartonId)); + } - @Transactional - public Task seal(long taskId, long palletId, DropshipPalletizationDtos.SealPalletRequest request, - long operatorId, String operatorName) { - TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); - PalletRow replay = mapper.palletBySealOperation(request.operationId().trim()); - if (replay != null) { - if (replay.getTaskId() != taskId || replay.getId() != palletId) { - 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())); - } + @Transactional + public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + String operation=operation(request.operationId()); + if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId())); + PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName); + int expected=mapper.unboundCount(taskId); + if(expected==0) throw conflict("当前没有待绑托箱"); + int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName); + if(bound!=expected || mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试"); + return view(requireTask(taskId,request.warehouseId())); + } - public List inboundPallets(long tenantId, long inboundId) { - String inboundCode = mapper.inboundCode(tenantId, inboundId); - if (inboundCode == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - return mapper.palletsByInbound(tenantId, inboundId).stream() - .map(row -> palletView(row, inboundCode)).toList(); - } + @Transactional + public Task moveCarton(long taskId,long cartonId,MoveCartonRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + CartonRow carton=mapper.carton(taskId,cartonId); + 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) { - return new ScanResult(label.getSkuCode(), label.getCargoName(), label.getInboundCode(), added, - task.getAllocatedCartons(), task.getTotalCartons() - task.getAllocatedCartons(), - palletView(pallet, task.getInboundCode())); - } + @Transactional + public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + 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) { - return new Task(row.getId(), row.getInboundId(), row.getInboundCode(), row.getTenantId(), - row.getCustomerName(), row.getWarehouseId(), row.getWarehouseName(), row.getTotalCartons(), - row.getAllocatedCartons(), row.getTaskStatus(), row.getAssigneeId(), row.getAssigneeName(), - row.getVersion(), mapper.pallets(row.getId()).stream() - .map(pallet -> palletView(pallet, row.getInboundCode())).toList()); - } + public List inboundPallets(long tenantId,long inboundId){ + String code=mapper.inboundCode(tenantId,inboundId); + if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在"); + return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList(); + } - private Pallet palletView(PalletRow row, String inboundCode) { - List cartons = mapper.cartons(row.getId()); - Map> groups = new LinkedHashMap<>(); - for (CartonRow carton : cartons) { - groups.computeIfAbsent(carton.getInboundDetailId() + "|" + carton.getSkuCode(), ignored -> new ArrayList<>()) - .add(carton); - } - List items = groups.values().stream().map(values -> { - CartonRow first = values.get(0); - return new SkuItem(first.getInboundId(), inboundCode, first.getInboundDetailId(), first.getSkuCode(), - first.getCargoName(), values.size(), values.stream().map(CartonRow::getBoxCode).toList()); - }).toList(); - return new Pallet(row.getId(), row.getPalletNo(), row.getPalletStatus(), cartons.size(), items.size(), - row.getVersion(), items, row.getCompletedAt()); - } + private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){ + PalletRow existing=mapper.palletByNo(palletNo); + if(existing!=null){ + if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用"); + if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); + return existing; + } + try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); } + catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); } + existing=mapper.palletByNo(palletNo); + if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用"); + return existing; + } - private TaskRow requireWorkingTask(long taskId, long warehouseId, long operatorId) { - TaskRow task = requireTask(taskId, warehouseId); - if (!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待合托任务"); - if (task.getAssigneeId() == null || task.getAssigneeId() != operatorId) throw conflict("任务不属于当前操作员"); - if (!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待合托状态"); - return task; - } + private CountResult countResult(TaskRow task,CartonRow carton){ + Pallet p=carton.getPalletId()==null?null:palletView(mapper.pallet(task.getId(),carton.getPalletId(),task.getWarehouseId()),task.getInboundCode()); + return new CountResult(cartonView(carton,task.getInboundCode(),false,null),task.getCountedCartons(),task.getAllocatedCartons(), + task.getCountedCartons()-task.getAllocatedCartons(),task.getTotalCartons()-task.getCountedCartons(),p); + } - 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 Task view(TaskRow row){ + return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(), + row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(), + 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) { - PalletRow pallet = mapper.pallet(taskId, palletId, warehouseId); - if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); - if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘已完成,不能继续上托"); - return pallet; - } + private Pallet palletView(PalletRow row,String inboundCode){ + List cartons=mapper.cartons(row.getId()); Map> groups=new LinkedHashMap<>(); + for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c); + List items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode, + 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 static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } - private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } + private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){ + return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(), + 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);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 5a36673a5..2689a8f9e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -13,7 +13,10 @@ import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; 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.repository.FbaPricingRepository; import org.springframework.stereotype.Service; @@ -28,15 +31,21 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HexFormat; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; /** FBA 费用试算、确认和发送状态机。 */ @Service public class FbaPricingService { 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 String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认"; + private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE"; @Resource private FbaPricingRepository repository; @Resource @@ -49,6 +58,8 @@ public class FbaPricingService { private InboundFeeDetailService feeDetails; @Resource private FinanceFeeSourceOutboxService financeOutbox; + @Resource + private ContainerTierAmountAllocator containerTierAllocator; @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, @@ -114,14 +125,17 @@ public class FbaPricingService { || !StringUtils.hasText(item.getCalculationRuleVersion()))); 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(), 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)); - RateRow rate = estimate.rate(); BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, 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 converted = chargeable; 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, 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.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource); if (published != null) { @@ -156,6 +177,210 @@ public class FbaPricingService { 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 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 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 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 allocationMembers = members.stream() + .map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity())) + .toList(); + List 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 public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, String idempotencyKey) { @@ -283,7 +508,8 @@ public class FbaPricingService { } String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":" + request.chargeableQuantity() + ":" + request.unitPrice() + ":" - + request.additionalAmount() + ":" + request.reason().trim(); + + request.additionalAmount() + ":" + request.reason().trim() + ":" + + request.toleranceConfirmed(); InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); if ("COMPLETED".equals(token.getStatus())) { @@ -297,6 +523,17 @@ public class FbaPricingService { adjusted.setId(null); adjusted.setFeeNo("FBAF-" + inboundId + "-" + 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.setConvertedQuantity(adjusted.getChargeableQuantity()); 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 rules = repository.matchRules(inbound, at); + if (rules == null || rules.isEmpty()) { + throw conflict("客户未配置当前不包税报价的计费方式"); + } + List 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, long inboundId, String operation, String key, String hash) { @@ -456,6 +714,8 @@ public class FbaPricingService { fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo()); fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor()); 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.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false); @@ -497,6 +757,8 @@ public class FbaPricingService { private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee, BigDecimal unitPrice, String nextStatus) { + BigDecimal baseline = baseline(fee); + ToleranceRange range = toleranceRange(baseline); return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(), fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(), fee.getMeasurementId(), fee.getMeasurementSource(), @@ -508,7 +770,9 @@ public class FbaPricingService { fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(), fee.getConversionFactor(), 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.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(), fee.getSentAt(), nextStatus, @@ -516,6 +780,22 @@ public class FbaPricingService { 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) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index b73623379..9de556f17 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PrintableOrderInboundClient; import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.entity.InboundCartonLabelEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -35,6 +36,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { @Resource private InboundCartonLabelRepository repository; @Resource private OrderCargoClient orderCargoClient; + @Resource private PrintableOrderInboundClient printableOrderInboundClient; @Resource private InboundOrderCreationService inboundOrderCreationService; @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())) { 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() .filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId())) .findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId, @@ -101,7 +107,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) { try { ApiResponse response = - orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId); + printableOrderInboundClient.reserve(internalToken, tenantId, orderId); if (response == null || !response.successful() || response.data() == null) { throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单"); } @@ -211,6 +217,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { label.setCargoItemNo(cargo.itemNo()); label.setCargoName(cargo.cargoName()); label.setSkuCode(cargo.skuNo()); + label.setUnitsPerCarton(cargo.unitsPerCarton()); label.setDetailPackageCount(cargo.packageCount()); label.setInboundCode(inbound.getInboundCode()); 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()), cargo.cargoName() == null ? "" : cargo.cargoName().trim(), cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()), + String.valueOf(cargo.unitsPerCarton()), decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm()))) .collect(java.util.stream.Collectors.joining("\n")); try { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index b35a25a7a..de61d512c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -23,6 +23,7 @@ import java.util.List; @Service public class InboundFeeDetailService { 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 private FeeItemRepository repository; @Resource @@ -379,6 +380,7 @@ public class InboundFeeDetailService { private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound, FeeItemEntity item, SaveRequest request, long operatorId) { + ensureDestinationApplicable(item, inbound); ensureWarehouseApplicable(item, request.direction()); value.setFeeItemId(item.getId()); value.setInboundCode(inbound.getInboundCode()); @@ -408,6 +410,13 @@ public class InboundFeeDetailService { 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, String direction, Long supplierId) { if (!"PAYABLE".equals(direction)) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 50e3a0a46..66c4c636a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -48,6 +48,8 @@ public class InboundMeasurementService { private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM"; /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; + private static final Set MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set WEIGHT_EDITABLE_STATUSES = Set.of( "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ @@ -100,7 +102,7 @@ public class InboundMeasurementService { com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId, java.util.function.Function.identity(), (left, right) -> left)); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId()); - InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); + LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound); return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight, review, latestDeclaration); } @@ -122,6 +124,10 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + if (!MEASUREMENT_DRAFT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, + "测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交"); + } validateInboundDetails(tenantId, inbound, request.items()); request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { @@ -468,7 +474,8 @@ public class InboundMeasurementService { List allocationSlots, Map allocationsByWeight, MeasureReviewRecordEntity review, - InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) { + LatestDeclaration latestDeclaration) { + InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot(); return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), @@ -480,21 +487,22 @@ public class InboundMeasurementService { inbound.getVersion(), measurement == null ? null : measurement.getVersion(), measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) - ? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(), + ? measurement.getOriginalDocumentNo() : declaration.referenceId(), measurement != null && measurement.getOriginalGrossWeightKg() != null - ? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(), + ? measurement.getOriginalGrossWeightKg() : declaration.grossWeightKg(), measurement != null && measurement.getOriginalVolumeCbm() != null - ? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(), - latestDeclaration.totalPackageCount(), + ? measurement.getOriginalVolumeCbm() : declaration.volumeCbm(), + declaration.totalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), inbound.getActualGrossWeightKg(), inbound.getActualNetWeightKg(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) - ? measurement.getActualGoodsName() : latestDeclaration.goodsName(), - latestDeclaration, - items.stream().map(this::toItem).toList(), weights.stream() + ? measurement.getActualGoodsName() : declaration.goodsName(), + declaration, + 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(), 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 * the measurement batch, carton-label mapping, weight results or review decision. */ - private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration( + private LatestDeclaration loadLatestDeclaration( InboundOrderEntity inbound) { OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound); List 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() .map(OrderCargoClient.CargoLabelBinding::packageCount) .filter(java.util.Objects::nonNull) @@ -538,7 +550,7 @@ public class InboundMeasurementService { if (packages > Integer.MAX_VALUE) { throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限"); } - return new InboundMeasurementResponses.DeclarationSnapshot( + InboundMeasurementResponses.DeclarationSnapshot snapshot = new InboundMeasurementResponses.DeclarationSnapshot( aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(), aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(), cargoes.stream() @@ -550,6 +562,19 @@ public class InboundMeasurementService { cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(), cargo.lengthCm(), cargo.widthCm(), cargo.heightCm())) .toList()); + Map 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 loadDeclarationCargoes(InboundOrderEntity inbound) { @@ -601,9 +626,11 @@ public class InboundMeasurementService { return value; } - private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { + private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item, + String inboundCode, + String fbaShipmentNo) { 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.getUnitVolumeCbm(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), @@ -612,11 +639,17 @@ public class InboundMeasurementService { item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(), item.getCalculationRuleVersion(), item.getChargeRuleId(), item.getCoefficientMode(), - item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), + item.getRemark(), item.getBoxCode(), fbaShipmentNo, item.getScannedShipmentId(), item.getPackageCount(), item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); } + private record LatestDeclaration( + InboundMeasurementResponses.DeclarationSnapshot snapshot, + Map fbaShipmentNos + ) { + } + private void requireConfirmedPackageTotal(InboundOrderEntity inbound, List items) { if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 8875925df..769a69693 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -56,10 +56,8 @@ public class LoadingListService { private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; 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 DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; - private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource private LoadingListRepository repository; @Resource @@ -74,6 +72,8 @@ public class LoadingListService { @Resource private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource + private FbaPricingService fbaPricingService; + @Resource private ObjectMapper objectMapper; @Resource private LoadingDocumentFileClient documentFiles; @@ -188,8 +188,8 @@ public class LoadingListService { } } 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 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 void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); } + private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); } @Transactional @BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER", @@ -501,6 +501,7 @@ public class LoadingListService { AggregateRow before = requirePlan(planId); List links = loadingPickService.requirePreloadConfirmationReady(planId); + requireContainerFeesReadyForPreloadConfirmation(links, operationId); if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), operatorId, trim(operatorName)) != 1) { 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 links, String operationId) { + Map> 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> 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) { if (from != null && to != null && from.isAfter(to)) { throw badRequest("创建开始时间不能晚于结束时间"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 8484f92cd..7782981bc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; @@ -106,6 +107,8 @@ public class LoadingPickService { private InboundPalletAllocationRepository palletAllocations; @Resource 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. */ @Transactional @@ -326,6 +329,7 @@ public class LoadingPickService { if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); } + requirePackageUnit(item); if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { throw conflict("拣货数量超过计划箱数或明细版本已变化"); } @@ -344,6 +348,41 @@ public class LoadingPickService { 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 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 public Task partial(long taskId, long itemId, PartialPickRequest request, long operatorId, String operatorName) { @@ -364,7 +403,8 @@ public class LoadingPickService { assertReplay(replay, taskId, itemId, requestHash); 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(), request.warehouseId()) != 1) { throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); @@ -397,6 +437,7 @@ public class LoadingPickService { return view(task, request.warehouseId()); } LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); + requirePackageUnit(item); if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); } @@ -443,6 +484,7 @@ public class LoadingPickService { List lines = validateBatchLines(task, request); validateInventorySnapshot(task, request, lines); + lines.forEach(line -> requirePackageUnit(line.item())); for (BatchLine line : lines) { 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) { if (repository.touchTask(task.getId(), task.getVersion()) != 1) { throw conflict("PDA任务版本已变化"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java deleted file mode 100644 index a557e4c72..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java +++ /dev/null @@ -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 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 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 values = cartons.selectPalletCartons(pallet.getId()); - Map> bySku = values.stream().collect( - java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode, - LinkedHashMap::new, java.util.stream.Collectors.toList())); - List 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 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); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 3246f5cdd..bad5a21ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -42,6 +42,8 @@ public class MeasurementBatchService { static final String STATUS_DRAFT = "DRAFT"; /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; + private static final Set EDITABLE_INBOUND_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); 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_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); @@ -60,6 +62,10 @@ public class MeasurementBatchService { if (inbound == null) { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); } + if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, + "当前入库状态不允许保存测量批次,已提交批次必须保持冻结"); + } validateSource(command); List calculatedItems = calculateItems( command, dimensionWeighting.load(command.tenantId())); @@ -71,12 +77,7 @@ public class MeasurementBatchService { repository.insertMeasurement(measurement); } else { measurement = requireEditableBatch(command); - // 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料, - // 不回退其主状态,也不把已经审核的批次重新标成待审核。 - if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") - .contains(inbound.getProcessStatus())) { - measurement.setStatus(STATUS_DRAFT); - } + measurement.setStatus(STATUS_DRAFT); applyHeader(measurement, command); applyTotals(measurement, calculatedItems); if (!repository.updateMeasurement(measurement)) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index e84e241f1..9aac856c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -23,6 +23,7 @@ import java.math.BigDecimal; @Service public class MerchantInboundBillingService { private static final Set PUBLISHED_BILLING_PROCESS_STATUSES = Set.of( + InboundProcessStatus.WAIT_PALLETIZE, InboundProcessStatus.WAIT_SPLIT, InboundProcessStatus.LOADING, InboundProcessStatus.LOADED, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index d913613d4..250b57c32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -32,7 +32,7 @@ public class MerchantPriceBookService { public List channels(long merchantTenantId, long originWarehouseId) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); - Long versionId = repository.activeVersionId(); + Long versionId = repository.effectiveVersionId(merchantTenantId); if (versionId == null) return List.of(); List channels = repository.channels(versionId, originWarehouseId); if (channels.isEmpty()) return List.of(); @@ -42,7 +42,11 @@ public class MerchantPriceBookService { public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) { 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(), estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(), estimate.amount(), estimate.currency(), DISCLAIMER); @@ -52,7 +56,7 @@ public class MerchantPriceBookService { String destinationType, boolean requireInboundCodePrefix) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); - Long versionId = repository.activeVersionId(); + Long versionId = repository.effectiveVersionId(merchantTenantId); String normalized = normalizeChannel(channelCode); if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) { throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java index 49614ba3d..eb2013660 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -117,7 +117,7 @@ public class OrderChannelChangePricingService { request.channelCode(), request.destinationType(), false); BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT); 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.destinationWarehouseId(), inbound.getDestinationName(), null, PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 1873105dd..5bdd15cc2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -63,8 +63,9 @@ public class OverseasContainerEventConsumer { ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); - containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, - uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt()); + containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt, + unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); row.setStatus("PROCESSED"); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(row); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java index ee121a0a2..9adcf4617 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java @@ -72,13 +72,26 @@ public class PlatformPriceBookService { @Value("${baiyue.security.internal-service-token}") private String internalToken; public List list(PriceBookStatus status) { + return list(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + + public List list(long customerId, PriceBookStatus status) { + requireScope(customerId); 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) { + return upload(operatorId, PriceBookVersionEntity.PLATFORM_SCOPE, file); + } + + public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, long customerId, MultipartFile file) { + requireScope(customerId); 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("价格版本创建失败"); Path temp = null; try { @@ -123,7 +136,8 @@ public class PlatformPriceBookService { List channelRows = repository.listChannels(versionId); List issues = new ArrayList<>(readIssues(version.getParseIssuesJson())); 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)) { Set current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet()); repository.listChannels(active.getId()).stream() @@ -140,7 +154,9 @@ public class PlatformPriceBookService { @Transactional(noRollbackFor = BizException.class) public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId, 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); List channels = repository.listChannels(versionId).stream().map(this::channelDto).toList(); List rates = repository.listRates(versionId).stream().map(this::rateDto).toList(); @@ -158,7 +174,8 @@ public class PlatformPriceBookService { version.setChannelCount(channels.size()); version.setRateCount(mapped.rates().size()); 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)) .forEach(active -> repository.supersede(active, 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(); - value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE); - value.setVersionNo(repository.nextVersionNo()); + value.setTenantId(customerId); + value.setVersionNo(customerId == PriceBookVersionEntity.PLATFORM_SCOPE + ? repository.nextVersionNo() : repository.nextVersionNo(customerId)); value.setOriginalFileName(safeFileName(originalFileName)); value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name()); 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, long operatorId, int number, LocalDateTime now) { 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.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim()); value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA"); @@ -540,12 +558,16 @@ public class PlatformPriceBookService { private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) { 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.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(), value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(), 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) { return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()), value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3fea4c3c1..5806840b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -54,6 +54,7 @@ public class SmartLoadingService { "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK") ); @Resource private SmartLoadingRepository repository; + @Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets; @Resource private OrderCargoClient orderCargoClient; @Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private InboundOrderCreationService inboundOrderCreationService; @@ -62,6 +63,7 @@ public class SmartLoadingService { @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private SplitPreloadService splitPreloadService; @Resource private ColoadGroupAssignmentPort coloadGroups; + @Resource private FbaPricingService fbaPricingService; @Resource private PriceBookQueryUseCase priceBooks; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private ObjectMapper objectMapper; @@ -111,7 +113,7 @@ public class SmartLoadingService { Map> grouped = candidates(bookingId, warehouseId).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, 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 recommendationCandidates(ContainerBookingEntity booking) { @@ -121,7 +123,7 @@ public class SmartLoadingService { repository.planByBooking(booking.getId()), values).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, 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. */ @@ -175,7 +177,7 @@ public class SmartLoadingService { List all = repository.globalCandidatesByUnits(unitPage.records()).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, java.util.stream.Collectors.toList())) - .values().stream().map(this::inboundCandidate).toList(); + .values().stream().flatMap(details -> inboundCandidates(details).stream()).toList(); Map units = new HashMap<>(); Map> grouped = all.stream() .filter(candidate -> candidate.coLoadGroupId() != null) @@ -216,6 +218,7 @@ public class SmartLoadingService { new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(), row.warehouseId(), row.channelCode(), row.channelName(), null, null, null, row.customerName(), null, row.totalPackageCount(), null, null, null, null, + "PACKAGE", null, null, false, null, null, null, null, false, true, null, "PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList(); 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())); } - private InboundCandidate inboundCandidate(List details) { + private List inboundCandidates(List details) { Candidate first = details.get(0); int packages = details.stream().map(Candidate::availablePackageCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); @@ -279,13 +282,23 @@ public class SmartLoadingService { BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText) .map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、")); - return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), + List 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.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), 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.expectedArrivalAt(), List.of()); + first.expectedArrivalAt(), List.of())); } private InboundCandidate globalGroupCandidate(List availableMembers) { @@ -317,7 +330,7 @@ public class SmartLoadingService { return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), 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(), occupied, 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", resourceId = "#planId", description = "分配智能装柜明细") 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, 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 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 requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() @@ -431,8 +493,14 @@ public class SmartLoadingService { if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); 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("入库货物不存在或尚未完成入库"); + 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()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); requireSameCustomerScope(booking, candidate); @@ -445,7 +513,9 @@ public class SmartLoadingService { operatorId, operatorName); } 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 availableIncludingCurrent = candidate.availablePackageCount() + (existing == null ? 0 : existing.getPackageCount()); @@ -457,6 +527,14 @@ public class SmartLoadingService { LoadingPlanItemEntity changed; if (existing == null) { 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("货物明细已被并发锁定"); } changed = item; } else { @@ -475,6 +553,9 @@ public class SmartLoadingService { loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(), operatorId, operatorName, operation); loadingPickService.sync(planId, changed); + if (recalculateFees) { + recalculateAfterRelationshipChange(booking.getId(), Set.of(candidate.accountId()), operation); + } return view(repository.plan(planId), booking); } @@ -488,7 +569,7 @@ public class SmartLoadingService { resourceId = "#planId", description = "按入仓码分配智能装柜箱数") public Plan assignInbound(long planId, AssignInboundRequest request, 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, operatorId, operatorName, operation); created.forEach(item -> loadingPickService.sync(planId, item)); + recalculateAfterRelationshipChange(booking.getId(), Set.of(request.customerId()), operation); return view(repository.plan(planId), booking); } private Plan assignInbound(long planId, AssignInboundRequest request, 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, long operatorId, String operatorName, boolean wholeGroupAssignment, List frozenDetails) { + return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, frozenDetails, false); + } + + private Plan assignInbound(long planId, AssignInboundRequest request, + long operatorId, String operatorName, boolean wholeGroupAssignment, + List frozenDetails, boolean recalculateFees) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' + request.packageCount() + '|' + request.expectedVersion()); @@ -592,8 +680,13 @@ public class SmartLoadingService { if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { - return assignDeclaredInbound(planId, request, projection, operation, requestHash, + Plan assigned = assignDeclaredInbound(planId, request, projection, operation, requestHash, 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); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); @@ -617,7 +710,7 @@ public class SmartLoadingService { .substring(0, 48); assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName, - wholeGroupAssignment); + wholeGroupAssignment, null, null, null, false, null, false); sequence++; } LoadingPlanEntity assigned = repository.plan(planId); @@ -626,6 +719,9 @@ public class SmartLoadingService { json(values("requestHash", requestHash, "packageCount", request.packageCount(), "specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")), operatorId, operatorName); + if (recalculateFees) { + recalculateAfterRelationshipChange(booking.getId(), Set.of(first.accountId()), operation); + } return view(assigned, booking); } @@ -756,6 +852,8 @@ public class SmartLoadingService { json(values("requestHash", requestHash, "groupId", locked.groupId(), "memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(), "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); } @@ -795,6 +893,8 @@ public class SmartLoadingService { json(values("version", request.expectedPlanVersion(), "groupId", groupId)), json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)), 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); } @@ -859,6 +959,9 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanItemEntity item = repository.item(planId, itemId); 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()); requireStandalone(item.getAccountId(), item.getInboundId()); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); @@ -971,6 +1074,9 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanItemEntity item = repository.item(planId, itemId); 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()); loadingPickService.remove(itemId); touch(plan, operatorId, operatorName); @@ -979,9 +1085,38 @@ public class SmartLoadingService { history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); + recalculateAfterRelationshipChange(plan.getContainerBookingId(), Set.of(item.getAccountId()), operation); 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 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 @BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "批量移除智能装柜未拣明细") @@ -1057,6 +1192,10 @@ public class SmartLoadingService { json(values("version", request.expectedPlanVersion(), "itemCount", items.size())), json(values("requestHash", requestHash, "removedItemCount", removable.size(), "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); } @@ -1461,7 +1600,40 @@ public class SmartLoadingService { : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), 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 collapsePalletUnits(List items) { + List result = new ArrayList<>(); + Map> 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 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 lines, + java.util.function.Function getter) { + return lines.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private int adjustmentTotal(LoadingPlanItemEntity item) { int current = item.getPackageCount() == null ? 0 : item.getPackageCount(); @@ -1526,6 +1698,17 @@ public class SmartLoadingService { private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { 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 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 Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { LoadingPlanHistoryEntity history = repository.operation(operation); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java new file mode 100644 index 000000000..3bcf2952d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -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> 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 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> 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 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 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 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 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())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java new file mode 100644 index 000000000..969e9f8bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java @@ -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 allowedActions, + ContainerActualFacts actualFacts, View terminalDrayage) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java new file mode 100644 index 000000000..2148b61b2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java @@ -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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java new file mode 100644 index 000000000..ddd847c04 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java new file mode 100644 index 000000000..6bf23c0dd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -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 { + @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(""" + + """) + List contexts(@Param("containerBookingIds") List 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(""" + + """) + List findAll(@Param("containerBookingIds") List 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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java new file mode 100644 index 000000000..018f20be1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java @@ -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 domesticPage(Query query, Set permissions) { + return page("DOMESTIC", query, permissions); + } + + public PageResult overseasPage(Query query, Set permissions) { + return page("OVERSEAS", query, permissions); + } + + public WorkbenchItem domesticDetail(long id, Set 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 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 page(String workbench, Query query, Set permissions) { + validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); + Query normalized = normalize(query); + PageResult source = repository.page(workbench, normalized); + List ids = source.records().stream().map(ContainerBookingEntity::getId).toList(); + Map facts = new HashMap<>(); + Map 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 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 actualFacts(List ids) { + Map 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 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 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 domesticActions(ContainerBookingEntity value, Set permissions) { + Set granted = permissions == null ? Set.of() : permissions; + List 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 overseasActions(View drayage, Set permissions) { + Set granted = permissions == null ? Set.of() : permissions; + List 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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java new file mode 100644 index 000000000..7c2c16d72 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -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 views(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of(); + List ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值"); + Map rows = new HashMap<>(); + mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value)); + Map 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 histories(long containerBookingId) { + requiredContext(containerBookingId); + return mapper.histories(containerBookingId); + } + + public List authorize(List values, Set permissions) { + return values.stream().map(value -> authorize(value, permissions)).toList(); + } + + public View authorize(View value, Set permissions) { + Set granted = permissions == null ? Set.of() : permissions; + List 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 allowedActions(WorkContext context, TerminalDrayageEntity value) { + List 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 actualFacts(List ids) { + Map 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; } +} diff --git a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql index 1d93f2ec6..02f34e80f 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 76de2bd92..4f7282aa1 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -209,6 +209,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', @@ -533,7 +534,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( CREATE TABLE IF NOT EXISTS fl_price_book_version ( 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 '整表版本号', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', 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_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', 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 '最终计费量', charge_unit VARCHAR(16) 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) ) 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 ( 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', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', 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 '已上托箱数', + discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_name VARCHAR(64) 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), 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_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合托任务'; 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 '客户隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', - pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', - pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', + pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', seal_operation_id VARCHAR(128) 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), 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 chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) + CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED')) ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( 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 '客户隔离键', 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', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', - box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', - operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', - bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码', + expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量', + 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_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', + bound_at DATETIME(3) NULL COMMENT '上托时间', PRIMARY KEY (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_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_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) diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 4fa48c82d..0e115c876 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -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' 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; DELIMITER $$ 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 AND id BETWEEN 193000000001000001 AND 193000000001000110 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' - AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' - 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 - 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 = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + 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 + 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 SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验'; END IF; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 2c66be65f..54dcd3305 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -74,9 +74,13 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', unloading_completed_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)', 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_name VARCHAR(64) NULL 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_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_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'), CONSTRAINT chk_container_booking_iso6346 CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$') ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; @@ -162,7 +167,7 @@ BEGIN 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 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; 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 @@ -174,6 +179,26 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; 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() 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); @@ -197,6 +222,11 @@ BEGIN 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}$'); 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$$ DELIMITER ; 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 '产品图片访问地址快照', package_count INT UNSIGNED NOT 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 '每箱产品数', pallet_count INT UNSIGNED NULL COMMENT '托盘数', 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_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', - active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + 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_name VARCHAR(64) NULL 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 '装柜明细最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + 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_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_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), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), 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)) ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; --- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。 +-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。 CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', - document_type VARCHAR(32) NOT NULL COMMENT '文件类型: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_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', 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) REFERENCES fl_container_booking (id), CONSTRAINT chk_container_document_type - CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) -) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; + CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER')) +) 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 ( 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) ) 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; DELIMITER $$ 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 WHERE container_booking_id = NEW.container_booking_id; 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 = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1222,7 +1207,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - 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 = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -1478,12 +1463,13 @@ BEGIN 'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'product_image_file_id', 'pallet_count', 'net_weight_kg', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', - 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + '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 = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql new file mode 100644 index 000000000..47d88d067 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -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='目的港码头拖车成功幂等操作'; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index bbc2d4d3f..37dea7a29 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -10,6 +10,25 @@ paths: x-permission: platform:firstleg:port-schedule-query:list responses: '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: get: summary: Query carrier schedule tasks @@ -175,6 +194,30 @@ components: properties: query: {$ref: '#/components/schemas/Task'} 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: type: object required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index 764600152..2e7c75c81 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -1,54 +1,153 @@ openapi: 3.0.3 info: - title: 一件代发 PDA 合托 API - version: 1.0.0 - description: 测量完成后独立下发;不创建国内库存。PDA 扫箱唛识别 SKU,并按箱数合托。 + title: 一件代发国内清点组托 API + version: 2.0.0 + description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。 paths: /api/platform/v1/first-leg/pda-dropship/tasks/pending: get: - summary: 查询待领取或本人已领取的一件代发合托任务 - responses: { '200': { description: OK } } + summary: 查询待领取或本人已领取任务 + x-required-authority: platform:firstleg:pda:view + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: 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: - - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: warehouseId, in: query, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: OK } } + - {$ref: '#/components/parameters/TaskId'} + - {$ref: '#/components/parameters/WarehouseId'} + - {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: post: - summary: 领取合托任务 - parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: OK } } - /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/open: + summary: 领取任务 + x-required-authority: platform:firstleg:pda:work + parameters: [{$ref: '#/components/parameters/TaskId'}] + 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: - summary: 开启一个现场托盘 - parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: OK } } - /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/scan: + summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘 + description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。 + x-required-authority: platform:firstleg:pda:work + 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: - 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: - - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: OK } } + - {$ref: '#/components/parameters/TaskId'} + - {name: cartonId, in: path, required: true, schema: {type: integer, format: int64}} + 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: post: - summary: 完成合托并生成 DP 托盘号 + summary: 封托;禁止空托、待绑箱、版本冲突和重复归托 + x-required-authority: platform:firstleg:pda:work parameters: - - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: OK } } + - {$ref: '#/components/parameters/TaskId'} + - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}} + 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: get: - summary: 入仓单查看绑定的一个或多个托盘及各自箱数 - parameters: [{ name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: OK } } + summary: Admin 查询入仓单权威托盘结果 + x-required-authority: firstleg:inbound:list + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: get: - summary: 打印托盘号、入仓码和 SKU 箱数托盘唛 - parameters: - - { name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: - '200': { description: PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } + summary: 打印托盘码、入仓码和 SKU 实收汇总 + x-required-authority: firstleg:inbound:list + responses: {'200': {description: PDF}} +components: + parameters: + 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} diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index ec62caa77..580aaad9b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -72,6 +72,7 @@ paths: summary: 查询船期价格表版本 description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。 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'}} responses: '200': @@ -84,7 +85,9 @@ paths: '403': {description: 无权限} post: 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: required: true content: @@ -105,7 +108,7 @@ paths: /api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish: post: summary: 确认并立即生效船期价格表版本 - description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。 + description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务只替换同 customerId 作用域的上一 ACTIVE 版本。计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本。 parameters: [{$ref: '#/components/parameters/VersionId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} @@ -165,7 +168,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/published: put: 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'}] requestBody: required: true @@ -184,7 +187,7 @@ paths: responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}} post: 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'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} @@ -330,6 +333,10 @@ components: actualWeightKg: {type: number, nullable: true} actualVolumeCbm: {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} chargeUnit: {type: string, nullable: true} unitPrice: {type: number, nullable: true} @@ -466,16 +473,22 @@ components: timestamp: {type: string, format: date-time, nullable: true} AdjustFeeRequest: type: object - required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] + required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed] properties: expectedFeeVersion: {type: integer, minimum: 1} chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} + toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本} CalculateRequest: 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: type: object required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] @@ -535,6 +548,7 @@ components: type: object properties: id: {type: string, pattern: '^[1-9][0-9]*$'} + customerId: {type: string, pattern: '^(0|[1-9][0-9]*)$', description: 0 为公共报价,正数为客户专属报价} versionNo: {type: integer} originalFileName: {type: string, maxLength: 255} sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} @@ -668,5 +682,6 @@ components: destinationName: {type: string, maxLength: 128, nullable: true} destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} + chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致} actualWeightKg: {type: number, minimum: 0} actualVolumeCbm: {type: number, minimum: 0} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 1252ac4b4..332742003 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -296,7 +296,7 @@ paths: post: summary: 按统一订单条件分页查询履约摘要(Order 内部) description: >- - mainStatus 接受六态产品状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态。 + mainStatus 接受七态产品状态;WAIT_PALLETIZE 为一件代发测量计费后的待清点组托状态,WAIT_SPLIT 为待分柜状态。 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。 security: - internalServiceToken: [] @@ -406,25 +406,6 @@ paths: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无分配权限} /api/platform/v1/containers: - get: - summary: 分页查询平台货柜 - description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account - parameters: - - {$ref: '#/components/parameters/Page'} - - {$ref: '#/components/parameters/Size'} - - {name: keyword, in: query, schema: {type: string, maxLength: 128}} - - {name: 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: summary: 新增内部模拟柜 description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱 @@ -436,6 +417,43 @@ paths: responses: '200': {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: post: summary: 将内部模拟柜关联到已发布订舱需求 @@ -444,14 +462,6 @@ paths: requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}} responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}} /api/platform/v1/containers/{id}: - get: - summary: 查询平台货柜详情 - description: 需要 platform:firstleg:container:list 权限 - parameters: - - {$ref: '#/components/parameters/ContainerBookingId'} - responses: - '200': {description: 成功或货柜不存在} - '403': {description: 无货柜查询权限} put: summary: 草稿阶段编辑货柜基础资料 description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口 @@ -465,16 +475,23 @@ paths: responses: '200': {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: get: summary: 查询货柜目的港清关资料 - description: 需要 platform:firstleg:customs:manage 权限 + description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] responses: {'200': {description: 成功或货柜不存在}} /api/platform/v1/containers/{id}/clearance/document: post: summary: 上传或替换货柜目的港清关资料 - description: 需要 platform:firstleg:customs:manage 权限,fileId 必须严格绑定当前货柜 + description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -483,7 +500,7 @@ paths: /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: get: summary: 下载货柜目的港清关附件 - description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 + description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 parameters: - {$ref: '#/components/parameters/ContainerBookingId'} - {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: post: 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'}] requestBody: required: true @@ -504,7 +521,7 @@ paths: /api/platform/v1/containers/{id}/clearance/complete: post: summary: 点击完成目的港清关 - description: 需要 platform:firstleg:customs:manage 权限 + description: 需要 platform:firstleg:terminal-drayage:document:edit 权限 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -916,6 +933,8 @@ paths: grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重; 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。 + inboundCode 是当前入库详情唯一入仓码;每条 items 按 inboundDetailId 返回 Order 权威 + fbaShipmentNo,并保留独立 scannedShipmentId,匹配失败返回空且禁止两者互相回退或覆盖。 parameters: - {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]*$'}} @@ -931,7 +950,9 @@ paths: summary: 保存测量草稿 description: >- 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; - actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。 + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING + 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 + measurement/items/{itemId}/confirmed-package-count 明细专用接口。 parameters: - {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]*$'}} @@ -1133,7 +1154,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 + description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。 parameters: - {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]*$'}} @@ -1150,7 +1171,7 @@ paths: expectedVersion: {type: integer, minimum: 0} reason: {type: string, maxLength: 1000} 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: post: summary: 提交测量完成 @@ -1164,7 +1185,7 @@ paths: /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: get: 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: - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: @@ -1383,8 +1404,8 @@ components: invalidatedFeeId: {type: integer, format: int64, nullable: true} UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] - description: 六态产品主状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态 + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + description: 七态产品主状态;WAIT_PALLETIZE 仅表示一件代发待清点组托 UnifiedInboundQuery: type: object properties: @@ -1393,7 +1414,7 @@ components: orderNo: {type: string, maxLength: 64, nullable: true} mainStatus: 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 description: WAIT_SPLIT 为唯一待分柜筛选值 customerId: {type: integer, format: int64, nullable: true} @@ -1511,6 +1532,7 @@ components: deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} + lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} taxMode: {type: string, nullable: true} @@ -1749,10 +1771,12 @@ components: type: object description: >- 单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的 - 尺寸测量原图,不得与 weightResults.fileId 混用。 + 尺寸测量原图,不得与 weightResults.fileId 混用。fbaShipmentNo 是按 inboundDetailId + 从 Order 货物明细实时读取的权威货件编号;scannedShipmentId 是独立设备实扫证据,二者不得回退或覆盖。 required: [id] properties: 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} itemNo: {type: string, nullable: true} boxSpecification: {type: string, nullable: true} @@ -1781,7 +1805,8 @@ components: coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} remark: {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} machineNo: {type: string, nullable: true} deviceMeasuredAt: {type: string, format: date-time, nullable: true} @@ -1793,7 +1818,7 @@ components: required: [inboundId, inboundVersion, items, weightResults] properties: 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} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} @@ -1979,7 +2004,7 @@ components: publicRemark: {type: string, nullable: true} expressCompanyName: {type: string, nullable: true} expressTrackingNo: {type: string, nullable: true} - ContainerBookingPageEnvelope: + ContainerWorkbenchPageEnvelope: type: object properties: code: {type: string, example: '200'} @@ -1987,14 +2012,78 @@ components: data: type: object 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]*$'} size: {type: string, pattern: '^[1-9][0-9]*$'} total: {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: 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: id: {type: string, pattern: '^[1-9][0-9]*$'} 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} unloadingCompletedAt: {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: 实际报空时间} emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]} @@ -2044,12 +2133,17 @@ components: destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} customsDeclarationDocumentStatus: {$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} ContainerDocumentStatus: type: string enum: [MISSING, UPLOADED, COMPLETED] - description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED + description: 单证聚合状态;DO 无完成动作,因此只返回 MISSING 或 UPLOADED ContainerSaveRequest: type: object required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg] @@ -2359,6 +2453,12 @@ components: file: {type: string, format: binary, description: 用户直接选择的清关资料} expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0} 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: type: object properties: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 4a9021268..8dce29cbf 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -361,6 +361,8 @@ components: itemId: {$ref: '#/components/schemas/LongId'} accountId: {$ref: '#/components/schemas/LongId'} 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'} orderNo: {type: string, description: 订单号快照} inboundId: {$ref: '#/components/schemas/LongId'} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 4051fd984..e89283d7e 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -7,6 +7,19 @@ paths: description: '权限:platform:firstleg:container:list' parameters: [{$ref: '#/components/parameters/ContainerId'}] 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: 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}}} @@ -20,32 +33,32 @@ paths: /api/platform/v1/containers/{id}/documents/{documentType}: get: 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'}] responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}} /api/platform/v1/containers/{id}/documents/{documentType}/upload: post: 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}}] 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'}}}}} /api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download: get: 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}}] responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}} /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: get: 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}}] responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}} components: parameters: 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: ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}} schemas: @@ -57,16 +70,17 @@ components: container: {$ref: '#/components/schemas/Container'} loadingPlanId: {type: integer, format: int64} 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'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} 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}}}} 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}}} - 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}}} 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}}} 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}}} diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 909e5e753..495956511 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -170,104 +170,11 @@ paths: expectedVersion: {type: integer, minimum: 0} reason: {type: string, minLength: 1, maxLength: 500} 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: parameters: 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}} - PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} 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: type: object required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml new file mode 100644 index 000000000..3a9b6332b --- /dev/null +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -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'}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index c3bfbea8e..e89e1e424 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -18,30 +18,35 @@ class ContainerControllerContractTest { @Test @DisplayName("查询、新增和编辑按钮权限彼此独立") void separatesButtonPermissions() { - assertPermission("page", "platform:firstleg:container:list"); - assertPermission("detail", "platform:firstleg:container:list"); + assertPermission("domesticPage", "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("linkOceanBooking", "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("overseasOverview", "platform:firstleg:terminal-drayage:list"); + assertPermission("updatePickupCode", "platform:firstleg:terminal-drayage:document:edit"); assertPermission("downloadPdaPhoto", "platform:firstleg:container:list"); assertPermission("markException", "platform:firstleg:container:edit"); assertPermission("cancelException", "platform:firstleg:container:edit"); - assertPermission("logisticsDocument", "platform:firstleg:container:list"); - assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit"); - assertPermission("downloadDocument", "platform:firstleg:container:list"); - assertPermission("downloadClearance", "platform:firstleg:customs:manage"); + assertPermission("logisticsDocument", "platform:firstleg:terminal-drayage:list"); + assertPermission("uploadLogisticsDocument", "platform:firstleg:terminal-drayage:document:edit"); + assertPermission("downloadDocument", "platform:firstleg:terminal-drayage:list"); + assertPermission("downloadClearance", "platform:firstleg:terminal-drayage:list"); } @Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception { String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", + "/api/platform/v1/containers/overseas/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "/clearance/document/{fileId}/download:", - "DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER", - "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", + "/api/platform/v1/containers/{id}/pickup-code:", "PickupCodeRequest", + "enum: [DELIVERY_ORDER]", "Idempotency-Key", "photoCounts:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", "downloadPath: {type: string, nullable: true", @@ -59,6 +64,9 @@ class ContainerControllerContractTest { assertThat(section) .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("RELEASED oceanBookingId") .contains("未满配 requirementId") @@ -72,7 +80,7 @@ class ContainerControllerContractTest { .contains("/api/platform/v1/containers/{id}/clearance/document/upload:") .contains("multipart/form-data") .contains("ContainerDocumentUploadRequest") - .contains("platform:firstleg:container:list") + .contains("platform:firstleg:terminal-drayage:list") .contains("EDITING") .contains("WAIT_LOADING") .contains("LOADING") @@ -80,6 +88,7 @@ class ContainerControllerContractTest { .doesNotContain("tenantId") .doesNotContain("accountId"); assertThat(openapi) + .contains("ContainerWorkbenchItem:", "allowedActions") .contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:") .contains("Content-Disposition 与 Cache-Control no-store") .contains("required: [oceanBookingId, requirementId, expectedVersion]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index c9adf167b..b033f0119 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -32,8 +32,10 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); assertThat(detailProperties).containsKeys( - "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", + "inboundCode", "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", "palletLocationGuide"); + assertThat(stringMap(detailProperties.get("inboundCode")).get("description").toString()) + .contains("唯一入仓码", "UI业务主展示字段"); assertThat(stringMap(detailProperties.get("latestDeclaration"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration"); assertThat(properties(schemas, "InboundMeasurementLatestDeclaration")) @@ -49,8 +51,11 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest"); assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items") .doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm"); - assertThat(paths).containsKey( - "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"); + String draftPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"; + assertThat(paths).containsKey(draftPath); + Map draftOperation = stringMap(stringMap(paths.get(draftPath)).get("put")); + assertThat(draftOperation.get("description").toString()) + .contains("MEASURE_PENDING", "冻结", "package-count"); assertThat(stringMap(stringMap(draftProperties.get("items")).get("items"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest"); assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( @@ -58,7 +63,14 @@ class InboundMeasurementEvidenceOpenApiContractTest { "widthCm", "heightCm", "intelligentImageFileId", "remark"); Map itemProperties = properties(schemas, "InboundMeasurementItem"); - assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); + assertThat(itemProperties).containsKeys( + "id", "inboundCode", "inboundDetailId", "intelligentImageFileId", "fbaShipmentNo", "scannedShipmentId"); + assertThat(stringMap(itemProperties.get("inboundCode")).get("description").toString()) + .contains("唯一权威入仓码", "根对象inboundCode一致"); + assertThat(stringMap(itemProperties.get("fbaShipmentNo")).get("description").toString()) + .contains("Order货物明细", "inboundDetailId", "匹配失败为空"); + assertThat(stringMap(itemProperties.get("scannedShipmentId")).get("description").toString()) + .contains("设备实扫", "不覆盖或回退fbaShipmentNo"); Map weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index 9e0ac4d84..a67097ac3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -40,7 +40,8 @@ class InboundMeasurementReviewContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") .contains("Idempotency-Key") .contains("初算异常只返回非阻断提示") - .contains("data.processStatus 固定为 WAIT_SPLIT") + .contains("标准头程审核通过后进入 WAIT_SPLIT") + .contains("一件代发进入 WAIT_PALLETIZE") .contains("feeStatus=UNPRICED 且主流程仍成功") .contains("name: inboundId") .contains("schema: {type: string, pattern:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 21563a38f..aca53edea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -16,6 +16,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; import jakarta.validation.constraints.NotNull; @@ -145,9 +146,15 @@ class LoadingListControllerContractTest { .doesNotContainKey("internalContainerNo"); assertThat(properties(schemas, "ConfirmedSummary")) .containsKeys("planId", "containerBookingId", "loadingListNo"); - assertThat(properties(schemas, "LoadingItem")).containsKeys( - "itemId", "accountId", "inboundId", "inboundDetailId", "skuCode", + Map loadingItem = properties(schemas, "LoadingItem"); + assertThat(loadingItem).containsKeys( + "itemId", "accountId", "customsTaxMode", "taxRefundFlag", "inboundId", "inboundDetailId", "skuCode", "destinationWarehouse", "productQuantity", "frozenAt"); + assertThat(stringMap(loadingItem.get("customsTaxMode"))) + .containsEntry("nullable", true) + .containsEntry("enum", List.of("TAX_REFUND", "BUY_DECLARATION")); + assertThat(stringMap(loadingItem.get("taxRefundFlag"))) + .containsEntry("nullable", true); assertThat(properties(schemas, "OperationHistory")).containsKeys( "historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus", "beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index 6d477e85e..c544acbb8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -24,13 +24,8 @@ class PlatformLoadingPdaControllerContractTest { authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); - authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); - authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, - com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest.class); - authority("scanCarton", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, - com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest.class); - authority("sealPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, - com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest.class); + authority("pickPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, + PalletPickRequest.class); } private void authority(String name, String expected, Class... parameterTypes) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index 8665093a8..895f92b97 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class UnifiedInboundOpenApiContractTest { @Test - void documentsInternalUnifiedOrderEndpointsAndSixStatuses() throws Exception { + void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception { String yaml = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map document = stringMap(new Yaml().load(yaml)); @@ -25,8 +25,8 @@ class UnifiedInboundOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); - assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "唯一待分柜状态"); + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + assertThat(responseStatus.get("description").toString()).contains("WAIT_PALLETIZE", "待清点组托"); Map query = stringMap(schemas.get("UnifiedInboundQuery")); Map queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 581673ddc..56915fc93 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -14,6 +14,18 @@ class CarrierScheduleQueryOpenApiContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") + .contains("/api/platform/v1/carrier-schedule-queries/container-actual-facts:") + .contains("name: containerBookingIds") + .contains("maxItems: 200") + .contains("uniqueItems: true") + .contains("ContainerActualFacts:") + .contains("departedWarehouseAt:") + .contains("departedOriginPortAt:") + .contains("arrivedDestinationPortAt:") + .contains("pickupReadyAt:") + .contains("pickedUpAt:") + .contains("emptyReturnedAt:") + .contains("Estimated facts are never returned or inferred") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") .contains("x-permission: platform:firstleg:port-schedule-query:manual") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index b113eaaac..64933838e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -18,6 +18,7 @@ class PlatformCarrierScheduleQueryControllerContractTest { .containsExactly("/api/platform/v1/carrier-schedule-queries"); Map permissions = Map.of( "carriers", "platform:firstleg:port-schedule-query:list", + "containerActualFacts", "platform:firstleg:port-schedule-query:list", "page", "platform:firstleg:port-schedule-query:list", "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 1c8463923..4e783961c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -9,10 +9,31 @@ import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + @Test + void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception { + String sql = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("selectContainerActualFacts", List.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("container_booking_id IN") + .contains("f.actual_at IS NOT NULL") + .contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'") + .contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'") + .contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'") + .contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'") + .contains("MAX(CASE WHEN f.standard_node='PICKED_UP'") + .contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'") + .contains("ORDER BY q.container_booking_id") + .doesNotContain("estimated_at"); + assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1); + } + @Test void annotationSqlUsesExecutableComparisonOperators() throws Exception { String selectDue = String.join("\n", CarrierScheduleQueryMapper.class @@ -149,4 +170,8 @@ class CarrierScheduleQuerySqlContractTest { return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql") .getContentAsString(StandardCharsets.UTF_8); } + + private static int count(String source, String needle) { + return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 8ad349e24..0c0425841 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; @@ -72,6 +73,35 @@ class CarrierScheduleQueryServiceTest { when(portSchedules.containerBooking(20L)).thenReturn(container); } + @Test + void returnsBatchActualFactsFromOneRepositoryCall() { + ContainerActualFacts facts = new ContainerActualFacts(20L, 30L, 2, "READY", + "PICKUP_READY", NOW, NOW.minusDays(12), NOW.minusDays(10), NOW.minusDays(2), + NOW, null, null); + when(repository.containerActualFacts(List.of(20L, 21L))).thenReturn(List.of(facts)); + + assertThat(service.containerActualFacts(List.of(20L, 21L))).containsExactly(facts); + verify(repository).containerActualFacts(List.of(20L, 21L)); + } + + @Test + void rejectsInvalidActualFactBatchBeforeRepositoryAccess() { + assertThatThrownBy(() -> service.containerActualFacts(null)) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(List.of())) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(java.util.Collections.nCopies(201, 1L))) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(java.util.Arrays.asList(1L, null))) + .isInstanceOf(BizException.class).hasMessageContaining("正数"); + assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 0L))) + .isInstanceOf(BizException.class).hasMessageContaining("正数"); + assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 1L))) + .isInstanceOf(BizException.class).hasMessageContaining("重复"); + + verify(repository, never()).containerActualFacts(any()); + } + @Test void createsTaskOnlyForBookingCustomerAndAllocatedContainer() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java index db339e20a..6ae21e583 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -25,11 +25,14 @@ class PriceBookQueryServiceTest { void delegatesStableReadOperationsToTheOutputPort() { ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findEffectiveVersionId(10001L)).thenReturn(10L); when(reader.findChannelPrefix("SEA")).thenReturn(prefix); assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.effectiveVersionId(10001L)).isEqualTo(10L); assertThat(service.channelPrefix("SEA")).isSameAs(prefix); verify(reader).findActiveVersionId(); + verify(reader).findEffectiveVersionId(10001L); verify(reader).findChannelPrefix("SEA"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java new file mode 100644 index 000000000..9a4d8e0f8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java @@ -0,0 +1,61 @@ +package com.baiyue.firstleg.pricing; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerTierAmountAllocatorTest { + private final ContainerTierAmountAllocator allocator = new ContainerTierAmountAllocator(); + + @Test + void allocatesOneGroupByChargeableWeightAndConservesRoundedTotal() { + var result = allocator.allocate(List.of( + member(31, "100"), member(11, "101"), member(21, "102") + ), new BigDecimal("9.99")); + + assertThat(result.totalQuantity()).isEqualByComparingTo("303"); + assertThat(result.totalAmount()).isEqualByComparingTo("3026.97"); + assertThat(result.memberAmounts()).containsExactly( + java.util.Map.entry(11L, new BigDecimal("1008.99")), + java.util.Map.entry(21L, new BigDecimal("1018.98")), + java.util.Map.entry(31L, new BigDecimal("999.00"))); + assertThat(result.memberAmounts().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo(result.totalAmount()); + } + + @Test + void distributesRoundingCentsDeterministically() { + var result = allocator.allocate(List.of(member(3, "1"), member(2, "1"), member(1, "1")), + new BigDecimal("0.005")); + + assertThat(result.memberAmounts()).containsExactly( + java.util.Map.entry(1L, new BigDecimal("0.01")), + java.util.Map.entry(2L, new BigDecimal("0.01")), + java.util.Map.entry(3L, new BigDecimal("0.00"))); + assertThat(result.memberAmounts().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("0.02"); + } + + @Test + void keepsSingleInboundAmountAndQuantity() { + var result = allocator.allocate(List.of(member(11, "1.337")), new BigDecimal("10")); + + assertThat(result.totalQuantity()).isEqualByComparingTo("1.337"); + assertThat(result.totalAmount()).isEqualByComparingTo("13.37"); + assertThat(result.memberAmounts()).containsExactly(java.util.Map.entry(11L, new BigDecimal("13.37"))); + } + + @Test + void rejectsDuplicateInboundMembers() { + assertThatThrownBy(() -> allocator.allocate(List.of(member(11, "1"), member(11, "2")), BigDecimal.ONE)) + .hasMessageContaining("不能重复"); + } + + private static ContainerTierAmountAllocator.Member member(long inboundId, String weight) { + return new ContainerTierAmountAllocator.Member(inboundId, new BigDecimal(weight)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 4978dd74a..e339e34ab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -46,7 +46,7 @@ class ContainerBookingMapperContractTest { void filtersPageByStableCustomerId() throws Exception { String page = String.join("\n", ContainerBookingMapper.class .getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, - com.baiyue.firstleg.dto.ContainerDtos.Query.class) + com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class) .getAnnotation(Select.class).value()); assertThat(page) .contains("fl_ocean_booking_container_allocation customer_allocation") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index 49fb75be2..e00485be6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -12,11 +12,12 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { - @Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception { + @Test void overviewIncludesExactlyTheTwoCurrentBusinessDocumentTypes() throws Exception { String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) .getAnnotation(Select.class).value()); assertThat(sql) - .contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')") + .contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER')") + .doesNotContain("PICKUP_ORDER") .contains("ORDER BY document_type"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java new file mode 100644 index 000000000..261197b42 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerWorkbenchSqlContractTest { + @Test + void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception { + String sql = String.join("\n", ContainerBookingMapper.class + .getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, + com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class) + .getAnnotation(Select.class).value()); + + assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'", + "#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch", + "confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL", + "b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status") + .doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact"); + } + + @Test + void baselineDocumentsTheSingleSeventyTwoHourEmptyReturnRule() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql")); + assertThat(sql).contains("预计还空时间(卸柜完成时间加72小时,UTC)") + .doesNotContain("卸柜照片上传成功时间加两天"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java new file mode 100644 index 000000000..ed3c1118b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DropshipPalletizationSqlContractTest { + @Test + void moduleAndUnifiedSchemasExposeCountingPalletAndWholePalletAssignmentFacts() throws Exception { + String inbound = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String loading = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + for (String sql : new String[]{inbound, unified}) { + assertThat(sql) + .contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0") + .contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0") + .contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'") + .contains("expected_sku_quantity INT UNSIGNED NOT NULL") + .contains("actual_sku_quantity INT UNSIGNED NOT NULL") + .contains("count_operation_id VARCHAR(128) NOT NULL") + .contains("bind_operation_id VARCHAR(128) NULL"); + } + for (String sql : new String[]{loading, unified}) { + assertThat(sql) + .contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'") + .contains("pallet_id BIGINT UNSIGNED NULL") + .contains("indivisible TINYINT(1) NOT NULL DEFAULT 0") + .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); + } + assertThat(unified) + .contains("CREATE PROCEDURE converge_loading_assignment_schema()") + .contains("CREATE PROCEDURE converge_dropship_palletization_schema()") + .contains("CHANGE COLUMN operation_id count_operation_id") + .contains("DROP INDEX uk_loading_item_plan_detail") + .contains("ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 48004d23f..a6f183ca3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -12,11 +12,13 @@ import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import com.baiyue.firstleg.mapper.InboundFeeItemMapper; import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; @@ -46,6 +48,7 @@ class FbaPricingRepositoryRuleListTest { @MockBean private InboundFeeItemMapper feeItems; @MockBean private InboundPricingIdempotencyMapper idempotency; @MockBean private InboundOutboxMapper outbox; + @MockBean private ContainerTierPricingMapper containerTierPricing; @Resource private FbaPricingRepository repository; @BeforeAll static void initializeEntityMetadata() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 38a44e5ce..144a900f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -24,6 +24,8 @@ class FeeItemMigrationSqlContractTest { .contains("matched_seed.trade_scope = f.trade_scope") .contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'") .contains("fee_code = 'FBA_HEAD_FREIGHT'") + .contains("193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY'") + .contains("fee_code = 'PRIVATE_LAST_MILE_DELIVERY'") .contains("warehouse_receivable = 1") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_code") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java index 604b8b1a9..9fe3da95f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java @@ -18,16 +18,19 @@ class InboundCartonLabelSqlContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") .contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)") .contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)") .contains("inbound_detail_id BIGINT UNSIGNED NOT NULL"); assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") .contains("固定二维码内容:入仓码#总箱数-箱下标") .contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)"); assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'") .contains("phase2_carton_label_add_column('batch_status'") .contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)"); assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") .contains("unified_carton_label_add_column('label_version'") .contains("unified_carton_label_add_column('batch_status'") .contains("unified_carton_label_add_column('cargo_fingerprint'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index f24d554ff..659703cb6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -59,6 +59,11 @@ class LoadingListQueryMapperContractTest { String items = sql("selectItems", Select.class); assertThat(items) .contains("i.order_id", "i.order_no", "b.container_no") + .contains("inbound.tax_mode AS customs_tax_mode") + .contains("WHEN 'TAX_REFUND' THEN TRUE") + .contains("WHEN 'BUY_DECLARATION' THEN FALSE") + .contains("ELSE NULL") + .doesNotContain("fl_customs_container_document", "fl_customs_container_document_link") .contains("b.record_status='ACTIVE'") .contains("NULLIF(i.pallet_count,0)") .contains("fl_inbound_weight_result weight") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index f9996b3f3..63c6fffe0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.mapper.LoadingListQueryMapper; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -96,4 +97,47 @@ class LoadingListRepositoryTest { var confirmed = repository.preloadDetail(row).summary().readiness(); assertThat(confirmed.readyForPda()).isTrue(); } + + @Test + void mapsFrozenCustomsTaxFactsPerInboundAcrossCustomers() { + AggregateRow aggregate = new AggregateRow(); + aggregate.setPlanId(41L); + aggregate.setContainerBookingId(61L); + aggregate.setPlanNo("LP-41"); + aggregate.setPlanStatus("COMPLETED"); + aggregate.setVersion(3); + ItemRow refund = item(101L, 11L, 201L, "IN-REFUND", "TAX_REFUND", true); + ItemRow declaration = item(102L, 12L, 202L, "IN-BUY", "BUY_DECLARATION", false); + ItemRow unknown = item(103L, 12L, 203L, "IN-UNKNOWN", null, null); + when(mapper.selectItems(41L)).thenReturn(List.of(refund, declaration, unknown)); + when(mapper.selectHistories(41L)).thenReturn(List.of()); + + var items = repository.confirmedDetail(aggregate).items(); + + assertThat(items).extracting(value -> value.inboundCode(), value -> value.accountId(), + value -> value.customsTaxMode(), value -> value.taxRefundFlag()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("IN-REFUND", 11L, "TAX_REFUND", true), + org.assertj.core.groups.Tuple.tuple("IN-BUY", 12L, "BUY_DECLARATION", false), + org.assertj.core.groups.Tuple.tuple("IN-UNKNOWN", 12L, null, null)); + } + + private static ItemRow item(long itemId, long accountId, long inboundId, String inboundCode, + String customsTaxMode, Boolean taxRefundFlag) { + ItemRow row = new ItemRow(); + row.setItemId(itemId); + row.setAccountId(accountId); + row.setCustomerName("customer-" + accountId); + row.setCustomsTaxMode(customsTaxMode); + row.setTaxRefundFlag(taxRefundFlag); + row.setOrderId(300L + inboundId); + row.setOrderNo("ORDER-" + inboundId); + row.setInboundId(inboundId); + row.setInboundDetailId(400L + inboundId); + row.setInboundCode(inboundCode); + row.setPackageCount(1); + row.setLoadSequence(1); + row.setItemVersion(0); + return row; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index bf62e460f..d45182895 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -53,7 +53,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("SUM(package_count) package_count") .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("fl_dropship_pallet_carton carton") - .contains("pallet.pallet_status='COMPLETED'") + .contains("pallet.pallet_status='SEALED'") .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") .contains("i.inbound_id=o.id") .contains("o.warehouse_id=#{warehouseId}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index abc985b0e..fc7e30a40 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -67,7 +67,8 @@ class SmartLoadingSqlContractTest { assertThat(migration()) .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") - .contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)") + .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .contains("UNIQUE KEY uk_container_booking_no (booking_no)") .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") @@ -161,10 +162,11 @@ class SmartLoadingSqlContractTest { } @Test - @DisplayName("货柜简单文件只保留目的港清关与DO/PU") - void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception { - for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql) - .contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'") + @DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码") + void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception { + assertThat(migration()) + .contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'") + .contains("pickup_code VARCHAR(64) NULL") .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_at DATETIME(3) NULL"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 11d6a3460..01c6b1963 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest; +import com.baiyue.firstleg.dto.ContainerOverviewDtos.PickupCodeRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.mapper.ContainerOverviewMapper; import com.baiyue.firstleg.repository.ContainerRepository; @@ -154,6 +155,22 @@ class ContainerOverviewServiceTest { eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}")); } + @Test void pickupCodePreservesLeadingZerosAndUsesContainerVersion() { + ContainerBookingEntity locked = new ContainerBookingEntity(); locked.setId(41L); locked.setVersion(3); + ContainerBookingEntity refreshed = new ContainerBookingEntity(); refreshed.setId(41L); refreshed.setVersion(4); refreshed.setPickupCode("00123456"); + when(containers.findActiveForUpdate(41L)).thenReturn(locked); + when(containers.updatePickupCode(41L, "00123456", 3, 7L, "operator")).thenReturn(1); + when(containers.find(41L)).thenReturn(refreshed); + when(mapper.documents(41L)).thenReturn(java.util.List.of()); + when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); + when(mapper.transport(41L)).thenReturn(java.util.List.of()); + + var result = service.updatePickupCode(41L, 7L, "operator", new PickupCodeRequest("00123456", 3)); + + assertThat(result.container().pickupCode()).isEqualTo("00123456"); + assertThat(result.container().version()).isEqualTo(4); + } + @Test void exactReplayReturnsWithoutLockingOrWriting() { String hash = hash("MARK|41|3|broken seal"); when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java new file mode 100644 index 000000000..51247d226 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DropshipPalletizationServiceTest { + private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); + private final DropshipPalletizationService service=new DropshipPalletizationService(); + + DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); } + + @Test void resolvesUncountedCartonBeforeQuantityConfirmation(){ + TaskRow task=task(); LabelRow label=label(); + when(mapper.task(10L,100L)).thenReturn(task); when(mapper.label(10L,"BOX-1")).thenReturn(label); + var result=service.resolveCarton(10L,100L,"BOX-1",7L); + assertThat(result.inboundCode()).isEqualTo("IN-1"); + assertThat(result.skuCode()).isEqualTo("SKU-1"); + assertThat(result.expectedSkuQuantity()).isEqualTo(12); + assertThat(result.countStatus()).isEqualTo("UNCOUNTED"); + assertThat(result.canCount()).isTrue(); + } + + @Test void mismatchCannotBeRecordedWithoutAcceptedVarianceAndRemark(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + var wrongAction=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.MATCH,null,null,"op-1"); + assertThatThrownBy(()->service.countCarton(10L,wrongAction,7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("确认差异处理结果"); + var noRemark=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.ACCEPT_VARIANCE,null,null,"op-2"); + assertThatThrownBy(()->service.countCarton(10L,noRemark,7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("处理说明"); + verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); + } + + @Test void countsOneCartonDirectlyIntoScannedPallet(){ + TaskRow before=task(),after=task(); after.setCountedCartons(1); after.setAllocatedCartons(1); + LabelRow label=label(); PalletRow pallet=pallet(); CartonRow carton=carton(); + when(mapper.task(10L,100L)).thenReturn(before,after); + when(mapper.label(10L,"BOX-1")).thenReturn(label); when(mapper.palletByNo("DP-260908-0001")).thenReturn(pallet); + when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton); + when(mapper.pallet(10L,20L,100L)).thenReturn(pallet); when(mapper.cartons(20L)).thenReturn(java.util.List.of(carton)); + when(mapper.metrics(20L)).thenReturn(new DropshipPalletizationMapper.PalletMetrics(java.math.BigDecimal.ONE,java.math.BigDecimal.TEN)); + var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1"); + var result=service.countCarton(10L,request,7L,"张三"); + assertThat(result.carton().countStatus()).isEqualTo("BOUND"); + assertThat(result.pallet().status()).isEqualTo("OPEN"); + verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"), + eq("货物"),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + } + + private static TaskRow task(){ TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t; } + private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} + private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} + private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 19954f276..ce25a475a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,13 +11,17 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator; import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.apache.ibatis.annotations.Select; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,6 +34,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.concurrent.atomic.AtomicReference; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -37,6 +42,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -56,6 +62,7 @@ class FbaPricingServiceTest { @MockBean private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository; @MockBean private InboundFeeDetailService feeDetails; @MockBean private ActivePriceBookEstimator priceBookEstimator; + @MockBean private ContainerTierAmountAllocator containerTierAllocator; @Resource private FbaPricingService service; @Test @@ -120,7 +127,7 @@ class FbaPricingServiceTest { inbound.setCarrierId(null); inbound.setCarrierName(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "167.017", "2.345678", "391.77", "10.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); @@ -162,7 +169,7 @@ class FbaPricingServiceTest { when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item)); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0019", "100", "100.19", "0")); service.calculate(TENANT, OPERATOR, INBOUND, NOW, @@ -200,7 +207,7 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("200.000", "0.2000"); inbound.setCustomerId(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "200.000", "2.000000", "400.00", "0.00")); assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer")) @@ -217,7 +224,11 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("363.001", "0.5000"); inbound.setPricingTaxMode("TAX_EXCLUDED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1); + rule.setChargeMode("CBM"); rule.setDefaultRule(true); + when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule)); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); @@ -228,6 +239,8 @@ class FbaPricingServiceTest { assertThat(response.conversionFactor()).isEqualByComparingTo("363"); assertThat(response.roundingScale()).isEqualTo(6); assertThat(response.roundingMode()).isEqualTo("HALF_UP"); + assertThat(response.chargeRuleId()).isEqualTo(601L); + assertThat(response.ruleCode()).isEqualTo("CBM-RULE"); } @Test @@ -237,7 +250,7 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("100.000", "1.0000"); inbound.setProcessStatus("WAIT_SPLIT"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "167.000", "2.000000", "334.00", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); @@ -296,11 +309,11 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("10", "1"); inbound.setMeasureReviewStatus("REJECTED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0000", "10", "10", "0")); assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull(); - verify(priceBookEstimator).estimate(any()); + verify(priceBookEstimator).estimate(eq(TENANT), any()); } @Test @@ -315,7 +328,7 @@ class FbaPricingServiceTest { when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0000", "10", "10", "0")); FeeSnapshotResponse response = service.calculate( @@ -357,7 +370,7 @@ class FbaPricingServiceTest { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenThrow( + when(priceBookEstimator.estimate(anyLong(), any())).thenThrow( new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) @@ -483,15 +496,19 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - new BigDecimal("20.00"), "客服修正"), "adjust-1"); + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-1"); assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeStatus()).isEqualTo("SENT"); - assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); + assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000"); + assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000"); + assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000"); + assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT"); - assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000"); + assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000"); verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); @@ -538,7 +555,7 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - BigDecimal.ZERO, "客服修正"), "locked-adjust")) + BigDecimal.ZERO, "客服修正", true), "locked-adjust")) .isInstanceOf(BizException.class) .hasMessageContaining("客户已确认"); @@ -574,11 +591,11 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - new BigDecimal("20.00"), "客服修正"), "adjust-calculated"); + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-calculated"); assertThat(response.feeStatus()).isEqualTo("CALCULATED"); - assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW); @@ -598,7 +615,7 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - BigDecimal.ZERO, "客服修正"), "stale-adjust")) + BigDecimal.ZERO, "客服修正", true), "stale-adjust")) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -607,6 +624,255 @@ class FbaPricingServiceTest { verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); } + @Test + @DisplayName("人工计费量允许上下10%含边界") + void acceptsInclusiveToleranceBoundaries() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; }) + .when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current); + + service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO, + "下界确认", true), "lower-bound"); + + verify(repository).insertFee(argThat(fee -> + fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0)); + } + + @Test + @DisplayName("人工计费量越过10%或未二次确认返回冲突") + void rejectsOutOfToleranceOrUnconfirmedAdjustment() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED")); + when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO, + "越界", true), "outside")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO, + "未确认", false), "unconfirmed")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + + @Test + @DisplayName("同客户同柜区域阶梯按总计费重命中临界档并按票分摊") + void appliesContainerCustomerTierAndAllocatedAmount() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), + memberRow(901L, 30002L, "100", "0.1") + )); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( + regionEstimate("100", "10", "1000"), regionEstimate("200", "8", "1600")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"), + Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("800.00")))); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "container-tier"); + + assertThat(response.chargeableQuantity()).isEqualByComparingTo("100"); + assertThat(response.unitPrice()).isEqualByComparingTo("8"); + assertThat(response.subtotalAmount()).isEqualByComparingTo("800.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("800.00"); + ArgumentCaptor requests = + ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class); + verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture()); + assertThat(requests.getAllValues().get(1).actualWeightKg()).isEqualByComparingTo("200"); + assertThat(requests.getAllValues().get(1).actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("单票柜次保持原区域阶梯结果,不进行无意义分摊") + void keepsIndividualResultForSingleInboundContainer() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1") + )); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "single-container-tier"); + + assertThat(response.totalAmount()).isEqualByComparingTo("1000"); + verify(containerTierAllocator, never()).allocate(any(), any()); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("无柜次关系不改变单票区域阶梯计价") + void keepsIndividualResultWithoutContainerRelation() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of()); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000")); + + assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "no-container-tier").totalAmount()) + .isEqualByComparingTo("1000"); + verify(containerTierAllocator, never()).allocate(any(), any()); + } + + @Test + @DisplayName("柜次成员查询只按客户和同一柜次隔离") + void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException { + String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + String lockedSql = ContainerTierPricingMapper.class.getMethod("membersForUpdate", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql).contains("plan.container_booking_id=current_plan.container_booking_id") + .contains("grouped_item.account_id=#{customerId}") + .contains("grouped_inbound.tenant_id=#{customerId}") + .doesNotContain("grouped_inbound.destination_code") + .doesNotContain("grouped_inbound.destination_warehouse_id"); + assertThat(lockedSql).contains("plan.container_booking_id=#{containerBookingId}") + .contains("item.account_id=#{customerId}").contains("FOR UPDATE"); + } + + @Test + @DisplayName("柜内客户费用重算复用冻结选量和来源,并按统一阶梯分摊") + void recalculatesUnlockedContainerFeesFromFrozenSelections() { + readyRepository(); + InboundPricingEntity firstInbound = inbound("999", "9"); + InboundPricingEntity secondInbound = inbound("888", "8"); + secondInbound.setId(30002L); secondInbound.setInboundCode("RK-002"); + InboundFeeEntity first = savedFee("CALCULATED"); + first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000")); + first.setMeasurementId(301L); first.setMeasurementSource("OUTER"); + InboundFeeEntity second = savedFee("CONFIRMED"); + second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000")); + second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L); + second.setMeasurementSource("ACTUAL"); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "999", "9"), memberRow(901L, 30002L, "888", "8") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(firstInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( + regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"), + Map.of(INBOUND, new BigDecimal("720.00"), 30002L, new BigDecimal("880.00")))); + + service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-1"); + + ArgumentCaptor fees = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository, times(2)).insertFee(fees.capture()); + assertThat(fees.getAllValues().get(0).getActualWeightKg()).isEqualByComparingTo("90.000"); + assertThat(fees.getAllValues().get(0).getActualVolumeCbm()).isEqualByComparingTo("0.1000"); + assertThat(fees.getAllValues().get(0).getMeasurementId()).isEqualTo(301L); + assertThat(fees.getAllValues().get(0).getMeasurementSource()).isEqualTo("OUTER"); + assertThat(fees.getAllValues().get(0).getSubtotalAmount()).isEqualByComparingTo("720.00"); + assertThat(fees.getAllValues().get(1).getActualWeightKg()).isEqualByComparingTo("110.000"); + assertThat(fees.getAllValues().get(1).getMeasurementId()).isEqualTo(302L); + assertThat(fees.getAllValues().get(1).getMeasurementSource()).isEqualTo("ACTUAL"); + ArgumentCaptor requests = + ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class); + verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture()); + assertThat(requests.getAllValues()).allSatisfy(request -> { + assertThat(request.actualWeightKg()).isEqualByComparingTo("200"); + assertThat(request.actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO); + }); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("柜内任一已发送或客户确认费用阻断整组重算") + void rejectsContainerRecalculationWhenAnyFeeIsImmutable() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + InboundFeeEntity sent = savedFee("SENT"); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + + assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2")) + .isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认"); + + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString()); + verify(repository, never()).insertFee(any()); + verify(priceBookEstimator, never()).estimate(anyLong(), any()); + } + + @Test + @DisplayName("待审核或待测量成员无初算费用时保持占位并返回未就绪") + void keepsContainerRelationshipWhenAnyMemberFeeIsNotReady() { + readyRepository(); + InboundPricingEntity pricedInbound = inbound("100", "0.1"); + InboundPricingEntity pendingInbound = inbound("80", "0.1"); + pendingInbound.setId(30002L); pendingInbound.setInboundCode("RK-PENDING"); + InboundFeeEntity priced = savedFee("CALCULATED"); + priced.setActualWeightKg(new BigDecimal("100")); priced.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(pricedInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(pendingInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(priced); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(null); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "pending-member"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY); + assertThat(result.recalculatedCount()).isZero(); + verify(repository, never()).insertFee(any()); + verify(priceBookEstimator, never()).estimate(anyLong(), any()); + } + + @Test + @DisplayName("相同柜次重算触发标识重放不重复创建费用版本") + void replaysCompletedContainerRecalculationTrigger() { + InboundPricingIdempotencyEntity completed = completedContainerRecalculation(901L, "same-trigger"); + when(repository.findIdempotency(TENANT, 0L, "CONTAINER_FEE_RECALCULATE", "CONTAINER:901:same-trigger")) + .thenReturn(completed); + + service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "same-trigger"); + + verify(repository, never()).containerTierMembersForUpdate(anyLong(), anyLong()); + verify(repository, never()).insertFee(any()); + } + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) @@ -655,6 +921,25 @@ class FbaPricingServiceTest { "KG".equals(unit) ? new BigDecimal("167") : new BigDecimal("363"), 6, "HALF_UP"); } + private ContainerTierPricingMapper.MemberRow memberRow(long containerId, long inboundId, + String weight, String volume) { + return new ContainerTierPricingMapper.MemberRow(containerId, inboundId, + new BigDecimal(weight), new BigDecimal(volume)); + } + + private ActivePriceBookEstimator.Estimate regionEstimate(String quantity, String unitPrice, String amount) { + RateRow rate = new RateRow(); + rate.setId(202L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-TIER-1"); rate.setRateVersion(1); + rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK"); + rate.setPriceModel(PriceModel.REGION_WEIGHT_TIER.name()); rate.setDestinationType("FBA"); + rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit("KG"); + rate.setUnitPrice(new BigDecimal(unitPrice)); rate.setCurrency("USD"); rate.setAdditionalFee(BigDecimal.ZERO); + rate.setEffectiveFrom(NOW.minusDays(1)); + return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.REGION_WEIGHT_TIER, + PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), "KG", new BigDecimal(unitPrice), + new BigDecimal(amount), "USD", new BigDecimal("167"), 6, "HALF_UP"); + } + private InboundPricingIdempotencyEntity completed(String operation, long feeId) { InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED"); @@ -666,6 +951,20 @@ class FbaPricingServiceTest { return value; } + private InboundPricingIdempotencyEntity completedContainerRecalculation(long containerBookingId, String trigger) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setId(900L); value.setOperation("CONTAINER_FEE_RECALCULATE"); value.setStatus("COMPLETED"); + value.setResultResourceId(containerBookingId); + try { + value.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(("CONTAINER_FEE_RECALCULATE:" + containerBookingId + ':' + trigger) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + return value; + } + private InboundFeeEntity savedFee(String status) { InboundFeeEntity value = new InboundFeeEntity(); value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 6588c9a40..a0fdc7b00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -337,6 +337,29 @@ class FeeManagementServiceTest { .hasMessageContaining("仓储应收/应付"); } + @Test + @DisplayName("私人仓尾程派送费只允许PRIVATE目的地") + void restrictsPrivateLastMileDeliveryFeeByDestinationType() { + InboundOrderEntity fbaInbound = inbound(); + fbaInbound.setDestinationType("FBA"); + FeeItemEntity privateDelivery = item(true, true); + privateDelivery.setFeeCode("PRIVATE_LAST_MILE_DELIVERY"); + when(repository.findInbound(10001L, 21L)).thenReturn(fbaInbound); + when(repository.findItem(11L)).thenReturn(privateDelivery); + + assertThatThrownBy(() -> details.create(10001L, 21L, 9L, + detailRequest("RECEIVABLE"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("目的地类型为PRIVATE"); + verify(repository, never()).insertDetail(any()); + + fbaInbound.setDestinationType("PRIVATE"); + doAnswer(invocation -> { ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); return null; }) + .when(repository).insertDetail(any()); + assertThat(details.create(10001L, 21L, 9L, detailRequest("RECEIVABLE")).getId()) + .isEqualTo(32L); + } + @Test @DisplayName("审核只更新待审核明细") void approvesPendingDetail() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index 868af9ece..a2a0b002a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PrintableOrderInboundClient; import com.baiyue.firstleg.entity.InboundCartonLabelEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundCartonLabelRepository; @@ -28,6 +29,7 @@ import static org.mockito.Mockito.when; class InboundCartonLabelServiceTest { @Mock private InboundCartonLabelRepository repository; @Mock private OrderCargoClient orderCargoClient; + @Mock private PrintableOrderInboundClient printableOrderInboundClient; @Mock private InboundOrderCreationService inboundOrderCreationService; private InboundCartonLabelService service; @@ -36,6 +38,7 @@ class InboundCartonLabelServiceTest { service = new InboundCartonLabelService(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient); + ReflectionTestUtils.setField(service, "printableOrderInboundClient", printableOrderInboundClient); ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } @@ -222,7 +225,7 @@ class InboundCartonLabelServiceTest { @Test void rejectsAnOrderWithoutAnOwnedPreparingInbound() { when(repository.findByOrder(10001L, 60001L)).thenReturn(null); - when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L)) + when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L)) .thenThrow(org.mockito.Mockito.mock(feign.FeignException.class)); assertThatThrownBy(() -> service.requireInboundIdByOrder(10001L, 60001L)) @@ -239,7 +242,7 @@ class InboundCartonLabelServiceTest { null, null, null, 2); InboundOrderEntity repaired = inbound(); when(repository.findByOrder(10001L, 60001L)).thenReturn(null, repaired); - when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L)) + when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(snapshot)); assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index ab0c01789..65b4a70dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -75,8 +75,8 @@ class InboundMeasurementServiceTest { "REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123"))); when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( - 80001L, 80L, 1, "123123", 3, new BigDecimal("40"), - new BigDecimal("30"), new BigDecimal("20"))))); + 80001L, 80L, 1, "123123", null, 3, new BigDecimal("40"), + new BigDecimal("30"), new BigDecimal("20"), "FBA-DECLARED-001")))); } @Test @@ -196,7 +196,9 @@ class InboundMeasurementServiceTest { item.setId(40L); item.setTenantId(1L); item.setMeasurementId(30L); + item.setInboundDetailId(80001L); item.setSourceImageFileId(140L); + item.setScannedShipmentId("FBA-SCANNED-001"); item.setOuterLengthCm(new BigDecimal("64.000")); item.setOuterWidthCm(new BigDecimal("54.000")); item.setOuterHeightCm(new BigDecimal("54.000")); @@ -217,11 +219,15 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + assertThat(result.inboundCode()).isEqualTo("RK-001"); assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000"); assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500"); assertThat(result.items()).singleElement().satisfies(dimension -> { assertThat(dimension.id()).isEqualTo(40L); + assertThat(dimension.inboundCode()).isEqualTo("RK-001"); assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); + assertThat(dimension.fbaShipmentNo()).isEqualTo("FBA-DECLARED-001"); + assertThat(dimension.scannedShipmentId()).isEqualTo("FBA-SCANNED-001"); assertThat(dimension.outerLengthCm()).isNull(); assertThat(dimension.outerWidthCm()).isNull(); assertThat(dimension.outerHeightCm()).isNull(); @@ -246,6 +252,63 @@ class InboundMeasurementServiceTest { }); } + @Test + void detailMapsMultipleDeclaredShipmentIdsByInboundDetailWithoutFallingBackToScannedValue() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + InboundMeasurementItemEntity first = new InboundMeasurementItemEntity(); + first.setId(41L); + first.setInboundDetailId(80001L); + first.setScannedShipmentId("SCAN-DIFFERENT-001"); + InboundMeasurementItemEntity second = new InboundMeasurementItemEntity(); + second.setId(42L); + second.setInboundDetailId(80002L); + InboundMeasurementItemEntity unmatched = new InboundMeasurementItemEntity(); + unmatched.setId(43L); + unmatched.setInboundDetailId(89999L); + unmatched.setScannedShipmentId("SCAN-ONLY-999"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(first, second, unmatched)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of( + new OrderCargoClient.CargoLabelBinding(80001L, 80L, 1, "货物1", null, + 1, null, null, null, "FBA-DECLARED-001"), + new OrderCargoClient.CargoLabelBinding(80002L, 80L, 2, "货物2", null, + 1, null, null, null, "FBA-DECLARED-002")))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.inboundCode()).isEqualTo("RK-001"); + assertThat(result.items()) + .extracting(InboundMeasurementResponses.Item::inboundDetailId, + InboundMeasurementResponses.Item::inboundCode, + InboundMeasurementResponses.Item::fbaShipmentNo, + InboundMeasurementResponses.Item::scannedShipmentId) + .containsExactly( + tuple(80001L, "RK-001", "FBA-DECLARED-001", "SCAN-DIFFERENT-001"), + tuple(80002L, "RK-001", "FBA-DECLARED-002", null), + tuple(89999L, "RK-001", null, "SCAN-ONLY-999")); + verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L); + } + + @Test + void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 81L, 1, "其他订单货物", null, 1, null, null, null, + "FBA-OTHER-001")))); + + assertThatThrownBy(() -> service.detail(1L, 10L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("货物明细归属不一致"); + } + @Test void detailReturnsWarehouseOuterDimensionsOnlyAfterManualOverrideWasSaved() { InboundOrderEntity inbound = inbound("MEASURING"); @@ -333,8 +396,8 @@ class InboundMeasurementServiceTest { "NEW-REF", new BigDecimal("40.000"), new BigDecimal("0.8000"), "最新申报品名"))); when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( - 80001L, 80L, 1, "最新申报品名", 4, new BigDecimal("50"), - new BigDecimal("40"), new BigDecimal("30"))))); + 80001L, 80L, 1, "最新申报品名", null, 4, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"), "FBA-LATEST-001")))); InboundMeasurementResponses.Detail result = service.detail(1L, 10L); @@ -356,6 +419,7 @@ class InboundMeasurementServiceTest { assertThat(result.items()).singleElement().satisfies(measured -> { assertThat(measured.id()).isEqualTo(40L); assertThat(measured.boxCode()).isEqualTo("RK-001#2-1"); + assertThat(measured.fbaShipmentNo()).isEqualTo("FBA-LATEST-001"); assertThat(measured.packageCount()).isEqualTo(2); assertThat(measured.lengthCm()).isEqualByComparingTo("45"); }); @@ -1116,6 +1180,21 @@ class InboundMeasurementServiceTest { verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); } + @Test + void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(30L, 10, null, List.of()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交待审核"); + + verifyNoInteractions(batchService); + verify(orderCargoClient, never()).detail(any(), anyLong(), anyLong(), anyLong()); + } + @Test void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -1157,6 +1236,7 @@ class InboundMeasurementServiceTest { inbound.setTenantId(1L); inbound.setOrderId(80L); inbound.setWarehouseId(100L); + inbound.setInboundCode("RK-001"); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); inbound.setVersion(0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 28c6ecf41..748dd8fc8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -64,6 +64,8 @@ class LoadingListServiceTest { @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; @MockBean + private FbaPricingService fbaPricingService; + @MockBean private InboundHoldGuard inboundHoldGuard; @Resource private LoadingListService service; @@ -630,13 +632,13 @@ class LoadingListServiceTest { } @Test - void genericContainerDocumentsOnlyAcceptDeliveryAndPickupOrders() { + void genericContainerDocumentsOnlyAcceptDeliveryOrders() { assertThatThrownBy(() -> service.containerLogisticsDocument(30L, LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class) - .hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER"); + .hasMessageContaining("DELIVERY_ORDER"); assertThatThrownBy(() -> service.containerLogisticsDocument(30L, "UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class) - .hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER"); + .hasMessageContaining("DELIVERY_ORDER"); verify(repository, never()).aggregateByBooking(30L); } @@ -668,36 +670,6 @@ class LoadingListServiceTest { eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); } - @Test - void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() { - AggregateRow plan = row("COMPLETED", 4); - byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); - var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content); - String contentHash = sha256Bytes(content); - ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false); - ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false); - saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash); - var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L, - "CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length); - when(repository.aggregateByBooking(30L)).thenReturn(plan); - when(repository.history(anyString())).thenReturn(null); - when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved); - when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"), - eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"), - eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata)); - when(repository.replaceDocument(any())).thenReturn(1); - - var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER, - 9L, "operator", "pu-update", 2, null, file); - - assertThat(result.fileId()).isEqualTo(102L); - assertThat(result.version()).isEqualTo(3); - verify(repository).replaceDocument(any()); - verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"), - eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); - } - @Test void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { AggregateRow plan = row("COMPLETED", 4); @@ -778,6 +750,9 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(before, after); when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED, 2)); when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); @@ -791,12 +766,35 @@ class LoadingListServiceTest { assertThat(response.readiness().pdaTaskId()).isEqualTo(80L); verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(102L), anyString()); verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), eq("operator"), org.mockito.ArgumentMatchers.isNull()); } + @Test + void refusesConfirmationWhenAContainerCustomerFeeIsNotReadyWithoutTouchingPlan() { + AggregateRow before = row("EDITING", 3); + List links = List.of(new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null)); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("初始费用"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + @Test void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { PreloadConfirmationResponse expected = new PreloadConfirmationResponse( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index dc043ee27..0ce9dc9f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -30,6 +30,7 @@ import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; @@ -103,6 +104,8 @@ class LoadingPickServiceTest { private InboundPalletAllocationRepository palletAllocations; @MockBean private InboundHoldGuard inboundHoldGuard; + @MockBean + private DropshipPalletizationMapper dropshipPallets; @Resource private LoadingPickService service; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java deleted file mode 100644 index f2ce1d0c2..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.baiyue.firstleg.service; - -import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; -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.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@SpringJUnitConfig(LoadingWorkPalletService.class) -class LoadingWorkPalletServiceTest { - @MockBean private LoadingWorkPalletMapper pallets; - @MockBean private LoadingWorkPalletCartonMapper cartons; - @MockBean private InboundHoldGuard inboundHoldGuard; - @Resource private LoadingWorkPalletService service; - - @Test - void scansDropshipCartonOntoMixedCustomerWorkPalletWithoutChangingPickWorkflow() { - when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); - LoadingWorkPalletEntity pallet = pallet(30L, "OPEN"); - when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet); - CartonCandidate candidate = candidate(); - when(cartons.selectCandidates(10L, 20L, "BOX-A")).thenReturn(List.of(candidate)); - doAnswer(invocation -> { - LoadingWorkPalletCartonEntity value = invocation.getArgument(0); - value.setId(50L); - return 1; - }).when(cartons).insert(any(LoadingWorkPalletCartonEntity.class)); - when(cartons.selectPalletCartons(30L)).thenAnswer(invocation -> List.of()); - - service.scan(10L, 30L, new ScanRequest(20L, "BOX-A", "SCAN-1"), 99L, "作业员"); - - var captor = org.mockito.ArgumentCaptor.forClass(LoadingWorkPalletCartonEntity.class); - verify(cartons).insert(captor.capture()); - assertThat(captor.getValue().getTenantId()).isEqualTo(88L); - assertThat(captor.getValue().getLoadingPlanItemId()).isEqualTo(41L); - assertThat(captor.getValue().getSkuCode()).isEqualTo("SKU-A"); - } - - @Test - void refusesToSealUntilScannedCartonsAreReconciledByExistingPickSubmission() { - when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); - when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet(30L, "OPEN")); - when(cartons.countPalletCartons(30L)).thenReturn(2); - when(cartons.countUnreconciled(10L)).thenReturn(1); - - assertThatThrownBy(() -> service.seal(10L, 30L, - new SealRequest(20L, 0, "SEAL-1"), 99L, "作业员")) - .hasMessageContaining("原PDA拣货提交"); - } - - private static TaskContext context() { - return new TaskContext(10L, 11L, "LOADING", 20L, 99L, 2); - } - - private static LoadingWorkPalletEntity pallet(long id, String status) { - LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); - value.setId(id); value.setPickTaskId(10L); value.setLoadingPlanId(11L); - value.setWarehouseId(20L); value.setPalletCode("PLT-01"); - value.setPalletStatus(status); value.setVersion(0); - return value; - } - - private static CartonCandidate candidate() { - CartonCandidate value = new CartonCandidate(); - value.setCartonLabelId(40L); value.setTenantId(88L); value.setInboundId(81L); - value.setInboundDetailId(82L); value.setBoxCode("BOX-A"); - value.setLoadingPlanItemId(41L); value.setPickItemId(42L); - value.setSkuCode("SKU-A"); value.setUnitsPerCarton(20); - return value; - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index ebdb1dccc..4ba98e21f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -392,28 +392,31 @@ class MeasurementBatchServiceTest { } @Test - void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() { + void submittedMeasurementCannotBeResetToDraftWhileReviewIsPending() { InboundMeasurementEntity existing = new InboundMeasurementEntity(); existing.setId(100L); existing.setTenantId(1L); existing.setInboundId(10L); existing.setSourceType("MANUAL"); - existing.setStatus("APPROVED"); + existing.setStatus("SUBMITTED"); existing.setVersion(3); + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); - when(repository.updateMeasurement(existing)).thenReturn(true); MeasurementBatchCommand command = new MeasurementBatchCommand( 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"), new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE))); - InboundMeasurementEntity saved = service.saveBatch(command); - - assertThat(saved.getStatus()).isEqualTo("DRAFT"); - verify(repository).updateMeasurement(existing); - verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), - org.mockito.ArgumentMatchers.eq(100L), any()); + assertThatThrownBy(() -> service.saveBatch(command)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交批次必须保持冻结"); + assertThat(existing.getStatus()).isEqualTo("SUBMITTED"); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).replaceItems( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8f6329212..b660e0a4e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -29,6 +29,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,7 +45,7 @@ class MerchantPriceBookServiceTest { @Test void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); @@ -59,7 +60,7 @@ class MerchantPriceBookServiceTest { @Test void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); @@ -72,7 +73,7 @@ class MerchantPriceBookServiceTest { @Test void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true); var response = service.requireAvailableChannel( 10001L, 10L, "LEGACY_CHANNEL", "FBA", false); @@ -84,7 +85,7 @@ class MerchantPriceBookServiceTest { @Test void shouldReturnEmptyChannelsWithoutActiveVersion() { - when(repository.activeVersionId()).thenReturn(null); + when(repository.effectiveVersionId(anyLong())).thenReturn(null); assertThat(service.channels(10001L, 10L)).isEmpty(); verify(repository, never()).channels(1L, 10L); @@ -93,7 +94,7 @@ class MerchantPriceBookServiceTest { @Test void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() { - when(repository.activeVersionId()).thenReturn(1L, 2L); + when(repository.effectiveVersionId(anyLong())).thenReturn(1L, 2L); when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE))); @@ -105,7 +106,7 @@ class MerchantPriceBookServiceTest { @Test void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE), channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER))); @@ -124,7 +125,7 @@ class MerchantPriceBookServiceTest { @Test void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of()); @@ -136,7 +137,7 @@ class MerchantPriceBookServiceTest { @Test void shouldRejectForgedOrRemovedChannel() { - when(repository.activeVersionId()).thenReturn(2L); + when(repository.effectiveVersionId(anyLong())).thenReturn(2L); when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL", PriceModel.FBA_WAREHOUSE_RATE))); @@ -282,7 +283,7 @@ class MerchantPriceBookServiceTest { } private void stubChannel(long versionId, String code, PriceModel model) { - when(repository.activeVersionId()).thenReturn(versionId); + when(repository.effectiveVersionId(anyLong())).thenReturn(versionId); when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model))); } @@ -292,6 +293,7 @@ class MerchantPriceBookServiceTest { String weight, String volume) { return new EstimateRequest(10L, channelCode, destinationCode, destinationType, destinationWarehouseId, null, destinationRegion, pricingTaxMode, + pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM", decimal(weight), decimal(volume)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java index e21fcfdaf..ef8b72bd3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -18,6 +18,7 @@ import java.math.BigDecimal; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,7 +49,7 @@ class OrderChannelChangePricingServiceTest { InboundPricingEntity inbound = inbound("CALCULATED"); when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED")); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); var response = service.validate(request(4)); @@ -64,7 +65,7 @@ class OrderChannelChangePricingServiceTest { InboundFeeEntity fee = fee(91L, "CALCULATED"); when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1); when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1); when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1); @@ -88,7 +89,7 @@ class OrderChannelChangePricingServiceTest { token.setRequestHash(requestHash(request(4))); when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index 938211c4f..c4242c9fc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -41,7 +41,7 @@ class OverseasContainerEventConsumerTest { } @Test - void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() { + void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() { ContainerBookingEntity container = container(); when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container); Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); @@ -52,7 +52,7 @@ class OverseasContainerEventConsumerTest { verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), eq(LocalDateTime.parse("2026-08-20T02:30:00")), - eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L)); + eq(LocalDateTime.parse("2026-08-23T03:00:00")), isNull(), eq(7L)); ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class); verify(inbox).insert(captor.capture()); assertThat(captor.getValue().getAggregateId()).isEqualTo("1001"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java index 42652dc2d..5729b9a57 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -64,6 +64,22 @@ class PlatformPriceBookServiceTest { ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } + @Test + void listsCustomerSpecificVersionsWithinRequestedScope() { + PriceBookVersionEntity customerVersion = version(21L, PriceBookStatus.ACTIVE); + customerVersion.setTenantId(10001L); + when(repository.listVersions(10001L, PriceBookStatus.ACTIVE.name())) + .thenReturn(List.of(customerVersion)); + + var result = service.list(10001L, PriceBookStatus.ACTIVE); + + assertThat(result).singleElement().satisfies(summary -> { + assertThat(summary.id()).isEqualTo(21L); + assertThat(summary.customerId()).isEqualTo(10001L); + }); + verify(repository).listVersions(10001L, PriceBookStatus.ACTIVE.name()); + } + @Test void fileServiceFailureMarksAllocatedVersionFailed() { executeTransactionsImmediately(); @@ -121,7 +137,7 @@ class PlatformPriceBookServiceTest { assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers())) .map(java.lang.reflect.Method::getName).toList()) - .containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source"); + .containsOnly("list", "upload", "detail", "publish", "source"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index cdbed4b46..fbae44ef2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -34,6 +34,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; import com.baiyue.firstleg.split.service.SplitPreloadService; @@ -93,8 +94,10 @@ class SmartLoadingServiceTest { @MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private SplitPreloadService splitPreloadService; @MockBean private ColoadGroupAssignmentPort coloadGroups; + @MockBean private FbaPricingService fbaPricingService; @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; + @MockBean private DropshipPalletizationMapper dropshipPallets; @Resource private SmartLoadingService service; @TestConfiguration @@ -112,6 +115,9 @@ class SmartLoadingServiceTest { when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, new BigDecimal("10"), new BigDecimal("2")))); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); } @Test @@ -390,6 +396,7 @@ class SmartLoadingServiceTest { assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50"); verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u"); verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(customerId), anyString()); } @Test @@ -864,6 +871,8 @@ class SmartLoadingServiceTest { verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L), org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(9L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(19L), anyString()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java new file mode 100644 index 000000000..9686b8a93 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -0,0 +1,47 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageOpenApiContractTest { + @Test + void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception { + assertPermission("views", "platform:firstleg:terminal-drayage:list"); + assertPermission("detail", "platform:firstleg:terminal-drayage:list"); + assertPermission("history", "platform:firstleg:terminal-drayage:list"); + assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit"); + assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit"); + assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm"); + assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm"); + } + + @Test + void freezesActionsPermissionsAndServerWarningFields() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml")); + assertThat(yaml).contains( + "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", + "platform:firstleg:terminal-drayage:list", + "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", + "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", + "emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays"); + assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + } + + private static void assertPermission(String methodName, String permission) { + var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods()) + .filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java new file mode 100644 index 000000000..3111f706b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageSqlContractTest { + @Test + void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java")); + assertThat(sql).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage (", + "UNIQUE KEY uk_terminal_drayage_container(container_booking_id)", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (", + "UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)", + "服务端按卸柜完成时间加72小时计算的预计还柜时间"); + assertThat(sql).doesNotContain("ON DELETE CASCADE"); + assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE", + "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}") + .doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact"); + } + + @Test + void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception { + String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(sql).contains( + "ALTER TABLE fl_terminal_drayage", + "ALTER TABLE fl_terminal_drayage_history", + "ALTER TABLE fl_terminal_drayage_operation", + "MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java new file mode 100644 index 000000000..89bdb28b8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java @@ -0,0 +1,105 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.dto.ContainerDtos.WorkbenchScope; +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.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning; +import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ContainerWorkbenchServiceTest { + private ContainerRepository repository; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService terminalDrayages; + private ContainerWorkbenchService service; + + @BeforeEach + void setUp() { + repository = mock(ContainerRepository.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + terminalDrayages = mock(TerminalDrayageService.class); + service = new ContainerWorkbenchService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "carrierSchedules", carrierSchedules); + ReflectionTestUtils.setField(service, "terminalDrayages", terminalDrayages); + } + + @Test + void domesticPageKeepsServerPaginationAndUsesOneActualFactBatch() { + ContainerBookingEntity container = container(); + container.setPlanStatus("COMPLETED"); + container.setCustomsDeclarationDocumentStatus("COMPLETED"); + when(repository.page(eq("DOMESTIC"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of()); + + var result = service.domesticPage(query(WorkbenchScope.ACTIVE), Set.of( + "platform:firstleg:customs:manage", "platform:firstleg:outbound-dispatch:confirm")); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.records().get(0).workbenchStage()).isEqualTo("WAIT_OUTBOUND"); + assertThat(result.records().get(0).actualFacts()).isNull(); + assertThat(result.records().get(0).allowedActions()) + .containsExactly("MANAGE_CUSTOMS", "CONFIRM_OUTBOUND"); + verify(carrierSchedules, times(1)).containerActualFacts(List.of(11L)); + verifyNoInteractions(terminalDrayages); + } + + @Test + void overseasPageConsumesTerminalAggregateOnceAndFiltersActionsByPermission() { + ContainerBookingEntity container = container(); + container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0)); + ContainerActualFacts facts = facts("PICKED_UP"); + View raw = new View(11L, "MSCU1234567", 21L, "UNLOADED", 3, 91L, "车队", + LocalDateTime.of(2026, 9, 4, 8, 0), LocalDateTime.of(2026, 9, 4, 9, 0), + LocalDateTime.of(2026, 9, 8, 8, 0), null, container.getUnloadingCompletedAt(), + new Warning("PENDING", LocalDateTime.of(2026, 9, 8, 8, 0), 0, 0), + List.of("RECORD_EMPTY_RETURN"), facts, LocalDateTime.of(2026, 9, 5, 9, 0)); + Set permissions = Set.of("platform:firstleg:terminal-drayage:empty-return:confirm"); + when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1)); + when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(raw)); + when(terminalDrayages.authorize(List.of(raw), permissions)).thenReturn(List.of(raw)); + + var result = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions); + + assertThat(result.records().get(0).workbenchStage()).isEqualTo("UNLOADED"); + assertThat(result.records().get(0).actualFacts()).isSameAs(facts); + assertThat(result.records().get(0).allowedActions()).containsExactly("RECORD_EMPTY_RETURN"); + verify(terminalDrayages, times(1)).views(List.of(11L)); + verifyNoInteractions(carrierSchedules); + } + + private static Query query(WorkbenchScope scope) { + return new Query(null, null, null, null, null, null, null, null, null, null, null, + null, null, null, scope, 1, 20); + } + + private static ContainerBookingEntity container() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(11L); value.setContainerNo("MSCU1234567"); value.setPlanId(31L); + value.setPlanNo("LP-260908-0001"); value.setPlanStatus("COMPLETED"); + value.setDestinationClearanceDocumentStatus("MISSING"); + value.setDeliveryOrderDocumentStatus("MISSING"); value.setOutboundDispatchStatus("NOT_CREATED"); + return value; + } + + private static ContainerActualFacts facts(String node) { + LocalDateTime now = LocalDateTime.of(2026, 9, 4, 9, 0); + return new ContainerActualFacts(11L, 41L, 3, "READY", node, now, + now.minusDays(5), now.minusDays(4), now.minusDays(1), now.minusHours(2), now, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java new file mode 100644 index 000000000..399d45cb0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -0,0 +1,307 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TerminalDrayageServiceTest { + private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0); + private TerminalDrayageMapper mapper; + private WarehouseSupplierSnapshotService suppliers; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService service; + private Deque facts; + + @BeforeEach + void setUp() { + mapper = mock(TerminalDrayageMapper.class); + suppliers = mock(WarehouseSupplierSnapshotService.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + facts = new ArrayDeque<>(); + when(carrierSchedules.containerActualFacts(any())).thenAnswer(ignored -> { + if (facts.isEmpty()) return List.of(); + return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst()); + }); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules()); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void rejectsFleetSelectionBeforeActualDestinationArrival() { + when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("货柜实际到达目的港后才能安排码头车队"); + verify(suppliers, never()).trucking(anyLong(), any()); + } + + @Test + void selectsActiveTruckingSupplierAndPersistsServerSnapshot() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(mapper.operation(11L, "fleet-1")).thenReturn(null); + when(suppliers.trucking(91L, "用户提示名")).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + TerminalDrayageEntity value = invocation.getArgument(0); + stored.set(value); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + + var result = service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜")); + + assertThat(result.version()).isEqualTo(1); + assertThat(result.fleetSupplierId()).isEqualTo(91L); + assertThat(result.fleetSupplierName()).isEqualTo("服务端车队"); + assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP"); + verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), + anyInt(), anyInt(), any(), anyLong(), anyString()); + verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + } + + @Test + void recordsPickupOnceAndAdvancesCanonicalNode() { + Context before = context("PICKUP_READY", null); + Context after = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "pickup-1")).thenReturn(null); + TerminalDrayageEntity row = row(2); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(3); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordPickup(11L, 7L, "admin", "pickup-1", + new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜")); + + assertThat(result.status()).isEqualTo("PICKED_UP"); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); + verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void requiresOverseasUnloadBeforeEmptyReturn() { + Context context = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "return-1")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) + .isInstanceOf(BizException.class) + .hasMessage("海外卸柜收货完成后才能确认还柜"); + verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void computesSeventyTwoHourWarningAndClearsItAfterReturn() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var overdue = service.detail(11L); + assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0)); + assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE"); + assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26); + assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1); + + row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0)); + assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); + } + + @Test + void doesNotOfferEmptyReturnWithoutExecutableDrayageAggregate() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(null); + + var result = service.detail(11L); + + assertThat(result.status()).isEqualTo("UNLOADED"); + assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); + } + + @Test + void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() { + when(mapper.contexts(List.of(11L))).thenReturn(List.of( + new Context(11L, "MSCU1234567", 21L, "ACTIVE", null))); + when(mapper.findAll(List.of(11L))).thenReturn(List.of()); + + assertThat(service.views(List.of(11L))).isEmpty(); + verify(carrierSchedules).containerActualFacts(List.of(11L)); + } + + @Test + void allowedActionsAreReducedByFrozenPermissionMatrix() { + Context context = context("PICKUP_READY", null); + TerminalDrayageEntity row = row(3); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var raw = service.detail(11L); + var authorized = service.authorize(raw, + Set.of("platform:firstleg:terminal-drayage:pickup:confirm")); + + assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP"); + assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP"); + } + + @Test + void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("Idempotency-Key已被不同的码头拖车操作占用"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(suppliers.trucking(91L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null); + + var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + + assertThat(replay).isEqualTo(first); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + verify(suppliers, times(1)).trucking(91L, null); + } + + @Test + void recordsEmptyReturnAndResolvesWarning() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context before = context("PICKED_UP", unloadedAt); + Context after = context("EMPTY_RETURNED", unloadedAt); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "return-2")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(4); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜")); + + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); + } + + private Context context(String node, LocalDateTime unloadedAt) { + int sequenceVersion = switch (node) { + case "PICKED_UP" -> 5; + case "PICKUP_READY" -> 4; + case "ARRIVED_DESTINATION_PORT" -> 3; + default -> 2; + }; + facts.addLast(new ContainerActualFacts(11L, 31L, sequenceVersion, "READY", node, ARRIVED_AT, + ARRIVED_AT.minusDays(5), ARRIVED_AT.minusDays(4), + switch (node) { + case "IN_TRANSIT" -> null; + default -> ARRIVED_AT; + }, + "PICKUP_READY".equals(node) || "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) + ? ARRIVED_AT.plusHours(2) : null, + "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null, + "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null)); + return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt); + } + + private TerminalDrayageEntity row(int version) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version); + value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队"); + return value; + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 4ffb1a909..89298259e 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -7,6 +7,7 @@ "booking-customs-trucking->outbound", "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", + "booking-customs-trucking->shipping-nodes", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", "finance-integration->inbound", diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 141cf31ef..fbd66bd91 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -77,7 +77,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** - id: finance-service uri: lb://finance-service predicates: diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java index 4584d620f..262b95956 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -15,6 +15,7 @@ class FirstlegContainerRouteContractTest { assertThat(yaml) .contains("/api/platform/v1/containers/**") + .contains("/api/platform/v1/terminal-drayages/**") .contains("/api/platform/v1/loading-plans/**"); } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 46813a762..43f79000e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -170,6 +170,8 @@ public class MemberService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT", + "MEMBER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 032898b2c..8f94a889d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -147,6 +147,8 @@ public class PlatformInternalUserService { repository.saveUser(user); repository.save(membership); securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index a277858ae..fa73ece19 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -203,6 +203,8 @@ public class PlatformUserManagementService { members.save(membership); securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), appCode(membership.getTenantId()), request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); diff --git a/iam/src/main/resources/db/container-workspace-permissions.sql b/iam/src/main/resources/db/container-workspace-permissions.sql new file mode 100644 index 000000000..57b15c1fd --- /dev/null +++ b/iam/src/main/resources/db/container-workspace-permissions.sql @@ -0,0 +1,139 @@ +-- 国内送柜报关与国外清关提柜双工作台权限;国内沿用现有货柜权限,国外直接收敛到独立权限族。 +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; diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql index 10a22894b..a39efd4a3 100644 --- a/iam/src/main/resources/db/phase5-delivery-permissions.sql +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -18,6 +18,7 @@ BEGIN 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 = 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 = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list') OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') @@ -36,6 +37,7 @@ BEGIN 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: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:customer-visit:list' AND id <> 5107080) OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) @@ -60,6 +62,7 @@ VALUES (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), (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), (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), @@ -95,6 +98,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', @@ -119,6 +123,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:exception-order:list'), ('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:status'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'), @@ -147,6 +152,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', @@ -194,12 +200,13 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 16 THEN + 'merchant:warehouse:delivery:create')) <> 17 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -217,10 +224,11 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', '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='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; IF EXISTS (SELECT 1 FROM iam_role role @@ -241,17 +249,18 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', '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='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; END IF; SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count 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 SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; @@ -270,6 +279,7 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index efd7ce318..a7783983a 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -124,7 +124,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/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`,不以 `container:list/edit` 兼容放行国外操作。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 18d1e3192..84116ee8f 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -31,9 +31,18 @@ class IamOpenApiContractTest { .contains("platform:overseas-warehouse:delivery:status") .contains("platform:overseas-warehouse:exception-order:list") .contains("platform:overseas-warehouse:exception-order:create") + .contains("platform:overseas-warehouse:exception-order:process") .contains("platform:overseas-warehouse:customer-visit:list") .contains("platform:overseas-warehouse:customer-visit:edit") .contains("platform:overseas-warehouse:outbound:cancel") + .contains("platform:firstleg:container:list/create/edit") + .contains("platform:firstleg:terminal-drayage:list") + .contains("platform:firstleg:terminal-drayage:document:edit") + .contains("platform:firstleg:terminal-drayage:fleet:edit") + .contains("platform:firstleg:terminal-drayage:pickup-appointment:edit") + .contains("platform:firstleg:terminal-drayage:pickup:confirm") + .contains("platform:firstleg:terminal-drayage:empty-return:confirm") + .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") .contains("merchant:warehouse:delivery:create") .contains("merchant:cross-border:procurement") diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java new file mode 100644 index 000000000..40f5c5878 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -0,0 +1,131 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerWorkspacePermissionSqlContractTest { + private static final Pattern CODE = Pattern.compile("platform:firstleg:(?:container|terminal-drayage):[a-z0-9:-]+"); + private static final Set REQUIRED = Set.of( + "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"); + + @Test + void definesSeparateDomesticAndOverseasMenusWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(codes(catalog(sql))).containsExactlyInAnyOrderElementsOf(REQUIRED); + assertThat(sql).contains( + "'platform:firstleg:container:list',\n '国内送柜报关','/container-loading/domestic','GET'", + "'platform:firstleg:terminal-drayage:list',\n '国外清关提柜','/container-loading/overseas','GET'", + "permission.permission_code='platform:firstleg:container:view'", + "SET status='INACTIVE', deleted=1"); + assertThat(codes(catalog(sql))).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).doesNotContain( + "platform:firstleg:domestic-container:", + "platform:firstleg:overseas-container:", + "platform:firstleg:terminal-drayage:edit"); + } + + @Test + void keepsDomesticWritesAndDefinesFineGrainedOverseasWrites() throws Exception { + assertThat(sql()).contains( + "'platform:firstleg:container:create',\n '建立国内货柜',NULL,'POST'", + "'platform:firstleg:container:edit',\n '维护国内货柜资料',NULL,'PUT'", + "'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'", + "'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'", + "'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'", + "'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'", + "'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'"); + } + + @Test + void grantsTheCompleteMatrixOnlyToTheExistingDefaultPlatformRoles() throws Exception { + String sql = sql(); + int grantStart = sql.indexOf("-- 平台超级管理员和现有订单运营保持货柜全链路能力"); + int assertionStart = sql.indexOf("IF (SELECT COUNT(*) FROM iam_permission", grantStart); + String grant = sql.substring(grantStart, assertionStart); + + assertThat(grant).contains( + "role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')", + "platform:firstleg:container:list", + "platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + assertThat(grant).doesNotContain( + "PLATFORM_CUSTOM_ADMIN", + "WAREHOUSE_OPERATOR", + "WAREHOUSE_TALLY", + "OVERSEAS_WAREHOUSE_PDA_OPERATOR", + "CN_FIRSTLEG_PDA_OPERATOR", + "MERCHANT_OWNER"); + assertThat(sql).contains( + "permission_code IN (", + ")) <> 9 THEN", + ")) <> 18 THEN"); + } + + @Test + void failsClosedForFixedIdentityConflictsAndRemainsReplaySafe() throws Exception { + assertThat(sql()).contains( + "migrate_container_workspace_permissions", + "id = 5102090 AND permission_code <> 'platform:firstleg:container:list'", + "id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm'", + "permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200", + "SIGNAL SQLSTATE '45000'", + "ON DUPLICATE KEY UPDATE", + "INSERT IGNORE INTO iam_role_permission"); + } + + @Test + void unifiedIncrementContainsTheCompleteModuleSql() throws Exception { + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) { + unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + String startMarker = "-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)"; + String endMarker = "-- 货柜双工作台权限结束,恢复头程数据库上下文"; + int start = unified.indexOf(startMarker); + int bodyStart = unified.indexOf('\n', start) + 1; + int end = unified.indexOf(endMarker, bodyStart); + + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(bodyStart).isGreaterThan(start); + assertThat(end).isGreaterThan(bodyStart); + assertThat(unified.substring(bodyStart, end).trim()).isEqualTo(sql().trim()); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/container-workspace-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CODE.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String catalog(String sql) { + int start = sql.indexOf("INSERT INTO iam_permission"); + int end = sql.indexOf("ON DUPLICATE KEY UPDATE", start); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index 894c6e748..f76aa3eaf 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -25,6 +25,7 @@ class Phase5DeliveryPermissionSqlContractTest { "platform:overseas-warehouse:delivery:list", "platform:overseas-warehouse:exception-order:list", "platform:overseas-warehouse:exception-order:create", + "platform:overseas-warehouse:exception-order:process", "platform:overseas-warehouse:delivery:return", "platform:overseas-warehouse:delivery:status", "platform:overseas-warehouse:customer-visit:list", @@ -54,6 +55,7 @@ class Phase5DeliveryPermissionSqlContractTest { "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", "'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'", "'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'", + "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", @@ -75,6 +77,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound: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:status')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')", @@ -89,6 +92,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')", @@ -106,7 +110,7 @@ class Phase5DeliveryPermissionSqlContractTest { "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", - "IF v_warehouse_operator_permission_count <> 14", + "IF v_warehouse_operator_permission_count <> 15", "OR v_warehouse_tally_permission_count <> 5 THEN", "IF EXISTS (SELECT 1 FROM iam_role role", "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", @@ -160,7 +164,7 @@ class Phase5DeliveryPermissionSqlContractTest { "海外排车、出库与派送权限", "测试和部署只执行固定入口,不再单独执行模块脚本", "排车列表/编辑/取消/转派送", - "异常订单列表/创建和客户回访列表/编辑十四项权限", + "异常订单列表/创建/处理和客户回访列表/编辑十五项权限", "merchant:warehouse:delivery:list", "merchant:warehouse:delivery:create", "PDA 岗位不得获得上述后台权限"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java index 0e7944937..c3a8217d6 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java @@ -37,12 +37,12 @@ class Phase5DeliveryPermissionSqlExecutionTest { assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(14); + assertThat(grantCount(connection, 11L)).isEqualTo(15); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(14); - assertThat(totalGrantCount(connection)).isEqualTo(14); + assertThat(grantCount(connection, 11L)).isEqualTo(15); + assertThat(totalGrantCount(connection)).isEqualTo(15); } } @@ -51,14 +51,14 @@ class Phase5DeliveryPermissionSqlExecutionTest { try (Connection connection = database()) { insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 22L, "Platform_Custom_Admin"); - grantFirstPermissions(connection, 21L, 14); - grantFirstPermissions(connection, 22L, 13); + grantFirstPermissions(connection, 21L, 15); + grantFirstPermissions(connection, 22L, 14); assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 21L)).isEqualTo(14); - assertThat(grantCount(connection, 22L)).isEqualTo(14); + assertThat(grantCount(connection, 21L)).isEqualTo(15); + assertThat(grantCount(connection, 22L)).isEqualTo(15); assertThat(customAdministratorViolations(connection)).isZero(); } } @@ -68,16 +68,16 @@ class Phase5DeliveryPermissionSqlExecutionTest { try (Connection connection = database()) { insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 32L, "platform_custom_admin"); - grantFirstPermissions(connection, 31L, 14); - grantFirstPermissions(connection, 32L, 14); + grantFirstPermissions(connection, 31L, 15); + grantFirstPermissions(connection, 32L, 15); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 31L)).isEqualTo(14); - assertThat(grantCount(connection, 32L)).isEqualTo(14); - assertThat(totalGrantCount(connection)).isEqualTo(28); + assertThat(grantCount(connection, 31L)).isEqualTo(15); + assertThat(grantCount(connection, 32L)).isEqualTo(15); + assertThat(totalGrantCount(connection)).isEqualTo(30); } } @@ -182,7 +182,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { AND (SELECT COUNT(*) FROM iam_role_permission grant_row JOIN iam_permission permission ON permission.id=grant_row.permission_id WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id - AND permission.permission_code IN (%s)) <> 14 + AND permission.permission_code IN (%s)) <> 15 """.formatted(placeholders); try (PreparedStatement query = connection.prepareStatement(sql)) { int index = 1; @@ -209,7 +209,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { LinkedHashSet codes = new LinkedHashSet<>(); String grant = administratorGrantSql(); PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); - assertThat(codes).hasSize(14); + assertThat(codes).hasSize(15); return codes; } diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 29f97b03f..b190038a9 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -106,6 +106,8 @@ class MemberServiceTest { assertEquals("DISABLED", result.status()); verify(repository).save(member); verify(repository).saveUser(user); + verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED"); + verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED"); } @Test void shouldHandleCase05() { diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index 6e8da47c5..ede6e98a7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -142,6 +142,19 @@ class PlatformInternalUserServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + + InternalUserResponse result = service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 99L); + + assertEquals("DISABLED", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + } + @Test void shouldHandleCase05() { when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 606955222..67b7cfc39 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -224,6 +224,20 @@ class PlatformUserManagementServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE"))); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L)); + + UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L); + + assertEquals("ACTIVE", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED"); + } + @Test void builtInPlatformAdministratorCannotBeDisabled() { IamRole platformAdmin = new IamRole(); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 44bd11641..f264f21e3 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -3,8 +3,8 @@ ## 职责与边界 - Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 -- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。 +- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 ## 快速索引 @@ -13,6 +13,7 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | @@ -41,8 +42,8 @@ | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 | -| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 | +| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 | | 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | @@ -102,6 +103,7 @@ - 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 - 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 - 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index a559f0cfc..0984202f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -7,12 +7,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; @@ -45,6 +47,7 @@ public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; + @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @@ -102,6 +105,20 @@ public class LastlegOrderController { return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); } + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "修改整柜海外接柜仓", + description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。") + public ApiResponse changeReceivingWarehouse( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) { + platformScope(user, null); + return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId, + idempotencyKey, request, operatorName(user))); + } + @GetMapping("/api/platform/v1/last-leg/orders/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) @@ -123,17 +140,43 @@ public class LastlegOrderController { return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); } - @PutMapping("/api/platform/v1/last-leg/orders/{id}/detained") + @PutMapping("/api/platform/v1/last-leg/orders/{id}/hold") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") - @Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) - public ApiResponse setDetained( + @Operation(summary = "暂扣或解除单票尾程订单", + description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse hold( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, - @Valid @RequestBody LastlegOrderRequests.SetDetained request) { - orderService.setDetained(platformScope(user, customerId), id, request, - String.valueOf(user.userId()), operatorName(user)); - return ApiResponse.success(null); + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) { + return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey, + request, operatorId(user), operatorName(user))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "保存单票人工节点覆盖", + description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse saveManualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) { + return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id, + idempotencyKey, request, operatorId(user), operatorName(user))); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询单票人工节点覆盖", + description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse manualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id)); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") @@ -213,4 +256,8 @@ public class LastlegOrderController { private String operatorName(AuthenticatedUser user) { return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? operatorName(user) : String.valueOf(user.userId()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java index f22fbe916..1707f2fef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -68,8 +68,8 @@ public class CustomerVisitController { @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") public ApiResponse completeDelivery( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, - @PathVariable String deliveryNo) { - return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo)); + @PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) { + return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request)); } @PutMapping("/delivery-orders/{deliveryNo}/status") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java index 5c7ffcb36..2153d08fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java @@ -31,5 +31,6 @@ public final class CustomerVisitRequests { @NotNull @Min(0) Long sizeBytes) { } public record DeliveryStatus(@NotBlank @Size(max = 32) String status, + @NotNull @Min(0) Long expectedVersion, @Size(max = 500) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java index 416d19088..bc1d68090 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -12,12 +12,11 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -51,7 +50,6 @@ public class CustomerVisitService { @Resource private DeliveryOperationMapper deliveryOperations; @Resource private LastlegOrderMapper lastMileOrders; @Resource private LastlegInboundOrderMapper inboundOrders; - @Resource private LastlegOrderStatusHistoryMapper histories; @Resource private CustomerVisitFileGateway files; @Resource private ObjectMapper objectMapper; @@ -79,31 +77,31 @@ public class CustomerVisitService { } @Transactional - public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) { - DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); - if (delivery == null) throw notFound("派送单不存在"); - if (Set.of("RETURN_PENDING", "REJECTED", "CANCELLED").contains(delivery.getStatus())) { - throw conflict("当前派送状态不能标记为派送完成"); + public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo, + CustomerVisitRequests.DeliveryStatus request) { + if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) { + throw badRequest("派送完成接口只接受 DELIVERED 状态"); } + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); boolean replayed = "DELIVERED".equals(delivery.getStatus()); + if (!replayed) { + requireVersion(delivery, request.expectedVersion()); + requireDeliveryTransition(delivery.getStatus(), "DELIVERED"); + } LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now(); if (!replayed) { delivery.setStatus("DELIVERED"); delivery.setSignedAt(now); delivery.setUpdatedAt(now); + delivery.setVersion(version(delivery) + 1); deliveryOrders.updateById(delivery); for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) { - String from = order.getCurrentStatus(); - order.setCurrentStatus("DELIVERED"); order.setSubOrderStatus("DELIVERY:DELIVERED"); order.setUpdatedAt(now); lastMileOrders.updateById(order); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); - history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); - history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo); - history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history); } recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now); } @@ -116,23 +114,25 @@ public class CustomerVisitService { String deliveryNo, CustomerVisitRequests.DeliveryStatus request) { String target = trim(request.status()).toUpperCase(Locale.ROOT); - if (!Set.of("WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED").contains(target)) { + if (!DeliveryStatusPolicy.isEditableTarget(target)) { throw badRequest("不支持的派送状态"); } - if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo); - DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request); + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); - if ("RETURN_PENDING".equals(delivery.getStatus())) throw conflict("退货处理中的派送单不能直接修改状态"); if (target.equals(delivery.getStatus())) { CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, delivery.getStatus(), existing == null ? null : existing.getStatus(), true); } + requireVersion(delivery, request.expectedVersion()); + requireDeliveryTransition(delivery.getStatus(), target); String from = delivery.getStatus(); LocalDateTime now = LocalDateTime.now(); delivery.setStatus(target); if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now); delivery.setUpdatedAt(now); + delivery.setVersion(version(delivery) + 1); deliveryOrders.updateById(delivery); String result = "派送状态由 " + from + " 修改为 " + target; if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark()); @@ -309,6 +309,15 @@ public class CustomerVisitService { } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static void requireDeliveryTransition(String from, String target) { + if (!DeliveryStatusPolicy.canTransition(from, target)) { + throw conflict("派送状态不允许由 " + from + " 变更为 " + target); + } + } + private static void requireVersion(DeliveryOrder delivery, long expectedVersion) { + if (version(delivery) != expectedVersion) throw conflict("派送单版本已变化,请刷新后重试"); + } + private static long version(DeliveryOrder delivery) { return Objects.requireNonNullElse(delivery.getVersion(), 0L); } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 838bf0905..77deb14da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -42,6 +42,15 @@ public class DeliveryTransferController { return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request)); } + @GetMapping("/outbound-orders/one-click-dropship/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "List server-authoritative one-click dropship outbound candidates") + public ApiResponse> oneClickCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @RequestParam(required = false) String warehouseId) { + return ApiResponse.success(service.oneClickCandidates(requiredCustomer(user, customerId), warehouseId)); + } + @GetMapping("/outbound-orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse> outboundPage( @@ -55,9 +64,9 @@ public class DeliveryTransferController { @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse outboundDetail( @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { + @PathVariable String outboundNo) { requirePlatform(user); - return ApiResponse.success(service.outboundDetail(outboundNo, query)); + return ApiResponse.success(service.outboundDetail(outboundNo)); } @PostMapping("/outbound-orders/{outboundNo}/cancel") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java index ad1d097db..67bdf0e02 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java @@ -34,6 +34,13 @@ public class OutboundPdaController { return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status)); } + @GetMapping("/resolve/{businessNo}") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String businessNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.resolvePdaTask(String.valueOf(identity.overseasWarehouseId()), businessNo)); + } + @GetMapping("/{taskNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String taskNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index ef68fcb59..aa32189d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -24,7 +24,7 @@ public final class DeliveryResponses { int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, - String remark, LocalDateTime createdAt) {} + String remark, LocalDateTime createdAt, long version, List allowedActions) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, @@ -40,10 +40,16 @@ public final class DeliveryResponses { public record DeliveryDetail(DeliveryRow order, List skus, List fees, List operations) {} - public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String skuCode, String inboundCode, - Integer packageCount, Integer pickedPackageCount, String status) {} + /** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */ + public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode, + String destinationWarehouseCode, String destinationWarehouseName, + String skuCode, int packageCount, int pickedPackageCount, String status) {} public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, List pallets) {} + public record OneClickCandidate(Long inventoryPositionId, String overseasWarehouseId, + String overseasWarehouseName, String palletNo, String inboundCode, + String skuCode, String productName, long availablePackages) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} + public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, String skuCode, String inboundCode, long availablePackages, long pickedPackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 93dbf8173..bd839ea81 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -47,6 +47,7 @@ public class DeliveryOrder { private String remark; private String idempotencyKey; private String createdBy; + private Long version; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java index 7d2641cf5..de1372e55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Select; public interface DeliveryOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java index 624436ef4..063871975 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java @@ -23,6 +23,12 @@ public interface OutboundPdaTaskMapper extends BaseMapper { "AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId); + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.customer_id=#{customerId} AND t.outbound_order_id=#{outboundOrderId} " + + "AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") + OutboundPdaTask findByOutboundAndWarehouse(@Param("customerId") String customerId, + @Param("outboundOrderId") Long outboundOrderId, @Param("warehouseId") String warehouseId); + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + "AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " + "AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java new file mode 100644 index 000000000..2ae76d3bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.delivery.model; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class DeliveryStatusPolicy { + private static final Set EDITABLE_TARGETS = Set.of( + "WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"); + private static final Map> TRANSITIONS = Map.of( + "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"), + "DELIVERING", Set.of("DELIVERED", "REJECTED"), + "DELIVERED", Set.of(), + "REJECTED", Set.of(), + "RETURN_PENDING", Set.of(), + "RETURNED", Set.of(), + "CANCELLED", Set.of()); + + private DeliveryStatusPolicy() { } + + public static boolean isEditableTarget(String status) { + return EDITABLE_TARGETS.contains(status); + } + + public static boolean canTransition(String from, String target) { + return TRANSITIONS.getOrDefault(from, Set.of()).contains(target); + } + + public static List allowedActions(String status) { + return switch (status == null ? "" : status) { + case "WAIT_DELIVERY" -> List.of( + "START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); + default -> List.of(); + }; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index edbf81f74..fa85ec469 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; @@ -40,6 +41,7 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; @@ -116,6 +118,7 @@ public class DeliveryTransferService { if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { throw conflict("Dispatch cannot be transferred to delivery in its current status"); } + dispatchItems.bindReceivedPallets(customerId, dispatch.getId()); List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); if (items.isEmpty()) throw conflict("Dispatch has no pallets"); if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { @@ -130,7 +133,7 @@ public class DeliveryTransferService { outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); - outbound.setLxCodes(items.stream().map(DispatchItem::getInboundCode).filter(StringUtils::hasText).map(code -> tailOrders.selectOne(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, code))).filter(Objects::nonNull).map(LastlegOrder::getLxCode).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","))); + outbound.setLxCodes(resolveLxCodes(customerId, items)); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); @@ -178,7 +181,7 @@ public class DeliveryTransferService { delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); LastlegOrder firstOrder = firstTailOrder(customerId, items); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); @@ -250,30 +253,35 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + public List oneClickCandidates(String customerId, String warehouseId) { + return dropshipProfiles.listOutboundCandidates(required(customerId, "Customer is required"), + StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream() + .map(this::oneClickCandidate).toList(); + } + private String validateOneClickItems(String customerId, List items) { String warehouseId = null; Map requestedPackages = new LinkedHashMap<>(); - Map profiles = new HashMap<>(); + Map candidates = new HashMap<>(); for (DeliveryRequests.OneClickItem input : items) { - InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); - if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); - DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId()); - if (profile == null) throw conflict("Selected pallet is not one-click dropship inventory"); - if (warehouseId == null) warehouseId = position.getOverseasWarehouseId(); - else if (!Objects.equals(warehouseId, position.getOverseasWarehouseId())) { - throw conflict("One outbound order must use inventory from the same overseas warehouse"); + String sku = input.sku().trim(); + DropshipOutboundCandidate candidate = dropshipProfiles.lockOutboundCandidate(customerId, + input.inventoryPositionId(), sku); + if (candidate == null) { + throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用"); } - if (value(position.getAvailablePackages()) < input.packages()) { - throw conflict("Insufficient available inventory for pallet " + position.getPalletNo()); + if (warehouseId == null) warehouseId = candidate.getOverseasWarehouseId(); + else if (!Objects.equals(warehouseId, candidate.getOverseasWarehouseId())) { + throw conflict("一张出库单只能选择同一海外仓的库存"); } - String key = profile.getId() + "\u0000" + input.sku().trim(); + String key = candidate.getProfileId() + "\u0000" + sku; requestedPackages.merge(key, input.packages(), Long::sum); - profiles.put(key, profile); + candidates.put(key, candidate); if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) { if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) { throw conflict("Specified carton marks must be unique and equal the package count"); } - List selected = dropshipCartons.listProfile(customerId, profile.getId()).stream() + List selected = dropshipCartons.listProfile(customerId, candidate.getProfileId()).stream() .filter(carton -> "AVAILABLE".equals(carton.getStatus()) && input.sku().trim().equals(carton.getSkuCode()) && input.cartonMarks().contains(carton.getCartonMark())).toList(); if (selected.size() != input.cartonMarks().size()) throw conflict("Specified carton marks are unavailable"); @@ -281,10 +289,14 @@ public class DeliveryTransferService { } for (Map.Entry requested : requestedPackages.entrySet()) { String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1); + DropshipOutboundCandidate candidate = candidates.get(requested.getKey()); + if (requested.getValue() > value(candidate.getAvailablePackages())) { + throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); + } List cartons = dropshipCartons.lockAvailable(customerId, - profiles.get(requested.getKey()).getId(), sku, requested.getValue()); + candidate.getProfileId(), sku, requested.getValue()); if (cartons.size() != requested.getValue()) { - throw conflict("Insufficient available one-click dropship cartons for SKU " + sku); + throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); } } return warehouseId; @@ -399,7 +411,7 @@ public class DeliveryTransferService { throw conflict("Outbound order cannot be cancelled while delivery is in progress"); LocalDateTime now = LocalDateTime.now(); DeliveryOrder delivery = existingDelivery; - if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } + if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); @@ -432,6 +444,36 @@ public class DeliveryTransferService { return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } + public DeliveryResponses.PdaTaskResolution resolvePdaTask(String warehouseId, String businessNo) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + String code = required(businessNo, "Outbound task, outbound or dispatch number is required"); + + OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(code, boundWarehouseId); + if (task != null) { + OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId); + return outboundResolution(outbound, task); + } + + OutboundOrder outbound = outboundOrders.findGlobalByNo(code); + if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { + if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) { + DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo()); + if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { + return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(), + dispatch.getDispatchNo()); + } + } + task = pdaTasks.findByOutboundAndWarehouse(outbound.getCustomerId(), outbound.getId(), boundWarehouseId); + if (task != null) return outboundResolution(outbound, task); + } + + DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code); + if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { + return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo()); + } + throw notFound("Outbound or dispatch task not found in the bound warehouse"); + } + private List pdaPickPositions(String customerId, String warehouseId, OutboundPalletItem item) { if (item.getWarehousePalletId() == null) return List.of(); @@ -471,6 +513,10 @@ public class DeliveryTransferService { } private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } + private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) { + return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(), + outbound.getDispatchNo()); + } private OutboundOrder requireOutbound(OutboundPdaTask task) { OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId()); if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) { @@ -499,14 +545,13 @@ public class DeliveryTransferService { return page(page, page.getRecords().stream().map(this::outboundRow).toList()); } - public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo, - DeliveryRequests.PalletQuery query) { + public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo) { OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); String customerId = outbound.getCustomerId(); DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); List pallets = outboundPallets.findByOutbound(customerId, outbound.getId()).stream() - .map(item -> new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getSkuCode(), item.getInboundCode(), item.getPackageCount(), item.getPickedPackageCount(), item.getStatus())).toList(); + .map(this::outboundPalletRow).toList(); return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), pallets); } @@ -582,12 +627,33 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); } + private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { + return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), + item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), + item.getSkuCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus()); + } + private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) { + return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), + candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(), + candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), + value(candidate.getAvailablePackages())); + } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private String resolveLxCodes(String customerId, List items) { + List inboundCodes = items.stream().map(DispatchItem::getInboundCode) + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); + if (inboundCodes.isEmpty()) return ""; + return tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getInboundCode, inboundCodes)) + .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); + } private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static String required(String value, String message) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index a62da4a4d..57239e4ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -24,6 +24,7 @@ public class DispatchPalletLock { private Long planPalletId; private Long warehousePalletId; private Long dispatchOrderId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long draftContainerId; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String draftToken; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e5e4f5875..227e8ad57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -36,7 +36,13 @@ public interface DispatchCandidateMapper { "(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," + - "MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + + "AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + @@ -60,6 +66,10 @@ public interface DispatchCandidateMapper { " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -94,7 +104,13 @@ public interface DispatchCandidateMapper { "(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," + - "MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + + "AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + @@ -112,6 +128,10 @@ public interface DispatchCandidateMapper { "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java index 38ed3c93d..5d849f04b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -7,8 +7,16 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE draft_token=#{draftToken} " + + "AND draft_container_id=#{draftContainerId} AND status='ACTIVE' " + + "AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY locked_at,id") + List findActiveByDraftContainer(@Param("draftToken") String draftToken, + @Param("draftContainerId") Long draftContainerId); + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") DispatchPalletLock lockActive(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index e326e2166..05a5e000a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -22,6 +22,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -53,6 +55,7 @@ public class DispatchService { @Resource private DispatchCandidateMapper candidates; @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private LastlegOrderMapper tailOrders; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -95,7 +98,8 @@ public class DispatchService { private List containers(String token) { return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), - locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, c.getId()).eq(DispatchPalletLock::getStatus, "ACTIVE")).stream().map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + locks.findActiveByDraftContainer(token, c.getId()).stream() + .map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); } @Transactional @@ -220,10 +224,17 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + assertNoDetainedOrders(customerId, selected); + boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .anyMatch(value -> value == null || value.isBlank()); + if (missingWarehouse) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "所选托盘中存在尚未关联海外仓的托盘"); + } Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet()); - if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse"); + .map(String::trim).collect(Collectors.toSet()); + if (warehouseIds.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); } String draftToken = trimToNull(request.draftToken()); for (DispatchPalletCandidate candidate : selected) { @@ -268,6 +279,7 @@ public class DispatchService { if (draftToken != null) { requireDraftOwner(lock, operatorId, draftToken); lock.setDispatchOrderId(dispatch.getId()); + lock.setDraftContainerId(null); lock.setDraftToken(null); lock.setExpiresAt(null); lock.setUpdatedAt(now); @@ -329,9 +341,37 @@ public class DispatchService { pdaTask.setCreatedAt(now); pdaTask.setUpdatedAt(now); pdaTasks.insert(pdaTask); + if (draftToken != null) { + draftContainers.delete(new LambdaQueryWrapper() + .eq(DispatchDraftContainer::getDraftToken, draftToken)); + DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); + if (draft != null) { + draft.setStatus("RELEASED"); + draft.setReleasedAt(now); + draft.setUpdatedAt(now); + drafts.updateById(draft); + } + } return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } + private void assertNoDetainedOrders(String customerId, List selected) { + List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) + .filter(Objects::nonNull).distinct().sorted().toList(); + if (orderIds.isEmpty()) return; + List scoped = tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, orderIds) + .orderByAsc(LastlegOrder::getId) + .last("FOR UPDATE")); + List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) + .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); + if (!detainedNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); + } + } + public PageResult page(String customerId, DispatchRequests.Query query) { if (!StringUtils.hasText(customerId)) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index cc99b10ff..e9e623908 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -2,13 +2,53 @@ package com.baiyue.lastleg.dropship.mapper; import org.apache.ibatis.annotations.Mapper; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface DropshipOrderProfileMapper extends BaseMapper { + String OUTBOUND_CANDIDATE_SELECT = "SELECT pos.id inventory_position_id,p.id profile_id," + + "p.warehouse_pallet_id,pos.overseas_warehouse_id,b.overseas_warehouse_name," + + "p.pallet_no,b.inbound_code,s.sku_code,s.product_name," + + "LEAST(pos.available_packages,s.package_count,(SELECT COUNT(*) FROM ll_dropship_carton c " + + "WHERE c.customer_id=p.customer_id AND c.profile_id=p.id AND c.sku_code=s.sku_code " + + "AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " + + "JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " + + "AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " + + "JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " + + "AND pos.pallet_id=p.warehouse_pallet_id " + + "JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "JOIN ll_dropship_sku_inventory s ON s.customer_id=p.customer_id AND s.order_id=p.order_id " + + "AND s.profile_id=p.id "; + String OUTBOUND_CANDIDATE_WHERE = "WHERE p.customer_id=#{customerId} AND p.status='ACTIVE' " + + "AND pos.status='OCCUPIED' AND pos.available_packages>0 AND s.package_count>0 " + + "AND EXISTS (SELECT 1 FROM ll_dropship_carton c WHERE c.customer_id=p.customer_id " + + "AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE') " + + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock dl WHERE dl.customer_id=p.customer_id " + + "AND dl.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id) AND dl.status='ACTIVE' " + + "AND (dl.expires_at IS NULL OR dl.expires_at>CURRENT_TIMESTAMP(3))) " + + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + "WHERE ta.customer_id=p.customer_id AND ta.inventory_position_id=pos.id AND ta.status='RESERVED') " + + "AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " + + "ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " + + "WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " + + "AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) "; + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1") DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Select("") + List listOutboundCandidates(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); + + @Select(OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE + + "AND pos.id=#{positionId} AND s.sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + DropshipOutboundCandidate lockOutboundCandidate(@Param("customerId") String customerId, + @Param("positionId") Long positionId, + @Param("skuCode") String skuCode); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java new file mode 100644 index 000000000..b5de0b755 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dropship.model; + +import lombok.Data; + +/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */ +@Data +public class DropshipOutboundCandidate { + private Long inventoryPositionId; + private Long profileId; + private Long warehousePalletId; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String palletNo; + private String inboundCode; + private String skuCode; + private String productName; + private Long availablePackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java index c8e1e029f..3c75db35d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -135,8 +135,8 @@ public class DropshipOrderService { throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); } Map> receiptsByPallet = receipts.stream() - .filter(receipt -> receipt.getPalletNo() != null) - .collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo)); + .filter(receipt -> receipt.getSourcePalletNo() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getSourcePalletNo)); if (receiptsByPallet.size() != profilesForOrder.size()) { throw conflict("PDA pallet count does not match dropship source pallet count"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java index 226f4bb40..ebd55205b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java @@ -6,6 +6,12 @@ import java.time.LocalDateTime; public record ContainerPlanProgressSummary( String sourceContainerBookingId, String containerNo, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + int assignmentVersion, + boolean canChangeReceivingWarehouse, + String receivingWarehouseLockReason, long totalOrderCount, long inboundCodeCount, long missingInboundCodeCount, @@ -16,4 +22,14 @@ public record ContainerPlanProgressSummary( boolean allPlansReady, String blockingReason ) { + public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo, + long totalOrderCount, long inboundCodeCount, + long missingInboundCodeCount, long planCount, + long plannedInboundCodeCount, long pendingPlanCount, + LocalDateTime plannedArrivalAt, boolean allPlansReady, + String blockingReason) { + this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null, + totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, + plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java new file mode 100644 index 000000000..d6e4a546f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderCommandDtos { + private LastlegOrderCommandDtos() { } + + public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable, + @NotBlank @Size(max = 500) String reason, + @NotNull @Min(0) Integer expectedVersion) { } + public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { } + + public record ManualNode(@NotBlank @Size(max = 32) String nodeCode, + LocalDateTime nodeAt) { } + public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion, + @NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { } + public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled, + List nodes, int version) { } + public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index 27650b7b0..7be87d591 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -9,6 +9,7 @@ import java.util.List; public record LastlegOrderDetailResponse( LastlegOrderSummary order, + List manualNodes, List cargo, List statusHistory, List fees, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 604d581c9..cab84e090 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -54,6 +54,12 @@ public final class LastlegOrderRequests { } } + public record ChangeReceivingWarehouse( + @NotNull Long overseasWarehouseId, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String remark + ) { } + public record TransferDestination( @NotEmpty @Size(max = 200) List orderIds, @NotBlank @Size(max = 128) String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 74f5b8425..88f84bda9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -15,12 +15,14 @@ public record LastlegOrderSummary( @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) String destinationType, String sourceInboundId, + @Schema(description = "真实来源入仓码;无入仓关系的海外本土业务为空") String inboundCode, String lxCode, String sourceOrderNo, String customerId, String customerName, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, String transferWarehouseId, String transferWarehouseCode, @@ -30,6 +32,11 @@ public record LastlegOrderSummary( String containerStatus, Boolean abnormal, Boolean detained, + Boolean heldChargeable, + String holdReason, + boolean manualOverrideEnabled, + String nodeSource, + int version, Integer palletCount, Integer packageCount, BigDecimal volumeCbm, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 360cf1313..b86b2a53f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -28,7 +28,9 @@ public class LastlegOrder { private String sourceWarehouseId; private String sourceWarehouseName; private String overseasWarehouseId; + private String overseasWarehouseCode; private String overseasWarehouseName; + private Integer receivingWarehouseVersion; private String fbaWarehouseCode; private String containerNo; private String transferWarehouseId; @@ -37,6 +39,10 @@ public class LastlegOrder { private String containerStatus; private Boolean abnormal; private Boolean detained; + private Boolean holdChargeable; + private String holdReason; + private Boolean manualNodeOverride; + private Integer version; private Integer palletCount; private Integer packageCount; private Integer pickedPackageCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java new file mode 100644 index 000000000..9f026da84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_command_record") +public class LastlegOrderCommandRecord { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String action; + private String operatorId; + private String idempotencyKey; + private String requestHash; + private String resultRef; + private String resultJson; + private Integer resultVersion; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java new file mode 100644 index 000000000..2abb9b1c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_hold_history") +public class LastlegOrderHoldHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private Boolean held; + private Boolean chargeable; + private String reason; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java new file mode 100644 index 000000000..323fc685c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_manual_node") +public class LastlegOrderManualNode { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodeCode; + private LocalDateTime nodeAt; + private String source; + private Integer orderVersion; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java new file mode 100644 index 000000000..cc3ec6899 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_manual_node_history") +public class LastlegOrderManualNodeHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodesJson; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java new file mode 100644 index 000000000..22b3c0117 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_warehouse_operation") +public class ReceivingWarehouseOperation { + @TableId(type = IdType.INPUT) private Long id; + private String sourceContainerBookingId; + private String idempotencyKey; + private String requestHash; + private Integer resultVersion; + private String operatorName; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java index d96198178..718e71837 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -10,10 +10,12 @@ import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -45,6 +47,46 @@ public class ExceptionOrderController { return ApiResponse.success(service.create(scope(user), operatorId(user), operatorName(user), request)); } + @GetMapping("/exception-orders/{exceptionNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo) { + return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo)); + } + + @PostMapping(value = "/exception-orders/{exceptionNo}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse uploadEvidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file)); + } + + @PostMapping("/exception-orders/{exceptionNo}/start-processing") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse startProcessing( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.StartProcessing request) { + return ApiResponse.success(service.startProcessing(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @PostMapping("/exception-orders/{exceptionNo}/complete") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Complete request) { + return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @PostMapping("/exception-orders/{exceptionNo}/cancel") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Cancel request) { + return ApiResponse.success(service.cancel(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + @GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") public ApiResponse> returnOptions(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java index 8af47a000..8aaa2d8b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; +import java.util.List; public final class ExceptionOrderRequests { private ExceptionOrderRequests() {} @@ -25,10 +26,36 @@ public final class ExceptionOrderRequests { @Min(1) Integer packageCount, @Size(max = 128) String skuCode) {} - public record MarkReturn(@NotNull Long inboundOrderId, + public record MarkReturn(@NotNull @Min(1) Long inboundOrderId, @NotBlank @Size(max = 1000) String reason, @NotBlank @Size(max = 32) String returnDirection, @Size(max = 128) String returnLogisticsNo, - @Min(1) Integer packageCount, - @Size(max = 128) String skuCode) {} + @NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items, + @NotBlank @Size(max = 255) String idempotencyKey) {} + + public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId, + @NotNull @Min(1) Long inventoryPositionId, + @Min(1) long packageCount) {} + + public record StartProcessing(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String handlingAction) {} + + public record Complete(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 2000) String handlingResult, + @NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds, + @Size(max = 200) List<@NotNull ReturnReceipt> receipts) {} + + public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId, + @NotNull @Min(1) Long inventoryPositionId, + @Min(1) long receivedPackages, + @NotNull @Min(1) Long targetPalletId, + @NotBlank @Size(max = 128) String targetPalletNo, + @NotNull @Min(1) Long targetLocationId, + @NotBlank @Size(max = 128) String targetLocationCode) {} + + public record Cancel(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index 5847820c8..f7fa3ddf8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.exceptionorder.dto; import java.time.LocalDateTime; +import java.util.List; public final class ExceptionOrderResponses { private ExceptionOrderResponses() {} @@ -13,13 +14,23 @@ public final class ExceptionOrderResponses { Long deliveryOrderId, String deliveryNo, String outboundNo, String skuCode, Integer packageCount, String returnDirection, String returnLogisticsNo, String handlingAction, - String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {} + String handlingResult, String handlerName, LocalDateTime exceptionAt, + LocalDateTime completedAt, LocalDateTime updatedAt, long version, + List evidenceFileIds, List allowedActions) {} public record Relation(Long lastMileOrderId, String lastMileOrderNo, Long inboundOrderId, String inboundCode, String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, - String deliveryNo, String outboundNo, Integer availablePackages) {} + String deliveryNo, String outboundNo, Integer availablePackages, + List returnSources) {} - public record Created(Long id, String exceptionNo, String status) {} + public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, + String palletNo, String skuCode, long maxReturnPackages) {} + public record Detail(Row order, List returnSources) {} + public record CommandResult(Long id, String exceptionNo, String status, long version, + Long inventoryAdjustmentId, long restoredPackages, + LocalDateTime completedAt, boolean replayed) {} + public record UploadedEvidence(Long fileId, String fileName, String contentType, long sizeBytes) {} + public record Created(Long id, String exceptionNo, String status, long version, boolean replayed) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java index b60908bb5..653b11372 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -32,11 +32,16 @@ public class ExceptionOrder { private String returnDirection; private String returnLogisticsNo; private String handlingAction; + private String handlingResult; + private String evidenceFileIdsJson; + private Long inventoryAdjustmentId; private String handlerId; private String handlerName; private String sourceType; private String createdBy; private LocalDateTime exceptionAt; + private LocalDateTime completedAt; + private Long version; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java new file mode 100644 index 000000000..e32ccdecc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.exceptionorder.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_exception_order_command") +public class ExceptionOrderCommand { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long exceptionOrderId; + private String action; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java new file mode 100644 index 000000000..24bf07152 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.exceptionorder.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_exception_return_item") +public class ExceptionReturnItem { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long exceptionOrderId; + private Long outboundPalletItemId; + private Long inventoryPositionId; + private String palletNo; + private String skuCode; + private Long maxReturnPackages; + private Long approvedReturnPackages; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java new file mode 100644 index 000000000..81629f184 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.exceptionorder.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface ExceptionEvidenceFileGateway { + Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file); + void validate(String customerId, long exceptionOrderId, long fileId); + + record Uploaded(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java new file mode 100644 index 000000000..47ffa58e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.exceptionorder.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpExceptionEvidenceFileGateway implements ExceptionEvidenceFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_EXCEPTION_EVIDENCE"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile()); + } + + @Override + public Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file) { + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取异常处理证据", exception); } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId(customerId), BUSINESS_TYPE, exceptionOrderId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException( + result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "异常处理证据上传失败"); }).body(MetadataEnvelope.class); + Metadata data = require(response); + return new Uploaded(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long exceptionOrderId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, exceptionOrderId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException( + result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "异常处理证据不存在或归属不正确"); }).body(MetadataEnvelope.class); + Metadata data = require(response); + if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(exceptionOrderId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "异常处理证据与当前异常单不匹配"); + } + } + + private static Metadata require(MetadataEnvelope response) { + if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回异常处理证据元数据"); + } + return response.data(); + } + private static long tenantId(String value) { + try { return Long.parseLong(value); } + catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); } + } + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java new file mode 100644 index 000000000..7ab0e6b3d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ExceptionOrderCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_order_command WHERE customer_id=#{customerId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1") + ExceptionOrderCommand findByKey(@Param("customerId") String customerId, + @Param("action") String action, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java index 39c25ca45..be96b379c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -12,4 +12,10 @@ public interface ExceptionOrderMapper extends BaseMapper { "AND exception_type='RETURN' LIMIT 1") ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1 FOR UPDATE") + ExceptionOrder lockByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo); + + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1") + ExceptionOrder findByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java new file mode 100644 index 000000000..0b29a66e7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface ExceptionReturnItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id") + List findByException(@Param("customerId") String customerId, + @Param("exceptionOrderId") Long exceptionOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index b4a8c8469..cb1be9ebc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -8,26 +8,52 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; @Service public class ExceptionOrderService { @@ -37,6 +63,14 @@ public class ExceptionOrderService { @Resource private LastlegInboundOrderMapper inboundOrders; @Resource private DeliveryOrderMapper deliveryOrders; @Resource private DeliveryOperationMapper deliveryOperations; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPalletItems; + @Resource private OutboundPdaPickMapper outboundPdaPicks; + @Resource private ExceptionReturnItemMapper returnItems; + @Resource private ExceptionOrderCommandMapper commands; + @Resource private InventoryCommandService inventoryCommands; + @Resource private ExceptionEvidenceFileGateway evidenceFiles; + @Resource private ObjectMapper objectMapper; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { String customerId = customerScope(scopedCustomerId, query.customerId()); @@ -65,14 +99,40 @@ public class ExceptionOrderService { return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo); } + public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { + ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); + } + + public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId, + String exceptionNo, MultipartFile file) { + ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + if (file == null || file.isEmpty()) throw badRequest("请选择异常处理证据文件"); + ExceptionEvidenceFileGateway.Uploaded uploaded = evidenceFiles.upload(customerId, order.getId(), operatorId, file); + return new ExceptionOrderResponses.UploadedEvidence(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + public List returnCandidates(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + throw conflict("当前派送状态不允许标记退回"); + } + return returnCandidates(delivery); + } + + private List returnCandidates(DeliveryOrder delivery) { + String customerId = delivery.getCustomerId(); + String deliveryNo = delivery.getDeliveryNo(); return lastMileOrders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) .orderByAsc(LastlegOrder::getOrderNo)).stream() - .map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo())) + .map(order -> withReturnSources( + relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery)) .distinct().toList(); } @@ -88,27 +148,56 @@ public class ExceptionOrderService { @Transactional public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, ExceptionOrderRequests.MarkReturn request) { - DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); + if (replay != null) return replayCreated(replay, requestHash); + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + throw conflict("当前派送状态不允许标记退回"); + } ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); - if (existing != null) return new ExceptionOrderResponses.Created(existing.getId(), existing.getExceptionNo(), existing.getStatus()); + if (existing != null) throw conflict("该派送单已存在退回异常单"); - List candidates = returnCandidates(customerId, deliveryNo); + List candidates = returnCandidates(delivery); ExceptionOrderResponses.Relation selected = candidates.stream() .filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + List available = selected.returnSources(); + List requested = request.items(); + validateReturnItems(available, requested); + int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum()); + Set skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item))) + .map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet()); + String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED"; + String returnDirection = trim(request.returnDirection()).toUpperCase(java.util.Locale.ROOT); + if (!Set.of("OVERSEAS_WAREHOUSE", "CUSTOMER", "OTHER").contains(returnDirection)) { + throw badRequest("不支持的退回方向"); + } ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), - request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(), + packages, sku, returnDirection, request.returnLogisticsNo(), "DELIVERY_RETURN", operatorId, operatorName); + for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { + ExceptionOrderResponses.ReturnSource source = available.stream() + .filter(candidate -> sameSource(candidate, requestedItem)).findFirst().orElseThrow(); + ExceptionReturnItem item = new ExceptionReturnItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id()); + item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); + item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode()); + item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); + item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item); + } + LocalDateTime now = LocalDateTime.now(); delivery.setStatus("RETURN_PENDING"); delivery.setUpdatedAt(now); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId()); if (tail != null) { tail.setAbnormal(true); - tail.setSubOrderStatus("RETURN_PENDING"); + tail.setSubOrderStatus("DELIVERY:RETURN_PENDING"); tail.setUpdatedAt(now); lastMileOrders.updateById(tail); } @@ -117,9 +206,85 @@ public class ExceptionOrderService { operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorName); operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now); deliveryOperations.insert(operation); + recordCommand(customerId, created.id(), "MARK_RETURN", request.idempotencyKey(), requestHash, created); return created; } + @Transactional + public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.StartProcessing request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "START_PROCESSING", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus("PROCESSING"); order.setHandlingAction(trim(request.handlingAction())); + order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); + exceptionOrders.updateById(order); + ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false); + recordCommand(customerId, order.getId(), "START_PROCESSING", request.idempotencyKey(), requestHash, result); + return result; + } + + @Transactional + public ExceptionOrderResponses.CommandResult cancel(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.Cancel request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason())); + order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); + exceptionOrders.updateById(order); + ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false); + recordCommand(customerId, order.getId(), "CANCEL", request.idempotencyKey(), requestHash, result); + return result; + } + + @Transactional + public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.Complete request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); + request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); + ReturnedInventoryRestoreResponse restored = null; + if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { + List stored = returnItems.findByException(customerId, order.getId()); + if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); + if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); + restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, + new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(), + order.getOverseasWarehouseId(), order.getInboundOrderId(), + stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem( + item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(), + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(), + request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( + receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), + receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), + receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), + request.evidenceFileIds().stream().map(String::valueOf).toList())); + } else if (request.receipts() != null && !request.receipts().isEmpty()) { + throw badRequest("非退回海外仓异常不能提交库存实收明细"); + } + LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt(); + order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult())); + order.setEvidenceFileIdsJson(write(request.evidenceFileIds())); + order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); + order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); + order.setVersion(version(order) + 1); exceptionOrders.updateById(order); + closeReturnedDelivery(order, operatorId, now); + long restoredPackages = restored == null ? 0 : restored.restoredPackages(); + ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false); + recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result); + return result; + } + private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId, String inboundCode, String lastMileOrderNo) { String customerId = customerScope(scopedCustomerId, requestedCustomerId); @@ -150,7 +315,7 @@ public class ExceptionOrderService { resolvedCustomer, tail == null ? null : tail.getCustomerName(), tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(), tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(), - tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount()); + tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of()); } private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, @@ -180,10 +345,11 @@ public class ExceptionOrderService { value.setHandlerId(operatorId); value.setHandlerName(operatorName); } value.setSourceType(sourceType); value.setCreatedBy(operatorId); + value.setVersion(0L); value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now); try { exceptionOrders.insert(value); } catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); } - return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus()); + return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value), false); } private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) { @@ -227,8 +393,164 @@ public class ExceptionOrderService { value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(), value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(), value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(), - value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlerName(), - value.getExceptionAt(), value.getUpdatedAt()); + value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(), + value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value), + readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value)); + } + + private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation, + DeliveryOrder delivery) { + List sources = returnSources(delivery).stream() + .filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) + .map(Source::response).toList(); + return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), + relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), + relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(), + relation.outboundNo(), relation.availablePackages(), sources); + } + + private List returnSources(DeliveryOrder delivery) { + OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo()); + if (outbound == null) throw conflict("派送单缺少原出库单据"); + Map itemById = new LinkedHashMap<>(); + outboundPalletItems.findByOutbound(delivery.getCustomerId(), outbound.getId()) + .forEach(item -> itemById.put(item.getId(), item)); + Map aggregated = new LinkedHashMap<>(); + for (OutboundPdaPick pick : outboundPdaPicks.findByOutbound(delivery.getCustomerId(), outbound.getId())) { + OutboundPalletItem item = itemById.get(pick.getOutboundPalletItemId()); + if (item == null || pick.getInventoryPositionId() == null) continue; + String key = item.getId() + ":" + pick.getInventoryPositionId(); + aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(), + item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)), + (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages())); + } + return new ArrayList<>(aggregated.values()); + } + + private List storedReturnSources(ExceptionOrder order) { + return returnItems.findByException(order.getCustomerId(), order.getId()).stream() + .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), + item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(); + } + + private void validateReturnItems(List available, + List requested) { + Set unique = new java.util.HashSet<>(); + for (ExceptionOrderRequests.ReturnItem item : requested) { + String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId(); + if (!unique.add(key)) throw badRequest("退回库存明细不能重复"); + ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item)) + .findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单")); + if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数"); + } + } + + private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) { + return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId()) + && Objects.equals(source.inventoryPositionId(), item.inventoryPositionId()); + } + + private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) { + ExceptionOrder order = exceptionOrders.lockByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + if (version(order) != expectedVersion) throw conflict("异常订单版本已变化,请刷新后重试"); + if (!expectedStatus.equals(order.getStatus())) throw conflict("当前异常状态不允许执行该操作"); + return order; + } + + private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) { + if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) { + throw conflict("关联派送单不是退货处理中状态"); + } + delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); + deliveryOrders.updateById(delivery); + for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, order.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) { + tail.setAbnormal(false); tail.setSubOrderStatus("DELIVERY:RETURNED"); tail.setUpdatedAt(now); + lastMileOrders.updateById(tail); + } + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode("RETURN_COMPLETED"); operation.setOperatorId(operatorId); + operation.setOperatorName(operatorId); operation.setResult("异常退回处理完成:" + order.getExceptionNo()); + operation.setCreatedAt(now); deliveryOperations.insert(operation); + } + + private List allowedActions(ExceptionOrder order) { + return switch (Objects.requireNonNullElse(order.getStatus(), "")) { + case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); + case "PROCESSING" -> List.of("COMPLETE_EXCEPTION"); + default -> List.of(); + }; + } + + private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages, + boolean replayed) { + return new ExceptionOrderResponses.CommandResult(order.getId(), order.getExceptionNo(), order.getStatus(), + version(order), order.getInventoryAdjustmentId(), restoredPackages, order.getCompletedAt(), replayed); + } + + private void recordCommand(String customerId, Long orderId, String action, String idempotencyKey, + String requestHash, Object result) { + ExceptionOrderCommand command = new ExceptionOrderCommand(); + command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(orderId); + command.setAction(action); command.setIdempotencyKey(idempotencyKey.trim()); + command.setRequestHash(requestHash); command.setResultJson(write(result)); command.setCreatedAt(LocalDateTime.now()); + commands.insert(command); + } + + private ExceptionOrderResponses.CommandResult replayResult(ExceptionOrderCommand command, String requestHash) { + requireReplayHash(command, requestHash); + try { + ExceptionOrderResponses.CommandResult result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.CommandResult.class); + return new ExceptionOrderResponses.CommandResult(result.id(), result.exceptionNo(), result.status(), result.version(), + result.inventoryAdjustmentId(), result.restoredPackages(), result.completedAt(), true); + } catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } + } + + private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) { + requireReplayHash(command, requestHash); + try { + ExceptionOrderResponses.Created result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); + return new ExceptionOrderResponses.Created(result.id(), result.exceptionNo(), result.status(), result.version(), true); + } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } + } + + private void requireReplayHash(ExceptionOrderCommand command, String requestHash) { + if (!Objects.equals(command.getRequestHash(), requestHash)) throw conflict("幂等键已用于不同请求"); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); } + } + + private List readEvidenceFileIds(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference<>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); } + } + + private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + + private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, + String skuCode, String inboundCode, long maxReturnPackages) { + Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); } + ExceptionOrderResponses.ReturnSource response() { + return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId, + palletNo, skuCode, maxReturnPackages); + } } private String customerScope(String scopedCustomerId, String requestedCustomerId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 849239c28..3a3c83bef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; @@ -44,19 +44,24 @@ public class InboundPlanController { return ApiResponse.success(service.page(scope(user, customerId), query)); } - @PostMapping("/convert") + @PostMapping("/manual") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 由平台人工将待分配尾程订单转换为入仓计划。 */ - @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) - public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody ConvertInboundPlanRequest request) { - return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); + public ApiResponse manual(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划"); + } + convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId)); } @PostMapping("/convert-container") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - @Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + @Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse> convertContainer( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertContainerInboundPlanRequest request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java deleted file mode 100644 index 615215fa4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.lastleg.inboundplan.dto; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record ConvertInboundPlanRequest( - @NotNull Long orderId, - @Size(max = 128) String customerId -) { -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java index 87a48969b..91840b731 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -12,6 +12,8 @@ public record InboundPlanListResponse( String inboundCode, String lxCode, String orderType, + String overseasWarehouseCode, + String overseasWarehouseName, String warehouseId, String warehouseCode, String warehouseName, @@ -29,18 +31,4 @@ public record InboundPlanListResponse( String dispatchStatus, LocalDateTime createdAt, Integer version -) { - public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId, - Long orderId, String inboundCode, String orderType, - String warehouseId, String warehouseCode, String warehouseName, - String fbaWarehouseCode, String customerName, Integer packageCount, - BigDecimal volumeCbm, Integer palletCount, String status, - String appointmentNo, String appointmentStatus, - LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, - String dispatchStatus, LocalDateTime createdAt) { - this(lineId, planId, planNo, customerId, orderId, inboundCode, null, orderType, warehouseId, - warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0, - volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt, - expectedLoadingAt, dispatchStatus, createdAt, 0); - } -} +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index 0fd8cd3e6..8188d0a75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.dto; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public record InboundPlanResponse( @@ -10,6 +11,8 @@ public record InboundPlanResponse( String customerId, Long sourceOrderId, String containerNo, + String overseasWarehouseCode, + String overseasWarehouseName, String status, int lineCount, int palletCount, @@ -19,24 +22,15 @@ public record InboundPlanResponse( List lines, List pallets, List allocations, - int version + int version, + LocalDate plannedArrivalDate, + String palletType, + boolean assigned, + boolean mergeRequired, + boolean urgent, + String creationMode, + String remark ) { - public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, - String containerNo, String status, int lineCount, int palletCount, - BigDecimal totalVolumeCbm, List lines, List pallets, - List allocations) { - this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, - totalVolumeCbm, lines, pallets, allocations, 0); - } - - public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, - String containerNo, String status, int lineCount, int palletCount, - BigDecimal totalVolumeCbm, List lines, List pallets, - List allocations, int version) { - this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, - totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(), - allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version); - } public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { @@ -46,7 +40,7 @@ public record InboundPlanResponse( String destinationWarehouseCode, String destinationWarehouseName, BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, - String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + String palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, int actualPackageCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java new file mode 100644 index 000000000..ce7be8228 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ManualInboundPlanRequest( + @NotBlank @Size(max = 128) String customerId, + @NotNull Long orderId, + @NotNull @Min(0) Integer expectedOrderVersion, + @NotNull LocalDate plannedArrivalDate, + @NotNull @Min(1) @Max(999) Integer palletCount, + @NotBlank @Size(max = 32) String palletType, + @NotNull Boolean assigned, + @NotNull Boolean mergeRequired, + @NotNull Boolean urgent, + @Size(max = 1000) String remark +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index 51c30b470..5a675332c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -8,6 +8,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; @Data @TableName("ll_inbound_plan") @@ -19,6 +20,13 @@ public class InboundPlan { private Long sourceOrderId; private String containerNo; private Long overseasWarehouseId; + private LocalDate plannedArrivalDate; + private String palletType; + private Boolean assignedFlag; + private Boolean mergeRequired; + private Boolean urgentFlag; + private String creationMode; + private String planRemark; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java index 28eea73e4..5e2085168 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -16,6 +16,8 @@ public class InboundPlanPallet { private String customerId; private Long planId; private String palletNo; + private String sourcePalletNo; + private String palletType; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index f7669d5f6..8aedf41ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -14,6 +14,19 @@ import java.util.List; public interface InboundPlanOrderMapper { record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo, String orderType) { } + record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName) { } + record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId, + String overseasWarehouseId) { } + + @Select("SELECT c.customer_id AS customerId,c.id AS orderId," + + "c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " + + "FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " + + "WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " + + "AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " + + "ORDER BY c.customer_id,c.id FOR UPDATE") + List lockContainerWarehouseFacts(@Param("customerId") String customerId, + @Param("orderId") Long orderId); @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," + "order_type AS orderType " + @@ -26,22 +39,30 @@ public interface InboundPlanOrderMapper { @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + - "COALESCE(o.pallet_count,0) AS recommendedPalletCount," + + "COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," + + "o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + - "0 AS destinationMismatchCount " + + "0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + - "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," + - "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," + + "o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " + "ORDER BY o.id FOR UPDATE") List lockOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT overseas_warehouse_id AS overseasWarehouseId," + + "overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " + + "FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + "WHERE customer_id=#{customerId} AND id=#{orderId}") @@ -49,6 +70,17 @@ public interface InboundPlanOrderMapper { @Param("planNo") String planNo, @Param("palletCount") int palletCount, @Param("updatedAt") LocalDateTime updatedAt); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," + + "pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " + + "AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL") + int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("mergeRequired") boolean mergeRequired, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") LocalDateTime updatedAt); + @Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " + "VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 7ee4ee6fb..86358277c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper { " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')" + " AND p.status=#{q.status}" + - " AND l.destination_warehouse_id=#{q.overseasWarehouseId}" + + " AND o.overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + @@ -38,6 +38,7 @@ public interface InboundPlanQueryMapper { @Select("