diff --git a/.gitignore b/.gitignore index f0ffe02b8..8d49f9f98 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ logs/ data/ .env .env.* +.env.development !.env.example !.env.*.example application-local.yml diff --git a/AGENTS.md b/AGENTS.md index aa72ee8ff..896e86841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,58 +16,58 @@ ## 服务路由(按关键词定位) -| 任务关键词 | 首选服务 | 先看入口 | -|---|---|---| -| 登录、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/` 定位;业务事件生产者回到所属服务 | -| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | -| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | -| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | -| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 | -| 履约分配、入库、测量、箱规算法、审核、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`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | -| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 | -| 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` | -| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File | -| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | -| 全局智能装柜候选、柜次、占位与移除 | `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/` | 国内 `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` 和客户范围校验 | -| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | -| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | -| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | -| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | -| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 | -| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | -| 百度/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`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.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/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 | -| 一件代发 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 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `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 候选进入可收货 | -| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | -| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | -| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | +本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。 -散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 +| 任务关键词 | 首选服务 | +|---|---| +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | +| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | +| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | +| 消息中心、站内通知、消息模板、收件箱 | `iam/` | +| 订单、货物、快照、状态、幂等、Outbox | `order/` | +| 客户头程订单中文批量导入 | `order/` | +| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | +| 平台订单批量审核预览 | `order/` | +| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | +| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | +| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | +| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | +| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | +| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | +| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | +| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | +| 客户入库结果与头程费用确认 | `firstleg/` | +| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | +| 目的港码头拖车与还空预警 | `firstleg/` | +| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | +| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | +| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | +| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | +| 供应商、国内整柜库存与出入库 | `warehouse/` | +| 客户运营统计、在途柜量 | `firstleg/` | +| 平台操作日志与审计查询 | `audit-service/` | +| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | +| 路由、CORS、JWT 入口、访问域 | `gateway/` | +| 百度/Amazon/OCR/第三方适配 | `integration-service/` | +| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | +| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | +| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | +| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | +| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | +| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | +| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | +| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | +| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | ## 公共模块快速索引 diff --git a/Jenkinsfile b/Jenkinsfile index b95e6abfa..d12eddd11 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -59,6 +59,42 @@ def databaseMigrationPaths = ['deploy/sql/'] def downstreamResults = [:] +def sendProductionWeComNotification(String status, String color, String detail) { + node('java-builder') { + withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { + String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '—' + String content = """## 生产部署通知 +> 项目:${env.JOB_NAME} +> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) +> 提交:${sourceVersion} +> 状态:${status} +> 详情:${detail} +> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} +""" + String payloadFile = 'wecom-production-notification.json' + writeFile( + file: payloadFile, + text: groovy.json.JsonOutput.toJson([ + msgtype: 'markdown', + markdown: [content: content] + ]), + encoding: 'UTF-8' + ) + sh ''' + set -eu + payload_file='wecom-production-notification.json' + trap 'rm -f "$payload_file"' EXIT HUP INT TERM + response="$(curl --fail --silent --show-error \ + -H 'Content-Type: application/json' \ + --data-binary @"$payload_file" \ + "$WECOM_WEBHOOK")" + printf '%s\n' "$response" + printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' + ''' + } + } +} + def parseAutoBuildState(String description, List allowedServices) { if (!description?.startsWith('AUTO ')) return null int markerIndex = description.indexOf(' | ') @@ -418,4 +454,67 @@ pipeline { } } + post { + success { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') { + try { + sendProductionWeComNotification( + '部署成功', + 'info', + "服务:${env.SELECTED_SERVICES ?: '—'}" + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + unstable { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { + try { + sendProductionWeComNotification( + '部署部分失败', + 'warning', + currentBuild.description ?: '部分服务部署未成功,请查看 Jenkins 日志' + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + failure { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { + try { + sendProductionWeComNotification( + '部署失败', + 'warning', + '生产发布流程失败,请打开 Jenkins 构建日志查看失败阶段' + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + aborted { + script { + if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { + try { + sendProductionWeComNotification( + '部署已取消', + 'comment', + '生产部署流程被人工取消或中止' + ) + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } + } + } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 65c0769af..cda05aa12 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -50,7 +50,7 @@ class OverseasTokenServiceTest { account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); String authority = "platform:overseas-master-data:quote:list"; String merchantAuthority = "merchant:warehouse:delivery:list"; - String merchantCrossBorderAuthority = "merchant:cross-border:procurement"; + String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list"; String lastlegAuthority = "lastleg:pda:operate"; String financeAuthority = "platform:finance:fee-source:edit"; String domesticAuthority = "platform:firstleg:order:list"; diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d5fc094b2..087e52ea9 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 136 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 @@ -24,7 +24,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/136`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -40,7 +40,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、346 个权限、521 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、364 个权限、539 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 82ba95643..61903f0a2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1438,7 +1438,7 @@ CREATE TABLE fl_consumed_event ( CREATE TABLE fl_container_booking ( id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', - container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '集装箱号;确认前允许修订', + container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订', cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号', source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', record_status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', @@ -1494,7 +1494,8 @@ CREATE TABLE fl_container_booking ( PRIMARY KEY (id), UNIQUE KEY uk_container_booking_no (booking_no), UNIQUE KEY uk_container_booking_cabinet (cabinet_no), - UNIQUE KEY uk_container_booking_container_no (container_no), + UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no), + KEY idx_container_booking_container_no (container_no,updated_at,id), KEY idx_container_booking_merge (record_status,merged_into_container_booking_id), KEY idx_container_booking_warehouse (warehouse_id,updated_at,id), KEY idx_container_booking_voyage (voyage_no,updated_at), @@ -1883,7 +1884,7 @@ CREATE TABLE fl_customs_frozen_document_inbound ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开始报关时冻结的客户资料覆盖范围'; CREATE TABLE fl_document_sequence ( - sequence_key varchar(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818', + sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET', current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0)) @@ -3194,7 +3195,7 @@ CREATE TABLE fl_loading_pallet_label ( destination_warehouse_code varchar(64) NOT NULL COMMENT 'FBA仓编码快照', destination_warehouse_name varchar(128) DEFAULT NULL COMMENT 'FBA仓名称快照', pallet_index int unsigned NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号', - pallet_code varchar(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号', + pallet_code varchar(128) NOT NULL COMMENT '托盘码:平台柜次号-FBA仓内部编号-三位序号;不依赖可跨提单复用的真实柜号', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间', PRIMARY KEY (id), UNIQUE KEY uk_loading_pallet_code (pallet_code), @@ -3368,6 +3369,7 @@ CREATE TABLE fl_ocean_booking ( version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_ocean_booking_no (booking_no), + UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no), KEY idx_ocean_booking_customer (customer_id,status,updated_at,id), KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), @@ -5966,7 +5968,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) -- 系统基线 iam_number_sequence:1 行。 INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); --- 系统基线 iam_permission:346 行。 +-- 系统基线 iam_permission:364 行。 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, created_at, updated_at, deleted) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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, created_at, updated_at, deleted) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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, created_at, updated_at, deleted) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6307,12 +6309,30 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, 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, created_at, updated_at, deleted) VALUES (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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, created_at, updated_at, deleted) VALUES (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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, created_at, updated_at, deleted) VALUES (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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, created_at, updated_at, deleted) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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, created_at, updated_at, deleted) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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, created_at, updated_at, deleted) VALUES (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306111,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:create','创建美国本土卡订单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306112,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:edit','编辑美国本土卡订单',NULL,'PATCH',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping','GET',90,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306121,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:create','创建美本土散板订单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306122,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:edit','编辑美本土散板订单',NULL,'PUT',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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, created_at, updated_at, deleted) VALUES (5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); 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, created_at, updated_at, deleted) VALUES (5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -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, created_at, updated_at, deleted) VALUES (5306141,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',96,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306150,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306160,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:relabel:list','贴标申请','/overseas-warehouse/labeling','GET',110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306161,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:create','创建贴标申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306162,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:files','管理贴标文件',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306163,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:confirm','确认贴标完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306170,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:transfer:list','仓储转运','/overseas-warehouse/storage-transfer','GET',120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306171,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:create','创建仓储转运单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306172,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:confirm','确认仓储转运实测',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +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, created_at, updated_at, deleted) VALUES (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -- 系统基线 iam_role:8 行。 INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000001,0,'PLATFORM','PLATFORM_ADMIN','平台超级管理员','SYSTEM','ACTIVE','一期平台全部菜单和按钮',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); @@ -6324,7 +6344,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); --- 系统基线 iam_role_permission:521 行。 +-- 系统基线 iam_role_permission:539 行。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); @@ -6841,11 +6861,29 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306111,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306112,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306120,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306121,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306122,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306130,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306140,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306141,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306150,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306160,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306161,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306162,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306163,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306170,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306171,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3)); -- 系统基线 iam_tenant:1 行。 INSERT INTO iam_tenant (id, tenant_code, tenant_name, customer_nickname, tenant_type, status, dimension_initialization_status, legal_name, legal_representative, unified_social_credit_code, contact_name, contact_phone, contact_email, service_package, contact_wechat, sales_owner_name, payment_term_days, enterprise_type, established_date, registered_capital, registered_capital_currency, registered_address, internal_remark, approved_at, approved_by, reject_reason, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (0,'PLATFORM','虫虫出海平台','平台','PLATFORM','ACTIVE','READY','虫虫出海平台',NULL,NULL,'平台系统管理员',NULL,NULL,'基础版',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,CURRENT_TIMESTAMP(3),1000001,NULL,1000001,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6e50d87f2..03d53351d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -311,14 +311,19 @@ CREATE TABLE ll_delivery_order ( delivery_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送单号', outbound_order_id bigint NOT NULL COMMENT '关联出库单ID', outbound_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联出库单号', - dispatch_order_id bigint NOT NULL COMMENT '关联派车单ID', - dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派车单号', + dispatch_order_id bigint DEFAULT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联派车单号,仓储转运直接出库时为空', overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓ID快照', - destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓名称快照', + destination_type varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓派送地址', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运运单号', @@ -433,7 +438,14 @@ CREATE TABLE ll_dispatch_item ( destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', carton_marks varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照', @@ -1091,6 +1103,10 @@ CREATE TABLE ll_inbound_plan_line ( destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '入仓码体积(立方米)', planned_pallet_count int NOT NULL DEFAULT '0' COMMENT '该明细分配的托盘数量', required_package_count int NOT NULL DEFAULT '0' COMMENT '计划明细应收箱数', @@ -1110,6 +1126,11 @@ CREATE TABLE ll_inbound_plan_pallet ( destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', capacity_cbm decimal(18,4) NOT NULL DEFAULT '2.0000' COMMENT '托盘容量,固定为2立方米', allocated_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '已分配体积(立方米)', sequence_no int NOT NULL COMMENT '计划内托盘序号', @@ -1455,6 +1476,7 @@ CREATE TABLE ll_order ( version int NOT NULL DEFAULT '0' COMMENT '尾程订单命令乐观锁版本', pallet_count int NOT NULL DEFAULT '0' COMMENT '托数', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + received_package_count int DEFAULT NULL COMMENT 'PDA实际入库箱数', picked_package_count int NOT NULL DEFAULT '0' COMMENT '累计已拣箱数', shipped_package_count int NOT NULL DEFAULT '0' COMMENT '累计已出库箱数', volume_cbm decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', @@ -1654,6 +1676,9 @@ CREATE TABLE ll_outbound_order ( status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', + gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -1679,6 +1704,9 @@ CREATE TABLE ll_outbound_pallet_item ( destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU', @@ -2704,7 +2732,17 @@ CREATE TABLE ll_warehouse_transfer_order ( lastleg_order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程订单号', operating_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码', + destination_type varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + private_destination_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓完整地址', + private_destination_postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + private_destination_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + private_destination_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提货地址或中转仓收货地址', + pickup_contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系人', + pickup_contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', package_count int NOT NULL COMMENT '箱数', delivery_method varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', estimated_arrival_at datetime(3) NOT NULL COMMENT '预计入仓时间', @@ -2718,6 +2756,10 @@ CREATE TABLE ll_warehouse_transfer_order ( relabel_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标申请单号', relabel_completed_at datetime(3) DEFAULT NULL COMMENT '关联贴标完成时间', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + review_decision varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核决定', + review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注或驳回原因', + reviewed_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', allocation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配幂等键', allocation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配请求哈希', @@ -2738,6 +2780,8 @@ CREATE TABLE ll_warehouse_transfer_order ( confirmation_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认幂等键', confirmation_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认请求哈希', customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认测量时间', + destination_binding_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定幂等键', + destination_binding_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定请求哈希', PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_no (customer_id,transfer_no), UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), @@ -2751,19 +2795,24 @@ CREATE TABLE ll_warehouse_transfer_pallet ( transfer_order_id bigint NOT NULL COMMENT '转运单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', sequence_no int NOT NULL COMMENT '托序号', - warehouse_pallet_id bigint NOT NULL COMMENT '海外仓真实托盘ID', - pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实托盘码', - warehouse_location_id bigint NOT NULL COMMENT '真实库位ID', + warehouse_pallet_id bigint DEFAULT NULL COMMENT '生成后的海外仓真实托盘ID', + pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生成后的真实托盘码', + warehouse_location_id bigint DEFAULT NULL COMMENT '生成后的真实库位ID', warehouse_location_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实库位码', - warehouse_resource_lock_id bigint NOT NULL COMMENT '仓储资源锁ID', + warehouse_resource_lock_id bigint DEFAULT NULL COMMENT '生成后的仓储资源锁ID', carton_count int NOT NULL COMMENT '本托实收箱数', length_cm decimal(12,2) NOT NULL COMMENT '实测长cm', width_cm decimal(12,2) NOT NULL COMMENT '实测宽cm', height_cm decimal(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg decimal(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm decimal(18,6) NOT NULL COMMENT '实测体积CBM', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '打托状态', - completed_at datetime(3) NOT NULL COMMENT 'PDA打托完成时间', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + draft_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿保存幂等键', + draft_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿请求摘要', + generate_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新托盘生成幂等键', + bind_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫码绑定幂等键', + bound_at datetime(3) DEFAULT NULL COMMENT '扫码确认绑定时间', + completed_at datetime(3) DEFAULT NULL COMMENT '整单打托完成时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), @@ -2785,6 +2834,22 @@ CREATE TABLE ll_warehouse_transfer_pallet_item ( KEY idx_ll_transfer_pallet_item_order (customer_id,transfer_order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE ll_warehouse_transfer_measurement_photo ( + id bigint NOT NULL COMMENT '测量照片引用ID', + transfer_order_id bigint NOT NULL COMMENT '仓储转运单ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + file_id bigint NOT NULL COMMENT '文件服务文件ID', + file_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', + content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', + size_bytes bigint NOT NULL DEFAULT '0' COMMENT '文件字节数快照', + uploaded_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', + uploaded_at datetime(3) NOT NULL COMMENT '上传时间', + submitted_at datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file (customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order (customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; + CREATE TABLE ll_warehouse_transfer_receipt ( id bigint NOT NULL COMMENT '雪花主键', transfer_order_id bigint NOT NULL COMMENT '转仓单ID', @@ -2995,4 +3060,5 @@ USE baiyue_lastleg; INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 137 AS initialized_tables; +-- assert_relabel_transfer_latest_schema_ready: 贴标申请与仓储转运最新表结构已完整部署。 +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 138 AS initialized_tables; diff --git a/docs/backend-business-index.md b/docs/backend-business-index.md new file mode 100644 index 000000000..c28c57c98 --- /dev/null +++ b/docs/backend-business-index.md @@ -0,0 +1,58 @@ +# 后端业务详细索引 + +本文件是同服务 `AGENTS.md` 的按需补充;命中业务后读取相应行/段,不默认全文加载。路径保持以后端仓库根为基准,各服务简写代码路径进入对应服务定位,不相对于本文件的 `docs/` 目录。 + +## 服务路由(按关键词定位) + +| 任务关键词 | 首选服务 | 先看入口 | +|---|---|---| +| 登录、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/` 定位;业务事件生产者回到所属服务 | +| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | +| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | +| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | +| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 | +| 履约分配、入库、测量、箱规算法、审核、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`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | +| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 | +| 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` | +| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File | +| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 | +| 全局智能装柜候选、柜次、占位与移除 | `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/` | 国内 `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` 和客户范围校验 | +| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | +| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | +| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | +| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | +| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 | +| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | +| 百度/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`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.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/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 | +| 一件代发 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 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `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 候选进入可收货 | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | +| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | + +散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 diff --git a/file/AGENTS.md b/file/AGENTS.md index fe8603673..063fc77f7 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -30,6 +30,8 @@ ## 修改约束 +- 贴标箱唛总文件和 SKU 总文件分别使用 `OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT`、`OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT`,与明细 PDF 采用同一客户复合归属和 10MiB PDF 校验。`file_object.business_type` 为 VARCHAR(64),存量扩容见海外统一增量和 `scripts/db/relabel-file-scope-incremental.sql`。Print 只负责生成 PDF,不保存上传文件。 + - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 - 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。 - 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。 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 f2603ec75..10c944048 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -24,7 +24,10 @@ enum BusinessFileCategory { CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, 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), diff --git a/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java index d3e76f9ea..629d70a0c 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java +++ b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java @@ -28,7 +28,7 @@ final class ExportSchemaContracts { column("mainStatus", "订单主状态", ExportColumnType.STRING), column("customerName", "客户", ExportColumnType.STRING), column("channelName", "渠道", ExportColumnType.STRING), - column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN), + column("channelChanged", "曾变更渠道", ExportColumnType.STRING), column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING), column("destinationWarehouseName", "交货仓", ExportColumnType.STRING), column("expectedShipDate", "预计出运日期", ExportColumnType.DATE), @@ -44,7 +44,7 @@ final class ExportSchemaContracts { column("skuNos", "SKU", ExportColumnType.STRING), column("taxMode", "报关方式", ExportColumnType.STRING), column("containerMode", "装柜方式", ExportColumnType.STRING), - column("customsInspection", "是否商检", ExportColumnType.BOOLEAN), + column("customsInspection", "是否商检", ExportColumnType.STRING), column("containerNo", "集装箱号", ExportColumnType.STRING), column("cabinetNo", "柜次号", ExportColumnType.STRING), column("vesselName", "船名", ExportColumnType.STRING), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 2a4a61cb0..17c8d2f4d 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 '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', + business_type VARCHAR(64) 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/domestic-order-export-v1.yaml b/file/src/main/resources/openapi/domestic-order-export-v1.yaml index e2b788aad..befe98689 100644 --- a/file/src/main/resources/openapi/domestic-order-export-v1.yaml +++ b/file/src/main/resources/openapi/domestic-order-export-v1.yaml @@ -114,7 +114,7 @@ components: mainStatus: {type: string, title: 订单主状态} customerName: {type: string, title: 客户} channelName: {type: string, title: 渠道} - channelChanged: {type: boolean, title: 曾变更渠道} + channelChanged: {type: string, title: 曾变更渠道, description: 是或否} receivingWarehouseName: {type: string, title: 定位仓库} destinationWarehouseName: {type: string, title: 交货仓} expectedShipDate: {type: string, format: date, title: 预计出运日期} @@ -130,7 +130,7 @@ components: skuNos: {type: string, title: SKU} taxMode: {type: string, title: 报关方式} containerMode: {type: string, title: 装柜方式} - customsInspection: {type: boolean, title: 是否商检} + customsInspection: {type: string, title: 是否商检, description: 是或否} containerNo: {type: string, title: 集装箱号} cabinetNo: {type: string, title: 柜次号} vesselName: {type: string, title: 船名} diff --git a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml index 9444e9a4d..1b0d87045 100644 --- a/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml +++ b/file/src/main/resources/openapi/overseas-relabel-document-v1.yaml @@ -4,7 +4,7 @@ info: version: 1.0.0 description: >- File 只保存文件并强制客户、业务类型、业务ID与文件ID复合归属。 - OVERSEAS_RELABEL_TOTAL_DOCUMENT 的 businessId 为 application.id; + OVERSEAS_RELABEL_TOTAL_DOCUMENT、OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT、OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT 的 businessId 为 application.id; OVERSEAS_RELABEL_ITEM_DOCUMENT 的 businessId 为 item.id。 Lastleg 必须先校验申请与明细存在、明细所属申请及其客户归属、业务状态和权限。 客户归属未知时失败关闭,不得使用平台归属或替代业务ID。 @@ -88,7 +88,7 @@ components: 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_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} + businessType: {type: string, enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT]} businessId: {type: string, pattern: '^[1-9][0-9]*$'} fileName: {type: string} sha256: {type: string, pattern: '^[0-9a-f]{64}$'} @@ -112,7 +112,7 @@ components: required: true schema: type: string - enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] + enum: [OVERSEAS_RELABEL_TOTAL_DOCUMENT, OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT, OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT, OVERSEAS_RELABEL_ITEM_DOCUMENT] BusinessId: name: businessId in: path diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java index fbe7daa45..97bcb5f18 100644 --- a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -57,6 +57,8 @@ class DomesticWarehouseOrderExportContractTest { assertThat(columns).containsKeys("expectedVolumeCbm", "expectedArrivalAt") .doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId", "destinationWarehouseId"); + assertThat(map(columns.get("channelChanged")).get("type")).isEqualTo("string"); + assertThat(map(columns.get("customsInspection")).get("type")).isEqualTo("string"); assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store"); assertThat(paths).containsKey("/api/platform/v1/files/export-tasks/{taskId}/retry"); assertThat(map(map(schemas.get("ExportTaskRetryState")).get("properties"))) diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index d983ad4ba..50efcf033 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -157,6 +157,9 @@ class GenericXlsxExportRunnerTest { Map row = new java.util.LinkedHashMap<>(); row.put("inboundCode", "IN-1"); row.put("orderNo", "ORD-1"); row.put("expectedVolumeCbm", "1.25"); row.put("expectedArrivalAt", "2026-09-03T12:30:00"); + row.put("mainStatus", "待审核"); row.put("channelChanged", "否"); + row.put("feeReviewStatus", "未提交"); row.put("taxMode", "买单报关"); + row.put("containerMode", "拼箱"); row.put("customsInspection", "否"); var batch = new ExportBatchResponse(1, "sha256:domestic-orders", "snapshot-orders", columns, List.of(row), null, false, 1L); when(provider.batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), any(), any())) @@ -173,6 +176,12 @@ class GenericXlsxExportRunnerTest { assertEquals("预计到货时间", sheet.getRow(0).getCell(9).getStringCellValue()); assertEquals("预计体积(m³)", sheet.getRow(0).getCell(10).getStringCellValue()); assertEquals(1.25d, sheet.getRow(1).getCell(10).getNumericCellValue()); + assertEquals("待审核", sheet.getRow(1).getCell(2).getStringCellValue()); + assertEquals("否", sheet.getRow(1).getCell(5).getStringCellValue()); + assertEquals("未提交", sheet.getRow(1).getCell(17).getStringCellValue()); + assertEquals("买单报关", sheet.getRow(1).getCell(19).getStringCellValue()); + assertEquals("拼箱", sheet.getRow(1).getCell(20).getStringCellValue()); + assertEquals("否", sheet.getRow(1).getCell(21).getStringCellValue()); } ArgumentCaptor request = ArgumentCaptor.forClass(ExportBatchRequest.class); verify(provider).batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST), diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java index f2f826bf9..cdc850eba 100644 --- a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -30,8 +30,8 @@ class OverseasExceptionEvidenceContractTest { 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); + assertThat(sql).contains("business_type VARCHAR(64)", "OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(64); } } } diff --git a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java index 1b8c009ed..380d6feda 100644 --- a/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/RelabelDocumentFileTest.java @@ -25,7 +25,7 @@ class RelabelDocumentFileTest { @TempDir Path root; @ParameterizedTest - @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"}) void pdfPolicyChecksContentTypeSignatureAndSize(BusinessFileCategory category) { BusinessFileStorage storage = storage(); byte[] exactLimit = new byte[10 * 1024 * 1024]; @@ -52,7 +52,7 @@ class RelabelDocumentFileTest { } @ParameterizedTest - @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT"}) + @EnumSource(value = BusinessFileCategory.class, names = {"OVERSEAS_RELABEL_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_ITEM_DOCUMENT", "OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT", "OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT"}) void uploadAndReadRequireExactCustomerBusinessTypeBusinessAndFile(BusinessFileCategory category) throws Exception { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "relabel-test"), FileObjectEntity.class); FileObjectMapper files = mock(FileObjectMapper.class); diff --git a/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java b/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java new file mode 100644 index 000000000..671602bab --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/TransferMeasurementPhotoContractTest.java @@ -0,0 +1,15 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransferMeasurementPhotoContractTest { + @Test + void transferMeasurementPhotoIsCustomerOwnedStandardBusinessFile() { + BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_TRANSFER_MEASUREMENT_PHOTO"); + category.validateOwner(10001L); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD); + assertThat(category.storageBusinessId("77")).isEqualTo("77"); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index de489a7d2..0355d2175 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,7 +9,7 @@ ## 模块入口 -散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为包含 141 等内部编号的完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 +柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | |---|---| @@ -36,7 +36,7 @@ | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | -| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | +| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | | 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | @@ -73,7 +73,7 @@ - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 -- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 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 7c9c3405f..16631c501 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -5,6 +5,8 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import java.math.BigDecimal; import java.util.List; @@ -36,10 +38,21 @@ public interface OrderCargoClient { @RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId, @PathVariable long orderId); + @PutMapping("/{tenantId}/{orderId}/remarks") + ApiResponse updateRemarks( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable long orderId, + @RequestBody RemarksUpdateRequest request); + record MeasurementDeclarationSnapshot( String referenceId, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} + record RemarksUpdateRequest(String internalRemark, String warehouseRemark, + String customerServiceRemark, long operatorId) {} + record RemarksUpdateResponse(int version, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark) {} + record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index e32e176a7..24b464e85 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -12,7 +12,9 @@ import com.baiyue.firstleg.service.InboundOrderCreationService; import com.baiyue.firstleg.service.InboundCartonLabelService; import com.baiyue.firstleg.service.UnifiedInboundQueryService; import com.baiyue.firstleg.service.OrderDeclarationSyncService; +import com.baiyue.firstleg.service.OrderRemarksSyncService; import com.baiyue.firstleg.dto.OrderDeclarationSync; +import com.baiyue.firstleg.dto.OrderRemarksSync; import com.baiyue.firstleg.dto.UnifiedInboundDetail; import com.baiyue.firstleg.dto.UnifiedInboundQuery; import com.baiyue.firstleg.dto.UnifiedInboundRequest; @@ -43,6 +45,8 @@ public class InternalInboundOrderController { private UnifiedInboundQueryService unifiedQueryService; @Resource private OrderDeclarationSyncService declarationSync; + @Resource + private OrderRemarksSyncService remarksSync; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -127,6 +131,14 @@ public class InternalInboundOrderController { return ApiResponse.success(null); } + @PostMapping("/order-remarks-updated") + public ApiResponse updateOrderRemarks( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody OrderRemarksSync.Command command) { + requireToken(token); + return ApiResponse.success(remarksSync.sync(command)); + } + @PostMapping("/unified-orders/query") public ApiResponse> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, 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 deda6cb94..363b15edb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -23,6 +23,8 @@ public final class InboundMeasurementResponses { Integer expectedPalletCount, String processStatus, String measureReviewStatus, + @Schema(description = "当前最新费用版本是否已由客户确认;确认后测量数据不可再修改") + boolean customerFeeConfirmed, @Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘", allowableValues = "STANDARD", example = "STANDARD") String recommendedPalletType, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderRemarksSync.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderRemarksSync.java new file mode 100644 index 000000000..e12d90a7e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderRemarksSync.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public final class OrderRemarksSync { + private OrderRemarksSync() { + } + + public record Command( + @Positive long tenantId, + @Positive long orderId, + @Size(max = 1000) String customerRemark, + @Size(max = 1000) String internalRemark, + @Size(max = 1000) String warehouseRemark, + @Size(max = 1000) String customerServiceRemark, + @NotNull @Positive Long operatorId, + @Size(max = 128) String operatorName + ) { + } +} 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 f503a3a7f..66605e865 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -168,9 +168,6 @@ public interface ContainerBookingMapper extends BaseMapper { @Update(""" UPDATE fl_loading_plan_item SET deleted=1,removed_by=#{operatorId},removed_by_name=#{operatorName}, @@ -30,4 +30,69 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl @Param("customerServiceRemark") String customerServiceRemark, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Select(""" + SELECT DISTINCT i.loading_plan_id + FROM fl_loading_plan_item i + JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING') + WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL + AND (#{excludedItemId} IS NULL OR i.id <> #{excludedItemId}) + AND NOT (i.customer_remark <=> #{customerRemark} + AND i.internal_remark <=> #{internalRemark} + AND i.warehouse_remark <=> #{warehouseRemark} + AND i.customer_service_remark <=> #{customerServiceRemark}) + """) + List findMutablePlanIdsWithDifferentRemarks(@Param("tenantId") long tenantId, + @Param("orderId") long orderId, + @Param("customerRemark") String customerRemark, + @Param("internalRemark") String internalRemark, + @Param("warehouseRemark") String warehouseRemark, + @Param("customerServiceRemark") String customerServiceRemark, + @Param("excludedItemId") Long excludedItemId); + + @Update(""" + UPDATE fl_loading_plan_item i + JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING') + SET i.customer_remark=#{customerRemark}, i.internal_remark=#{internalRemark}, i.warehouse_remark=#{warehouseRemark}, + i.customer_service_remark=#{customerServiceRemark}, i.updated_by=#{operatorId}, + i.updated_by_name=#{operatorName}, i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1 + WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL + AND (#{excludedItemId} IS NULL OR i.id <> #{excludedItemId}) + AND NOT (i.customer_remark <=> #{customerRemark} + AND i.internal_remark <=> #{internalRemark} + AND i.warehouse_remark <=> #{warehouseRemark} + AND i.customer_service_remark <=> #{customerServiceRemark}) + """) + int syncMutableOrderRemarks(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("customerRemark") String customerRemark, + @Param("internalRemark") String internalRemark, + @Param("warehouseRemark") String warehouseRemark, + @Param("customerServiceRemark") String customerServiceRemark, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName, + @Param("excludedItemId") Long excludedItemId); + + @Select(""" + SELECT DISTINCT i.loading_plan_id + FROM fl_loading_plan_item i + JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING') + WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL + AND NOT (i.customer_remark <=> #{customerRemark}) + """) + List findMutablePlanIdsWithDifferentCustomerRemark(@Param("tenantId") long tenantId, + @Param("orderId") long orderId, + @Param("customerRemark") String customerRemark); + + @Update(""" + UPDATE fl_loading_plan_item i + JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status IN ('EDITING','LOADING') + SET i.customer_remark=#{customerRemark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName}, + i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1 + WHERE i.account_id=#{tenantId} AND i.order_id=#{orderId} AND i.deleted=0 AND i.frozen_at IS NULL + AND NOT (i.customer_remark <=> #{customerRemark}) + """) + int syncMutableOrderCustomerRemark(@Param("tenantId") long tenantId, @Param("orderId") long orderId, + @Param("customerRemark") String customerRemark, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java index 59d07e48f..0e1e60f07 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanMapper.java @@ -1,5 +1,5 @@ package com.baiyue.firstleg.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanEntity; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface LoadingPlanMapper extends BaseMapper { @Update(""" UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}, @@ -13,4 +13,16 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl WHERE id=#{planId} AND plan_status='EDITING' """) int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Update(""" + + """) + int touchForOrderRemarks(@Param("planIds") List planIds, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 616b98671..5e4ea1a51 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -39,7 +39,7 @@ public final class OceanBookingDtos { } } - public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId, + public record LinkCandidate(Long oceanBookingId, String bookingNo, String billOfLadingNo, Long requirementId, String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, int requiredQuantity, long allocatedQuantity, long remainingQuantity, String customerName, String warehouseName, String channelName) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 479ee3d79..ddd83f869 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -55,7 +55,7 @@ public interface OceanBookingMapper extends BaseMapper { @Select(""" ") + List findOrderWeights(@Param("customerId") String customerId, + @Param("orderIds") List orderIds); + @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}") @@ -126,7 +139,7 @@ public interface InboundPlanOrderMapper { @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 " + + "WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} " + "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, 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 8409e4e1d..f428897ce 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 o.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND p.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,11 +38,15 @@ public interface InboundPlanQueryMapper { @Select("") List balances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query, @Param("offset") long offset, @Param("limit") long limit); - @Select("") + @Select("") long countBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query); + @Select("") + List sourceBalances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + @Select("") + List findOrderWeights(@Param("orderIds") List orderIds); + String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java index a3e5b145f..d931abeb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java @@ -2,9 +2,27 @@ package com.baiyue.lastleg.model; /** Standard main-process statuses for a last-leg order. */ public enum LastlegOrderStatus { + DRAFT("草稿"), + PENDING_REVIEW("待审核"), + APPROVED("审核通过"), + DESTINATION_ASSIGNED("已分配目的仓"), + INBOUND_PLANNED("已生成入库计划"), + INBOUND_MEASURING("入库测量中"), PENDING_ASSIGNMENT("待分配"), PENDING_INBOUND("待入库"), INBOUND_COMPLETED("入库完成"), + WAIT_RELABEL("待换标"), + WAIT_FBA_DESTINATION("待确认目的仓"), + READY_FOR_DISPATCH("待创建出库单"), + WAIT_OUTBOUND("待出库"), + ALLOCATED("已分配库存"), + OUTBOUNDED("已出库"), + PARTIALLY_RECEIVED("部分收货"), + RECEIVED("已收货"), + IN_TRANSIT("运输中"), + PROCESSING("处理中"), + COMPLETED("已完成"), + CANCELLED("已取消"), PENDING_DELIVERY("待派送"), DELIVERING("派送中"), DELIVERED("派送完成"); @@ -18,4 +36,13 @@ public enum LastlegOrderStatus { public String label() { return label; } + + public static String labelOf(String status) { + if (status == null || status.isBlank()) return status; + try { + return valueOf(status).label(); + } catch (IllegalArgumentException ignored) { + return status; + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java index 069b72eb2..74886139e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -28,6 +28,13 @@ public class PdaPalletOperationController { return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); } + @PostMapping("/relabel-sources") + public ApiResponse> relabelSources( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.RelabelSources request) { + return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request)); + } + @PostMapping("/relabel-pallet-label") public ApiResponse createRelabelPallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java index 2a2922edd..3194e89d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -13,6 +13,7 @@ public final class PalletOperationRequests { private PalletOperationRequests() { } public record Scan(@NotBlank @Size(max = 128) String code) { } + public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { } public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo, @NotBlank @Size(max = 128) String idempotencyKey) { } public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, @@ -33,13 +34,19 @@ public final class PalletOperationRequests { @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, @Size(max = 500) String reason) { } - /** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */ + /** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */ public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey, @NotBlank @Size(max = 64) String relabelNo, @Valid PalletExpectation source, @Valid PalletExpectation target, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks, - @Size(max = 500) String reason) { } + @Size(max = 500) String reason, + @Valid MoveLine actualSource) { + public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source, + PalletExpectation target, List cartonMarks, String reason) { + this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null); + } + } public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, @Valid PalletExpectation source, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java index 462c13fab..1017dd630 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -5,6 +5,9 @@ import java.util.List; public final class PalletOperationResponses { private PalletOperationResponses() { } + public record RelabelSource(long positionId, long positionVersion, String palletCode, + String locationCode, String inboundCode, long stockPackages, + long movablePackages, List cartonMarks) { } public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, String inboundCode, String skuCode, String productName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index f28f4c05a..d53b0c2fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, @Param("idempotencyKey") String idempotencyKey); + @Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " + + "AND b.id=#{balanceId} AND p.pallet_id>0 " + + "AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id") + List relabelSources(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("balanceId") long balanceId); + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + "ORDER BY b.customer_id,b.inbound_code,p.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index d399282b4..ccbe09d05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -78,13 +78,42 @@ public class PdaPalletOperationService { return content(identity.overseasWarehouseId(), pallet); } + public List relabelSources(PdaWarehouseIdentity identity, + PalletOperationRequests.RelabelSources request) { + RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo()); + requireRelabelWork(identity, app); + Set boundMarks = boundRelabelMarks(app); + List items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())); + Map> byBalance = new LinkedHashMap<>(); + for (RelabelItem item : items) { + InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId()); + if (anchor == null || !Objects.equals(anchor.getCustomerId(), app.getCustomerId()) + || !Objects.equals(anchor.getOverseasWarehouseId(), app.getOverseasWarehouseId())) + throw conflict("换标单库存来源缺失,请核对原入仓单"); + byBalance.computeIfAbsent(anchor.getBalanceId(), ignored -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + byBalance.forEach((balanceId, group) -> { + for (PalletInventoryRow row : mapper.relabelSources(app.getCustomerId(), app.getOverseasWarehouseId(), balanceId)) { + long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId())) + .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()), + row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(), + value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + + value(row.getPickedPackages()), + value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())), + group.stream().map(RelabelItem::getCartonMark).filter(mark -> !boundMarks.contains(mark)).toList())); + } + }); + return result; + } + public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity, PalletOperationRequests.NewRelabelPallet request) { String relabelNo = required(request.relabelNo(), "换标单号不能为空"); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); - if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { - throw conflict("换标单尚未完成客户确认,不能生成新托盘"); - } + requireRelabelWork(identity, application); if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } @@ -153,9 +182,7 @@ public class PdaPalletOperationService { if (replay != null) return replay; String relabelNo = required(request.relabelNo(), "换标单号不能为空"); RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo); - if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) { - throw conflict("换标单尚未完成客户确认,不能执行拆托"); - } + requireRelabelWork(identity, application); if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { throw conflict("换标单不属于 Token 冻结的海外仓"); } @@ -177,29 +204,61 @@ public class PdaPalletOperationService { } Map packagesByPosition = new LinkedHashMap<>(); Map> itemsByPosition = new LinkedHashMap<>(); + Map anchors = new LinkedHashMap<>(); + InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId()); + if (request.actualSource() != null && (actual == null + || !Objects.equals(actual.getCustomerId(), application.getCustomerId()) + || !Objects.equals(actual.getPalletId(), source.palletId()) + || !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId()) + || value(actual.getVersion()) != request.actualSource().expectedPositionVersion())) + throw conflict("实际来源托盘库存已变化,请刷新后确认"); + if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank()) + throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源"); + if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains)) + throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱"); + if ("PROCESSING".equals(application.getStatus())) { + var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId()); + for (RelabelItem item : selected) { + Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId()) + .eq("item_id", item.getId()).eq("result", "RELABELLED")); + if (completed == null || completed < value(item.getNotifiedPackageCount())) + throw conflict("请先完成本箱全部换标作业,再进行组托"); + } + } for (RelabelItem item : selected) { if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null || item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照"); InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId()); if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId()) - || !Objects.equals(position.getPalletId(), source.palletId()) + || (actual == null && !Objects.equals(position.getPalletId(), source.palletId())) + || (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId()) + || !Objects.equals(position.getBatchNo(), actual.getBatchNo()))) || !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) { - throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致"); + throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次"); } - packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum); - itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item); + anchors.put(position.getId(), position); + Long sourcePosition = actual == null ? position.getId() : actual.getId(); + packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum); + itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item); } + if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId())) + throw conflict("拆出箱数必须等于本次确认绑定的换标箱数"); List moves = packagesByPosition.entrySet().stream().map(entry -> { InventoryPosition position = positions.selectById(entry.getKey()); - if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足"); + long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId())) + .mapToLong(item -> value(item.getNotifiedPackageCount())).sum(); + if (actual == null ? value(position.getHoldPackages()) < entry.getValue() + : value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own) + throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存"); return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD); }).toList(); - requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false); Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(), - new RelabelMutation(application, itemsByPosition)); + new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors)); String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank() ? "" : ":" + request.reason().trim()); return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request, @@ -288,6 +347,10 @@ public class PdaPalletOperationService { throw conflict("库存所在托盘已变化,请重新扫码"); } if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); + if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) { + reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key); + locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId()); + } if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages()); else requireMovable(locked, move.packages()); customers.add(locked.getCustomerId()); @@ -296,6 +359,8 @@ public class PdaPalletOperationService { } if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems( plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets); + if (plan.relabelMutation() != null) + recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets); for (PalletChange change : plan.changes()) { long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); @@ -320,7 +385,8 @@ public class PdaPalletOperationService { private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); - if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus()) + if (locked != null) requireUnallocatedRelabel(locked); + if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus()) || !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId()) || !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId)) || !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) { @@ -328,6 +394,52 @@ public class PdaPalletOperationService { } } + /** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */ + private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation, + RelabelMutation mutation, PlannedMove move, String key) { + requireRelabelWork(identity, mutation.application()); + List selected = mutation.itemsByPosition().get(move.positionId()); + Set alreadyBound = boundRelabelMarks(mutation.application()); + if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark()))) + throw conflict("换标箱已在本轮组托,请刷新后重试"); + requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode())); + Map release = new LinkedHashMap<>(); + for (RelabelItem item : selected) { + RelabelItem current = relabelItems.selectById(item.getId()); + if (current == null || !Objects.equals(current.getSourceInventoryPositionId(), item.getSourceInventoryPositionId()) + || !Objects.equals(current.getNotifiedPackageCount(), item.getNotifiedPackageCount())) + throw conflict("换标箱的绑定已变化,请刷新后重试"); + if (!Objects.equals(item.getSourceInventoryPositionId(), move.positionId())) + release.merge(item.getSourceInventoryPositionId(), (long) value(item.getNotifiedPackageCount()), Long::sum); + } + InventoryPosition actual = positions.selectById(move.positionId()); + long required = release.values().stream().mapToLong(Long::longValue).sum(); + if (value(actual.getAvailablePackages()) < required) throw conflict("实际来源托盘可用箱数不足"); + InventoryBalance balance = balances.selectById(actual.getBalanceId()); + for (var entry : release.entrySet()) { + PalletInventoryRow locked = mapper.lockPosition(mutation.application().getOverseasWarehouseId(), entry.getKey()); + InventoryPosition snapshot = mutation.anchors().get(entry.getKey()); + if (locked == null || value(locked.getVersion()) != value(snapshot.getVersion()) + || !Objects.equals(locked.getBalanceId(), actual.getBalanceId()) + || value(locked.getHoldPackages()) < entry.getValue()) + throw conflict("原冻结库存已变化,请刷新后重试"); + InventoryPosition anchor = positions.selectById(entry.getKey()); + adjustRelabelHold(identity, operation, balance, anchor, -entry.getValue(), key + ":RELEASE:" + anchor.getId()); + } + if (required > 0) adjustRelabelHold(identity, operation, balance, actual, required, key + ":FREEZE:" + actual.getId()); + } + + private void adjustRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation, + InventoryBalance balance, InventoryPosition position, long delta, String key) { + long before = value(position.getHoldPackages()); + position.setHoldPackages(before + delta); + position.setAvailablePackages(value(position.getAvailablePackages()) - delta); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + ledger(identity, operation, balance, position, key, before, delta, before + delta, + position.getPalletNo(), position.getPalletNo(), "现场按原入仓单确认拆托来源:冻结桶调整,可用桶反向调整,实物总数不变"); + } + private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { InventoryPosition source = positions.selectById(sourceRow.getPositionId()); @@ -458,6 +570,89 @@ public class PdaPalletOperationService { if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); } + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks; + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts; + @jakarta.annotation.Resource + private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory; + + private Set boundRelabelMarks(RelabelApplication app) { + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) return Set.of(); + String round = Objects.toString(task.getReviewedAt(), "INITIAL"); + Set marks = new HashSet<>(); + var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("customer_id", app.getCustomerId()).eq("task_id", task.getId()).eq("action", "PALLET_BIND")); + for (var record : records) { + try { + var data = objectMapper.readTree(record.getDetailJson()); + if (round.equals(data.path("round").asText())) data.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); + } catch (JsonProcessingException failure) { throw conflict("换标组托记录无法读取,请核对作业历史"); } + } + return marks; + } + + private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation, + Map targets) { + var app = mutation.application(); + var task = relabelWorkTasks.find(app.getCustomerId(), app.getId()); + if (task == null) throw conflict("换标作业任务不存在,不能记录组托"); + var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId()); + record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND"); + record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus()); + record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName()); + record.setCreatedAt(LocalDateTime.now()); + record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"), + "cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(), + "targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList()))); + relabelWorkHistory.insert(record); + } + + private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) { + if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus())) + throw conflict("当前换标状态不能组托"); + if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) + throw conflict("换标单不属于当前海外仓"); + requireUnallocatedRelabel(application); + if ("PROCESSING".equals(application.getStatus())) { + var task = relabelWorkTasks.find(application.getCustomerId(), application.getId()); + if (task == null || !"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey())) + throw conflict("请先认领当前换标任务"); + } + } + + private void requireUnallocatedRelabel(RelabelApplication application) { + String fulfillment = application.getFulfillmentStatus(); + if (org.springframework.util.StringUtils.hasText(application.getOutboundOrderNo()) + || (org.springframework.util.StringUtils.hasText(fulfillment) + && !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(fulfillment))) + throw conflict("换标单已进入排车或出库流程,请先解除占用后再改绑托盘"); + } + + private void requireRelabelDestination(long warehouseId, List selected, + PalletPhysicalGateway.PalletReference target) { + Set destinations = new HashSet<>(); + selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode()))); + if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托"); + for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) { + var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId())); + long held = 0; + for (RelabelItem item : bound) { + var app = relabelApplications.selectById(item.getApplicationId()); + if (app != null && Set.of("PROCESSING", "CUSTOMER_CONFIRM_PENDING", "READY_FOR_OUTBOUND").contains(app.getStatus())) { + held += value(item.getNotifiedPackageCount()); destinations.add(text(item.getDestinationFbaWarehouseCode())); + } + } + if (value(row.getAvailablePackages()) > 0 || value(row.getAllocatedPackages()) > 0 + || value(row.getPickedPackages()) > 0 || value(row.getHoldPackages()) > held) + destinations.add(text(row.getDestinationWarehouseCode())); + } + if (destinations.size() != 1) throw conflict("目标托盘内货物与本次换标的新目的仓不同,不能合托"); + } + private Map resolvePallets(long warehouseId, PalletOperationRequests.PalletExpectation first, List rest) { List values = new ArrayList<>(); values.add(first); values.addAll(rest); @@ -658,6 +853,7 @@ public class PdaPalletOperationService { List moves, List changes, RelabelMutation relabelMutation) { } private record RelabelMutation(RelabelApplication application, - Map> itemsByPosition) { } + Map> itemsByPosition, + Map anchors) { } private enum InventoryBucket { AVAILABLE, HOLD } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java index e4a2c254a..e674063fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -22,11 +22,11 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") -@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT + "')") public class MerchantOverseasProcurementController { @Resource private OverseasProcurementService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')") public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody OverseasProcurementRequests.Create request) { @@ -34,12 +34,14 @@ public class MerchantOverseasProcurementController { } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.MerchantQuery query) { return ApiResponse.success(service.page(customer(user), query.scoped())); } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { return ApiResponse.success(service.detail(customer(user), orderNo)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 8c15edf5f..56263ab90 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +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; @@ -24,6 +26,7 @@ import java.util.List; public class MerchantRelabelApplicationController { @Resource private RelabelApplicationService service; @GetMapping("/inventory-candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String operationWarehouseId, @@ -31,6 +34,7 @@ public class MerchantRelabelApplicationController { return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode)); } @GetMapping("/historical-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> historicalOrders( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String operationWarehouseId, @@ -38,20 +42,25 @@ public class MerchantRelabelApplicationController { return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword)); } @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); } @PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") public ApiResponse uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file)); } @PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')") public ApiResponse uploadFile(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId, @RequestPart("file") MultipartFile file) { @@ -60,18 +69,21 @@ public class MerchantRelabelApplicationController { return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind)); } @GetMapping("/{relabelNo}/shipping-label/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ResponseEntity downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) { var file = service.downloadShippingLabel(customer(user), relabelNo, fileId); return download(file.content(), file.fileName(), file.contentType()); } @GetMapping("/{relabelNo}/completion-photos/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ResponseEntity downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @PathVariable long fileId) { var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId); return download(file.content(), file.fileName(), file.contentType()); } @PostMapping("/{relabelNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CONFIRM + "')") public ApiResponse confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) { return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 88a51cd6f..c3cab46c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -36,6 +36,16 @@ public class RelabelApplicationController { return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request)); } + @PostMapping("/{relabelNo}/confirm-completion") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + public ApiResponse confirmCompletion( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.CustomerConfirm request) { + requirePlatform(user); + String customerId = service.customerId(relabelNo); + return ApiResponse.success(service.confirmCompletion(customerId, relabelNo, operator(user), request)); + } + @GetMapping("/{relabelNo}/review-task") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") public ApiResponse reviewTask( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index d0d73eacc..f43b27f0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -39,9 +39,9 @@ public final class RelabelRequests { } public record Item( - @NotNull Long sourceInventoryBalanceId, - @NotNull Long sourceInventoryPositionId, - @NotNull Long sourceInboundOrderId, + Long sourceInventoryBalanceId, + Long sourceInventoryPositionId, + Long sourceInboundOrderId, @Size(max = 128) String skuCode, @Size(max = 128) String productImageFileId, @Size(max = 255) String productImageFileName, @@ -61,8 +61,22 @@ public final class RelabelRequests { @Size(max = 255) String fbaCartonMark, @Size(max = 128) String destinationFbaWarehouseCode, @Size(max = 32) String operationType, - @Size(max = 100) List<@Valid SkuItem> skuItems + @Size(max = 100) List<@Valid SkuItem> skuItems, + Long returnCartonId ) { + public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, + String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, + String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, String remark, + String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark, + String fbaCartonMark, String destinationFbaWarehouseCode, String operationType, + List skuItems) { + this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode, + productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, + notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, + relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, + destinationFbaWarehouseCode, operationType, skuItems, null); + } /** 保持既有箱唛换标调用方的二进制参数形态。 */ public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, @@ -74,7 +88,7 @@ public final class RelabelRequests { productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark, - destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null); } public Item(String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -85,7 +99,7 @@ public final class RelabelRequests { this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl, - cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of()); + cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null); } public Item(String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, @@ -94,7 +108,7 @@ public final class RelabelRequests { this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8", - "CARTON_LABEL", List.of()); + "CARTON_LABEL", List.of(), null); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 66f7fff17..8e6052f4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -36,7 +36,9 @@ public final class RelabelResponses { String inboundCode, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String destinationFbaWarehouseCode, String destinationFbaWarehouseName, String skuCode, String productNameZh, - long availablePackages, long version, List positions) {} + long availablePackages, long version, List positions, + String sourceType, Long returnCartonId, String cartonMark, + List skuItems) {} public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java index 66c40bd81..ede1a60ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java @@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa @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.clients.file.url:http://127.0.0.1:9206}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 1e9b91c39..871f82830 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE") + Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("balanceId") Long balanceId); + + @org.apache.ibatis.annotations.Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " + + "WHERE i.customer_id=#{customerId} AND a.customer_id=#{customerId} AND i.source_inventory_balance_id=#{balanceId} " + + "AND UPPER(TRIM(i.carton_mark))=UPPER(TRIM(#{mark})) AND a.status NOT IN ('CANCELLED','REJECTED') LIMIT 1") + String findExistingCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("balanceId") Long balanceId, + @org.apache.ibatis.annotations.Param("mark") String mark); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java new file mode 100644 index 000000000..f05f065cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.model; + +import java.util.Arrays; + +/** 换标申请业务状态。 */ +public enum RelabelApplicationStatus { + PENDING_PROCESSING("待处理"), + PROCESSING("处理中"), + CUSTOMER_CONFIRM_PENDING("待客户确认"), + READY_FOR_OUTBOUND("换标完成,待出库"), + COMPLETED("已完成"), + REJECTED("已拒绝"), + CANCELLED("已取消"); + + private final String label; + + RelabelApplicationStatus(String label) { this.label = label; } + + public String code() { return name(); } + public String label() { return label; } + + public static RelabelApplicationStatus fromCode(String code) { + return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported relabel application status: " + code)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java index 7bc4d4595..34011a428 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -70,6 +70,14 @@ public class PdaRelabelController { identity.overseasWarehouseId(), relabelNo, request)); } + @PostMapping("/{relabelNo}/skus/scan") + public ApiResponse scanSku(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.SkuScan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scanSku(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + @GetMapping("/{relabelNo}/documents") public ApiResponse> documents(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java index e402ee3fb..c28d56bbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -14,6 +14,10 @@ public final class PdaRelabelRequests { private PdaRelabelRequests() { } public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex, + @NotBlank @Size(max = 255) String oldSku, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonScan(@NotBlank @Size(max = 255) String cartonMark, @NotNull @Min(0) Integer expectedVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java index 92b660d53..e00d48949 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -26,7 +26,8 @@ public final class PdaRelabelResponses { String cartonMark, String fbaCartonMark, String operationType, List skuItems, int expectedPackages, int relabelledPackages, int skippedPackages, - int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { } + int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages, + String destinationFbaWarehouseCode) { } public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { } public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java index 66f504d4b..93c912595 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -15,8 +15,18 @@ public interface PdaRelabelTaskMapper extends BaseMapper { @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); - @Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE") - PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey); + @Select(""" + SELECT task.* + FROM ll_relabel_pda_task task + JOIN ll_relabel_application application + ON application.id=task.application_id AND application.customer_id=task.customer_id + WHERE task.operator_key=#{operatorKey} + AND task.status='PROCESSING' + AND application.status='PROCESSING' + ORDER BY task.claimed_at ASC + LIMIT 1 FOR UPDATE + """) + PdaRelabelTask lockProcessingByOperator(@Param("operatorKey") String operatorKey); @Update(""" UPDATE ll_relabel_pda_task diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java index a4a5c9f73..b6a15ee2e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -51,6 +51,7 @@ import java.util.stream.Collectors; @Service public class PdaRelabelService { + @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard; public static final String PROCESSING = "PROCESSING"; public static final String WAIT_REVIEW = "WAIT_REVIEW"; public static final String COMPLETED = "COMPLETED"; @@ -109,7 +110,7 @@ public class PdaRelabelService { throw conflict("Relabel application is not executable"); } PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); - PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey); + PdaRelabelTask activeTask = tasks.lockProcessingByOperator(operatorKey); if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) { throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单"); } @@ -194,6 +195,49 @@ public class PdaRelabelService { public record Document(String scope, String fileId, String fileName, Long itemId) { } + @Transactional + public PdaRelabelResponses.Detail scanSku(String operatorKey, String operatorName, long warehouseId, + String relabelNo, PdaRelabelRequests.SkuScan request) { + UnitCommand command = new UnitCommand(relabelNo, "SCAN_SKU", request); + var replayed = replay(operatorKey, warehouseId, relabelNo, "SCAN_SKU", request.idempotencyKey(), command); + if (replayed != null) return replayed; + var app = requireApplication(relabelNo, warehouseId, true); + var task = requireOwnedTask(app, operatorKey, true); + requireVersion(task, request.expectedVersion()); + var item = requireItem(app, request.itemId()); + if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务"); + if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱"); + var skus = readSkuItems(item.getSkuRelabelItemsJson()); + if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在"); + var sku = skus.get(request.skuIndex()); + if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0) + throw conflict("旧SKU或绑定的新标签、数量不正确"); + if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId())) + || mixedStageDone(task, item.getId(), "SKU")) throw conflict("本箱SKU作业已完成"); + Set done = new java.util.HashSet<>(); + for (var history : histories.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, app.getCustomerId()) + .eq(PdaRelabelHistory::getAction, "SKU_SCAN"))) { + if (!"SKU_SCAN".equals(history.getAction())) continue; + try { + var data = objectMapper.readTree(history.getDetailJson()); + if (mixedRound(task).equals(data.path("round").asText()) && item.getId().toString().equals(data.path("itemId").asText())) + done.add(data.path("skuIndex").asInt(-1)); + } catch (Exception failure) { throw conflict("SKU扫描历史无法读取"); } + } + if (!done.add(request.skuIndex())) throw conflict("该SKU明细已处理,请勿重复扫描"); + insertHistory(task, "SKU_SCAN", PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("round", mixedRound(task), "itemId", item.getId().toString(), "skuIndex", request.skuIndex(), + "oldSku", sku.skuCode(), "newSku", sku.newSkuCode(), "quantity", sku.quantity())); + if (done.size() == skus.size()) return unitAction(operatorKey, operatorName, warehouseId, relabelNo, + "SCAN_SKU", request.idempotencyKey(), request, item.getId(), item.getCartonMark(), null, + "RELABELLED", null, null, request.expectedVersion(), ignored -> { }); + updateTask(task, request.expectedVersion()); + var result = detail(app, task); + saveOperation(task, operatorKey, "SCAN_SKU", request.idempotencyKey(), command, result); + return result; + } + @Transactional(readOnly = true) public List documents(long warehouseId, String relabelNo) { RelabelApplication app = requireApplication(relabelNo, warehouseId, false); @@ -264,6 +308,7 @@ public class PdaRelabelService { RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); requireVersion(task, request.expectedVersion()); + if (!request.manualCompletion()) bindingGuard.requireBound(application); List detailItems = itemList(application); List facts = factList(task); boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream() @@ -558,7 +603,7 @@ public class PdaRelabelService { private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, String operatorKey) { return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), - application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task), operationType(application), value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), @@ -580,7 +625,8 @@ public class PdaRelabelService { value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), count(itemFacts, "EXCEPTION"), count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0), - count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0)); + count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0), + item.getDestinationFbaWarehouseCode()); }).toList(); List factResponses = facts.stream().map(fact -> new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), @@ -593,7 +639,7 @@ public class PdaRelabelService { history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), - application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(), + application.getStatus(), operationType(application), effectiveTaskStatus(application, task), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), @@ -605,6 +651,13 @@ public class PdaRelabelService { itemResponses, factResponses, historyResponses); } + private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) { + if (task == null) return "AVAILABLE"; + if (RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus()) + && PROCESSING.equals(task.getStatus())) return WAIT_REVIEW; + return task.getStatus(); + } + private void insertHistory(PdaRelabelTask task, String action, String from, String to, String operatorKey, String operatorName, Object detail) { PdaRelabelHistory history = new PdaRelabelHistory(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 6bf25b776..0aa38d523 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -29,6 +29,10 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; @@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private InventoryCommandService inventoryCommands; @Resource private InventoryBalanceMapper inventoryBalances; @Resource private InventoryPositionMapper inventoryPositions; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnInventoryCartonItemMapper returnCartonItems; @Resource private RelabelDocumentFileGateway documentFiles; @Resource private PdaRelabelTaskMapper pdaTasks; @Resource private RelabelPhotoFileGateway photoFiles; @@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler { .gt(InventoryBalance::getAvailablePackages, 0) .like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode)) .orderByDesc(InventoryBalance::getUpdatedAt)); - return balances.stream().map(balance -> { + List result = new java.util.ArrayList<>(balances.stream().map(balance -> { List positions = inventoryPositions.listBalance(customerId, balance.getId()) .stream().filter(position -> value(position.getAvailablePackages()) > 0) .map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(), @@ -249,8 +255,27 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), - balance.getProductNameZh(), available, value(balance.getVersion()), positions); - }).filter(candidate -> candidate.availablePackages() > 0).toList(); + balance.getProductNameZh(), available, value(balance.getVersion()), positions, + "INBOUND", null, null, List.of()); + }).filter(candidate -> candidate.availablePackages() > 0).toList()); + for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + String keyword = trim(inboundCode); + if (StringUtils.hasText(keyword) + && !containsIgnoreCase(carton.getCartonMark(), keyword) + && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue; + List cartonSkus = returnCartonItems.listCarton(carton.getId()).stream() + .filter(item -> value(item.getQuantity()) > 0) + .map(item -> new RelabelResponses.SkuItem(item.getSkuCode(), null, + Math.toIntExact(item.getQuantity()), null)).toList(); + if (cartonSkus.isEmpty()) continue; + String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) + .collect(Collectors.joining(",")); + result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), + null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), + carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(), + "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus)); + } + return result; } @Transactional(readOnly = true) @@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler { } @Transactional public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + return create(customerId, operatorId, request, null); + } + + @Transactional + public RelabelResponses.Detail createForExistingTailOrder(String customerId, String operatorId, + Long existingTailOrderId, + RelabelRequests.Create request) { + if (existingTailOrderId == null) throw badRequest("Existing tail order is required"); + return create(customerId, operatorId, request, existingTailOrderId); + } + + private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, + Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); String deliveryMethod = trim(request.deliveryMethod()); @@ -298,6 +336,13 @@ public class RelabelApplicationService implements WalletResultHandler { if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); return detail(customerId, replay.getRelabelNo()); } + Set requestedDestinations = request.items().stream() + .filter(item -> !"SKU_LABEL".equals(operationType(item))) + .map(RelabelRequests.Item::destinationFbaWarehouseCode) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); + if (requestedDestinations.size() > 1) { + throw badRequest("同一张贴标申请只能选择一个 FBA 目的仓"); + } int total = 0; int notified = 0; List sources = new java.util.ArrayList<>(); @@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler { if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) { throw badRequest("The same carton mark cannot create more than one relabel task"); } + if (item.returnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId()); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) { + throw conflict("所选退货箱不属于当前客户和操作仓"); + } + if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { + throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); + } + if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) { + throw conflict("退货箱仓库唛头已变化,请重新查询"); + } + sources.add(new SourceInventory(item, null, carton)); + continue; + } + if (item.sourceInventoryBalanceId() == null || item.sourceInventoryPositionId() == null + || item.sourceInboundOrderId() == null) { + throw badRequest("正常入仓库存必须提供余额、仓位和入仓单"); + } InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId()); if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) { throw conflict("Selected inventory position is no longer available"); @@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler { if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) { throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo()); } - sources.add(new SourceInventory(item, balance)); + sources.add(new SourceInventory(item, balance, null)); } LocalDateTime now = LocalDateTime.now(); long applicationId = IdWorker.getId(); @@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler { boolean requiresWallet = chargeAmount.signum() > 0; int holdIndex = 0; for (SourceInventory source : sources) { + if (source.returnCarton() != null) continue; inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, "贴标申请冻结原入仓单库存")); } - LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo, - operationWarehouseId, total, now); + LastlegOrder tailOrder = existingTailOrderId == null + ? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now) + : linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now); List tailOrders = List.of(tailOrder); RelabelApplication application = new RelabelApplication(); @@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler { application.setUpdatedAt(now); applications.insert(application); + for (SourceInventory source : sources) { + if (source.returnCarton() == null) continue; + ReturnInventoryCarton carton = source.returnCarton(); + carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo); + carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); + returnCartons.updateById(carton); + } + if (requiresWallet) { String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, chargeAmount, "换标订单费用冻结", 1L); @@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler { insertItem(customerId, applicationId, source, now); } insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); - tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, - order.getCurrentStatus(), "Relabel application submitted", now)); + if (existingTailOrderId == null) { + tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null, + order.getCurrentStatus(), "Relabel application submitted", now)); + } return detail(customerId, relabelNo); } @@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler { } for (LastlegOrder order : linkedOrders) { String orderBefore = order.getCurrentStatus(); - String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType()) + ? "WAIT_RELABEL" : orderBefore; String orderStatus = switch (next) { case CUSTOMER_CONFIRM_PENDING -> mainStatus; case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) @@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler { PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId()); if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus())) throw conflict("仓库尚未提交换标完工复核"); - reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED"); - reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); - reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); - reviewTask.setUpdatedAt(LocalDateTime.now()); - if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); if (Boolean.TRUE.equals(application.getPrintShippingLabel()) && !StringUtils.hasText(application.getShippingLabelFileId())) { throw conflict("Upload the shipping label before confirming relabel completion"); } + List relabelItems = itemList(application.getId()); + boolean requiresOutbound = relabelItems.stream() + .anyMatch(item -> !"SKU_LABEL".equals(operationType(item))); + reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); + reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认"); + reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now()); + reviewTask.setUpdatedAt(LocalDateTime.now()); + if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新"); LocalDateTime now = LocalDateTime.now(); String before = application.getStatus(); - application.setStatus(READY_FOR_OUTBOUND); + application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED"); application.setCustomerConfirmationKey(key); application.setCustomerConfirmedAt(now); - application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod()) - ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"); - if (!"TRUCK".equals(application.getDeliveryMethod())) { + application.setFulfillmentStatus(requiresOutbound + ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") + : "NOT_REQUIRED"); + if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) { createDirectOutbound(application, operatorId, now); application.setFulfillmentStatus("OUTBOUND_CREATED"); } @@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler { applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { String orderBefore = order.getCurrentStatus(); - order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED"); order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setRelabelStatus("COMPLETED"); order.setUpdatedAt(now); @@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler { request.remark(), now); } } - insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId, + insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 if (warehouseTransfers != null) { WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { - transfer.setStatus("READY_FOR_DISPATCH"); + Set destinations = relabelItems.stream() + .map(RelabelItem::getDestinationFbaWarehouseCode) + .filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet()); + if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) { + transfer.setFbaWarehouseCode(destinations.iterator().next()); + } + String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode()) + ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); + application.setFulfillmentStatus(transferStatus); + applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { String transferOrderBefore = transferOrder.getCurrentStatus(); - transferOrder.setCurrentStatus("READY_FOR_DISPATCH"); + transferOrder.setCurrentStatus(transferStatus); + transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setRelabelStatus("COMPLETED"); - transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:" - + "COMPLETED"); + transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); transferOrder.setUpdatedAt(now); orders.updateById(transferOrder); if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) { @@ -683,27 +774,28 @@ public class RelabelApplicationService implements WalletResultHandler { outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); - outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count()); + outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); Map> byPosition = relabelItems.stream().collect(Collectors.groupingBy( - RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList())); + this::outboundSourceKey, LinkedHashMap::new, Collectors.toList())); for (Map.Entry> entry : byPosition.entrySet()) { - InventoryPosition position = inventoryPositions.selectById(entry.getKey()); - if (position == null || !application.getCustomerId().equals(position.getCustomerId())) { + InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null; + if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) { throw conflict("Relabel source inventory position no longer exists"); } List group = entry.getValue(); OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId()); - item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); + item.setWarehousePalletId(position == null ? null : position.getPalletId()); + item.setPalletNo(position == null ? null : position.getPalletNo()); item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); - item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group)); + item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group)); item.setCreatedAt(now); outboundItems.insert(item); } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); @@ -714,11 +806,14 @@ public class RelabelApplicationService implements WalletResultHandler { private String writeOutboundRefs(String relabelNo, Long positionId, List values) { try { - return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo, - "sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of( + Map refs = new LinkedHashMap<>(); + refs.put("sourceType", "RELABEL_ORDER"); refs.put("relabelNo", relabelNo); + if (positionId != null) refs.put("sourceInventoryPositionId", positionId); + refs.put("items", values.stream().map(item -> Map.of( "itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""), "destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""), - "packages", intValue(item.getNotifiedPackageCount()))).toList())); + "packages", intValue(item.getNotifiedPackageCount()))).toList()); + return objectMapper.writeValueAsString(refs); } catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); } } @@ -740,14 +835,21 @@ public class RelabelApplicationService implements WalletResultHandler { item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode())); item.setOperationType(operationType(source)); item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems())); - item.setSourceInventoryBalanceId(balance.getId()); - item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); - item.setSourceInboundOrderId(balance.getInboundOrderId()); - item.setSourceInboundOrderNo(balance.getInboundOrderNo()); - item.setSourceInboundCode(balance.getInboundCode()); - item.setSourceWarehouseId(balance.getOverseasWarehouseId()); - item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); - item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + if (balance != null) { + item.setSourceInventoryBalanceId(balance.getId()); + item.setSourceInventoryPositionId(source.sourceInventoryPositionId()); + item.setSourceInboundOrderId(balance.getInboundOrderId()); + item.setSourceInboundOrderNo(balance.getInboundOrderNo()); + item.setSourceInboundCode(balance.getInboundCode()); + item.setSourceWarehouseId(balance.getOverseasWarehouseId()); + item.setSourceWarehouseCode(balance.getOverseasWarehouseCode()); + item.setSourceWarehouseName(balance.getOverseasWarehouseName()); + } else { + ReturnInventoryCarton carton = sourceInventory.returnCarton(); + item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); + item.setSourceInboundOrderNo(carton.getSourceReturnNo()); + item.setSourceWarehouseId(carton.getOverseasWarehouseId()); + } item.setCreatedAt(now); items.insert(item); } @@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler { order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); - order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL"); order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId); @@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler { return order; } + private LastlegOrder linkExistingTailOrder(String customerId, Long orderId, String relabelNo, + int packageCount, LocalDateTime now) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId())) { + throw conflict("Existing tail order does not belong to this customer"); + } + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING); + order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order); + return order; + } + private List tailOrders(RelabelApplication application) { List linked = orders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, application.getCustomerId()) @@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler { return legacy == null ? List.of() : List.of(legacy); } - private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {} + private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance, + ReturnInventoryCarton returnCarton) {} private static long value(Long value) { return value == null ? 0L : value; } private static int intValue(Integer value) { return value == null ? 0 : value; } + private static boolean containsIgnoreCase(String value, String fragment) { + return value != null && fragment != null + && value.toLowerCase(java.util.Locale.ROOT).contains(fragment.toLowerCase(java.util.Locale.ROOT)); + } private void releaseInventory(String customerId, String operatorId, RelabelApplication application) { for (RelabelItem item : itemList(application.getId())) { + if (item.getReturnCartonId() != null) { + ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton != null && customerId.equals(carton.getCustomerId()) + && Objects.equals(application.getId(), carton.getRelabelApplicationId()) + && "RELABEL_PENDING".equals(carton.getStatus())) { + carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); + } + continue; + } if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue; inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, @@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler { private static String businessRelabelStatus(String workflowStatus) { if ("PROCESSING".equals(workflowStatus)) return "PROCESSING"; - return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) + return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus) ? "COMPLETED" : PENDING_PROCESSING; } + + private long outboundSourceKey(RelabelItem item) { + if (item.getSourceInventoryPositionId() != null) return item.getSourceInventoryPositionId(); + if (item.getReturnCartonId() != null) return -item.getReturnCartonId(); + throw conflict("Relabel outbound item has no physical inventory source"); + } private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java new file mode 100644 index 000000000..63dc446ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** Requires saved physical bindings, never the original accounting freeze anchor. */ +@Service +public class RelabelBindingGuard { + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private InventoryPositionMapper positions; + @Resource private ObjectMapper objectMapper; + + public void requireBound(RelabelApplication app) { + List cartons = items.selectList(new QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())) + .stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList(); + if (cartons.isEmpty()) return; + Map> bindings = new HashMap<>(); + for (PdaRelabelHistory history : histories.selectList(new QueryWrapper() + .eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()) + .orderByAsc("created_at", "id"))) { + if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear(); + if (!"PALLET_BIND".equals(history.getAction())) continue; + try { + var data = objectMapper.readTree(history.getDetailJson()); + Set targets = new HashSet<>(); + data.path("targetPallets").forEach(value -> targets.add(value.asText())); + data.path("cartonMarks").forEach(value -> bindings.put(value.asText(), targets)); + } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } + } + for (RelabelItem item : cartons) { + var position = item.getSourceInventoryPositionId() == null ? null + : positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId()); + if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) + || position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount()) + throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库"); + } + } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java index b014751d4..d5628de57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java @@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests; import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses; import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +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; @@ -17,17 +19,22 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/merchant/v1/last-leg/return-orders") public class MerchantReturnOrderController { @Resource private ReturnReceiptService service; - @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } - @GetMapping public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } - @GetMapping("/{returnNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @GetMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); } - @GetMapping("/{returnNo}/relabel-candidates") public ApiResponse> relabelCandidates( + @GetMapping("/{returnNo}/relabel-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')") + public ApiResponse> relabelCandidates( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) { return ApiResponse.success(service.relabelCandidates(customer(user), returnNo)); } - @PostMapping("/{returnNo}/relabel") public ApiResponse createRelabel( + @PostMapping("/{returnNo}/relabel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')") + public ApiResponse createRelabel( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.RelabelCreate request) { return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 3d4bf6ae0..609d90308 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -45,9 +45,29 @@ public final class OverseasPermissions { public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list"; public static final String MERCHANT_RETURN_INVENTORY_LIST = "merchant:overseas-warehouse:return-inventory:list"; - public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement"; - public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking"; - public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping"; + public static final String MERCHANT_LAST_MILE_ORDER_LIST = + "merchant:overseas-warehouse:last-mile-order:list"; + public static final String MERCHANT_RELABEL_LIST = "merchant:overseas-warehouse:relabel:list"; + public static final String MERCHANT_RELABEL_CREATE = "merchant:overseas-warehouse:relabel:create"; + public static final String MERCHANT_RELABEL_FILES = "merchant:overseas-warehouse:relabel:files"; + public static final String MERCHANT_RELABEL_CONFIRM = "merchant:overseas-warehouse:relabel:confirm"; + public static final String MERCHANT_TRANSFER_LIST = "merchant:overseas-warehouse:transfer:list"; + public static final String MERCHANT_TRANSFER_CREATE = "merchant:overseas-warehouse:transfer:create"; + public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm"; + public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list"; + public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create"; + public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel"; + public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list"; + public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create"; + public static final String MERCHANT_RETURN_ORDER_RELABEL = "merchant:overseas-warehouse:return-order:relabel"; + public static final String MERCHANT_PROCUREMENT_LIST = "merchant:cross-border:procurement:list"; + public static final String MERCHANT_PROCUREMENT_CREATE = "merchant:cross-border:procurement:create"; + public static final String MERCHANT_TRUCKING_LIST = "merchant:cross-border:domestic-trucking:list"; + public static final String MERCHANT_TRUCKING_CREATE = "merchant:cross-border:domestic-trucking:create"; + public static final String MERCHANT_TRUCKING_EDIT = "merchant:cross-border:domestic-trucking:edit"; + public static final String MERCHANT_LOOSEBOARD_LIST = "merchant:cross-border:pallet-shipping:list"; + public static final String MERCHANT_LOOSEBOARD_CREATE = "merchant:cross-border:pallet-shipping:create"; + public static final String MERCHANT_LOOSEBOARD_EDIT = "merchant:cross-border:pallet-shipping:edit"; public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java index 4b71d885c..f85571490 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java @@ -15,6 +15,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -51,6 +52,7 @@ public class LastlegOrderCommandService { @Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory; @Resource private LastlegOrderOperationLogMapper operationLogs; @Resource private InboundOrderDetainedOutboxService detainedOutbox; + @Resource private InventoryCommandService inventory; @Resource private ObjectMapper objectMapper; @Transactional @@ -69,6 +71,9 @@ public class LastlegOrderCommandService { if (Boolean.TRUE.equals(order.getDetained()) == held) { throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态"); } + if (held && !"INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + throw conflict("货物完成入库后才能暂扣"); + } if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount()) || text(order.getOutboundOrderNo()) != null)) { throw conflict("尾程订单已进入出库流程,不能新增暂扣"); @@ -79,6 +84,13 @@ public class LastlegOrderCommandService { if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) { throw conflict("尾程订单版本已变化,请刷新后重试"); } + if (held) { + inventory.holdInboundOrderInventory(order.getCustomerId(), operatorId, orderId, + order.getOrderNo(), reason); + } else { + inventory.releaseInboundOrderHold(order.getCustomerId(), operatorId, orderId, + order.getOrderNo(), reason); + } int nextVersion = version + 1; LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse( orderId, held, chargeable, held ? reason : null, nextVersion); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 622a55aae..49812a1c3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -34,6 +34,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; import com.baiyue.lastleg.model.LastlegOrderType; +import com.baiyue.lastleg.model.LastlegOrderStatus; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import jakarta.annotation.Resource; @@ -47,8 +48,8 @@ import java.nio.charset.StandardCharsets; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.math.BigDecimal; import java.time.LocalDateTime; -import java.time.ZoneOffset; import java.security.MessageDigest; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -79,7 +80,6 @@ public class LastlegOrderService { @Resource private InboundPlanCartonMapper cartonMapper; @Resource private FbaWarehouseValidator fbaWarehouseValidator; @Resource private InventoryCommandService inventory; - @Resource private InboundOrderDetainedOutboxService detainedOutbox; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Resource private WarehouseResourceGateway warehouseResourceGateway; @@ -95,9 +95,11 @@ public class LastlegOrderService { Map> mergeRelations = palletMergeRelations(page.getRecords()); Map containerNodes = containerNodes(page.getRecords()); Map dispatchStatuses = dispatchStatuses(page.getRecords()); + Map weights = orderWeights(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) - ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"))).toList(), + ? containerNodes.get(order.getSourceContainerBookingId()) : null, + dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -350,7 +352,8 @@ public class LastlegOrderService { value.getNodeCode(), value.getNodeAt(), value.getSource())) .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), - containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED")), manualNodes, cargo, history.stream() + containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), + orderWeights(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -387,47 +390,6 @@ public class LastlegOrderService { return fee; } - @Transactional - public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, - String operatorName) { - setDetained(customerId, orderId, request, null, operatorName); - } - - @Transactional - public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, - String operatorId, String operatorName) { - LastlegOrder current = findOwned(customerId, orderId); - LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); - if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); - boolean detained = request.detained(); - if (Boolean.TRUE.equals(order.getDetained()) == detained) { - throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held"); - } - if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held"); - } - LocalDateTime now = LocalDateTime.now(); - order.setDetained(detained); - order.setUpdatedAt(now); - orderMapper.updateById(order); - if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { - inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim()); - } - LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); - operation.setId(IdWorker.getId()); - operation.setCustomerId(order.getCustomerId()); - operation.setOrderId(orderId); - operation.setOperationNode(detained ? "暂扣" : "解除暂扣"); - operation.setOperatorName(operatorName); - operation.setResult(request.remark().trim()); - operation.setCreatedAt(now); - operationLogMapper.insert(operation); - if (StringUtils.hasText(order.getSourceInboundId())) { - detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName, - now.toInstant(ZoneOffset.UTC), operation.getId()); - } - } - @Transactional public void setPalletMergeRequired(String customerId, long orderId, LastlegOrderRequests.SetPalletMergeRequired request, @@ -569,7 +531,7 @@ public class LastlegOrderService { public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output) throws IOException { - output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); + output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8)); final int batchSize = 1000; long page = 1; while (true) { @@ -584,11 +546,12 @@ public class LastlegOrderService { csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId()) - ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + ? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(yesOrNo(order.getAbnormal())), csv(yesOrNo(order.getDetained())), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()), + csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())), - csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), + csv(order.getPalletMergeFlag()), csv(LastlegOrderStatus.labelOf(order.getCurrentStatus())), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))) + "\r\n"; output.write(line.getBytes(StandardCharsets.UTF_8)); } @@ -666,8 +629,17 @@ public class LastlegOrderService { LastlegOrderMapper.DispatchStatusRow::dispatchStatus)); } + private Map orderWeights(List orders) { + if (orders.isEmpty()) return Map.of(); + List rows = orderMapper.findOrderWeights( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.OrderWeightRow::orderId, row -> row)); + } + private LastlegOrderSummary summary(LastlegOrder o, List relations, - String containerNode, String dispatchStatus) { + String containerNode, String dispatchStatus, + LastlegOrderMapper.OrderWeightRow weight) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -677,7 +649,9 @@ public class LastlegOrderService { o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode, o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", - o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), + o.getReceivedPackageCount(), packageDifference(o), weight == null ? BigDecimal.ZERO : weight.grossWeightKg(), + weight == null ? BigDecimal.ZERO : weight.netWeightKg(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), @@ -686,6 +660,11 @@ public class LastlegOrderService { o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } + private static Integer packageDifference(LastlegOrder order) { + if (order.getReceivedPackageCount() == null) return null; + return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0); + } + static String displayOrderNo(LastlegOrder order) { return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); @@ -723,6 +702,11 @@ public class LastlegOrderService { ? "COMPLETED" : "PENDING_PROCESSING"; } + private static String yesOrNo(Boolean value) { + if (value == null) return null; + return value ? "是" : "否"; + } + private static Long parseLong(String value) { try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; } catch (NumberFormatException ignored) { return null; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 63f93c8c4..56c1bb480 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -6,12 +6,22 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +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.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; + +import java.nio.charset.StandardCharsets; +import java.util.List; @RestController @RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers") @@ -19,24 +29,75 @@ public class MerchantWarehouseTransferController { @Resource private WarehouseTransferService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseTransferRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @PutMapping("/{transferNo}") + public ApiResponse updateDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request)); + } + + /** + * POST 兼容入口:部分本地网关只放行 POST,草稿保存不能因此阻断提交。 + * 与 PUT 更新草稿使用完全相同的服务逻辑和租户校验。 + */ + @PostMapping("/{transferNo}/save-draft") + public ApiResponse saveDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/submit") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.submit(customer(user), transferNo, operator(user))); + } + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseTransferRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @GetMapping("/{transferNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { return ApiResponse.success(service.detail(customer(user), transferNo)); } + @GetMapping("/{transferNo}/carton-label") + public ApiResponse cartonLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo) { + return ApiResponse.success(service.cartonLabel(customer(user), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files") + public ApiResponse> measurementPhotos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + return ApiResponse.success(service.measurementPhotos(customer(user), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download") + public ResponseEntity downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long fileId) { + var file = service.downloadMeasurementPhoto(customer(user), transferNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{transferNo}/confirm-measurement") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CONFIRM + "')") public ApiResponse confirmMeasurement( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java index 848f20c01..6bd39c1ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java @@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.ResponseEntity; +import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; import java.util.List; @RestController @@ -32,6 +41,42 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId())); } + @GetMapping("/{transferNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaDetail(identity.overseasWarehouseId(), transferNo)); + } + + @PostMapping(value = "/{transferNo}/measurement-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadMeasurementPhoto( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @RequestParam("file") MultipartFile file) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + long operatorUserId = user.userId() == null ? 0L : user.userId(); + return ApiResponse.success(service.uploadMeasurementPhoto(identity.operatorKey(), operatorUserId, + identity.overseasWarehouseId(), transferNo, file)); + } + + @GetMapping("/{transferNo}/measurement-photos/files") + public ApiResponse> measurementPhotos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.pdaMeasurementPhotos(identity.overseasWarehouseId(), transferNo)); + } + + @GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download") + public ResponseEntity downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long fileId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var file = service.downloadPdaMeasurementPhoto(identity.overseasWarehouseId(), transferNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } + @PostMapping("/{transferNo}/palletize") public ApiResponse palletize( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @@ -40,4 +85,49 @@ public class PdaWarehouseTransferController { return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request)); } + + @PostMapping("/{transferNo}/pallets/drafts") + public ApiResponse savePalletDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.PalletDraft request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.savePalletDraft(identity.overseasWarehouseId(), transferNo, request)); + } + + @PostMapping("/{transferNo}/pallets/{palletId}/generate") + public ApiResponse generatePallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId, + @Valid @RequestBody WarehouseTransferRequests.PalletAction request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.generatePallet(identity.overseasWarehouseId(), transferNo, palletId, request)); + } + + @PostMapping("/{transferNo}/pallets/{palletId}/bind") + public ApiResponse bindPallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId, + @Valid @RequestBody WarehouseTransferRequests.PalletBind request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.bindPallet(identity.overseasWarehouseId(), transferNo, palletId, request)); + } + + @org.springframework.web.bind.annotation.DeleteMapping("/{transferNo}/pallets/{palletId}") + public ApiResponse deletePalletDraft( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.deletePalletDraft(identity.overseasWarehouseId(), transferNo, palletId)); + } + + @GetMapping("/{transferNo}/pallets/{warehousePalletId}/label") + public ResponseEntity palletLabel(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String transferNo, + @PathVariable long warehousePalletId, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + var file = service.printPdaPalletLabel(identity.overseasWarehouseId(), transferNo, + warehousePalletId, requestId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.contentType())).body(file.content()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java index 9967c47d5..8d3c9f135 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -24,14 +24,14 @@ public class WarehouseTransferController { @PostMapping public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { - return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request)); } @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) { - return ApiResponse.success(service.page(scope(user, customerId), query)); + return ApiResponse.success(service.page(scope(user, customerId), query, false)); } @GetMapping("/{transferNo}") @@ -56,6 +56,17 @@ public class WarehouseTransferController { return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); } + @PostMapping("/bind-destination") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + public ApiResponse> bindDestination( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseTransferRequests.BindDestination request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + return ApiResponse.success(service.bindDestination(operator(user), request)); + } + @PostMapping("/{transferNo}/allocations") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String transferNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index db1373ca1..42804efba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -21,7 +21,17 @@ public final class WarehouseTransferRequests { @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, @NotBlank @Size(max = 128) String operatingWarehouseId, @Size(max = 128) String fbaWarehouseCode, + @Size(max = 16) String destinationType, + @Size(max = 128) String destinationWarehouseId, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @Size(max = 1000) String privateDestinationAddress, + @Size(max = 32) String privateDestinationPostalCode, + @Size(max = 128) String privateDestinationContactName, + @Size(max = 64) String privateDestinationContactPhone, @NotBlank @Size(max = 1000) String deliveryAddress, + @Size(max = 128) String pickupContactName, + @Size(max = 64) String pickupContactPhone, @Positive int packageCount, @NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm, @NotBlank @Size(max = 64) String deliveryMethod, @@ -46,12 +56,29 @@ public final class WarehouseTransferRequests { String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, boolean needRelabel, String idempotencyKey, List items) { - this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress, - packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, + this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, + null, null, null, null, null, null, null, null, + deliveryAddress, null, null, packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo, fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null); } } + public record DestinationSelection( + @NotBlank @Size(max = 128) String customerId, + @NotBlank @Size(max = 64) String transferNo) {} + + public record BindDestination( + @NotEmpty @Size(max = 100) List<@Valid DestinationSelection> transfers, + @NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType, + @Size(max = 128) String destinationWarehouseId, + @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @Size(max = 1000) String privateDestinationAddress, + @Size(max = 32) String privateDestinationPostalCode, + @Size(max = 128) String privateDestinationContactName, + @Size(max = 64) String privateDestinationContactPhone, + @NotBlank @Size(max = 255) String idempotencyKey) {} + /** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */ public record Review(@NotBlank @Size(max = 32) String decision, @Size(max = 1000) String remark) {} @@ -72,19 +99,34 @@ public final class WarehouseTransferRequests { public record Palletize( @NotBlank @Size(max = 255) String idempotencyKey, - @NotEmpty @Size(max = 100) List<@Valid Pallet> pallets + @NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds ) {} + public record PalletDraft( + Long palletId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Positive int cartonCount, + @NotNull @DecimalMin("0.01") BigDecimal lengthCm, + @NotNull @DecimalMin("0.01") BigDecimal widthCm, + @NotNull @DecimalMin("0.01") BigDecimal heightCm, + @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg + ) {} + + public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {} + + public record PalletBind(@NotBlank @Size(max = 128) String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) {} + public record Pallet( - @Size(max = 128) String palletCode, @Positive int cartonCount, + @NotNull @DecimalMin("0.01") BigDecimal lengthCm, + @NotNull @DecimalMin("0.01") BigDecimal widthCm, + @NotNull @DecimalMin("0.01") BigDecimal heightCm, @NotNull @DecimalMin("0.000001") BigDecimal volumeCbm, - @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg, - @NotEmpty @Size(max = 500) List<@Valid PalletItem> items + @NotNull @DecimalMin("0.01") BigDecimal grossWeightKg ) {} - public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {} - public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} @@ -106,8 +148,8 @@ public final class WarehouseTransferRequests { ) {} public record Item( - @NotBlank @Size(max = 128) String skuCode, - @NotBlank @Size(max = 255) String productNameZh, + @Size(max = 128) String skuCode, + @Size(max = 255) String productNameZh, @Positive int packageCount, @Size(max = 255) String shippingMark ) {} @@ -115,7 +157,7 @@ public final class WarehouseTransferRequests { public record RelabelItem( @NotBlank @Size(max = 32) String operationType, @NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks, - @NotBlank @Size(max = 128) String originalSku, + @Size(max = 128) String originalSku, @Size(max = 128) String newSku, @Positive int quantity, @Size(max = 255) String fbaCartonMark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 3abdde3da..140c50ef6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -9,12 +9,16 @@ public final class WarehouseTransferResponses { public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {} public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {} public record Label(String transferNo, List cartons, String printableHtml) {} + public record DestinationResult(String customerId, String transferNo) {} public record PalletItem(Long transferItemId, String skuCode, int packageCount) {} + public record MeasurementPhoto(Long fileId, String fileName, String contentType, long sizeBytes, + LocalDateTime uploadedAt) {} public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo, Long warehouseLocationId, String warehouseLocationCode, int cartonCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm, String status, - List items, LocalDateTime completedAt) {} + String fbaWarehouseCode, String labelDownloadPath, + List items, LocalDateTime boundAt, LocalDateTime completedAt) {} public record RelabelBinding(String operationType, String customerCartonMark, String originalSku, String newSku, int quantity, String fbaCartonMark, String destinationWarehouseCode, String remark) {} @@ -24,19 +28,28 @@ public final class WarehouseTransferResponses { Long destinationInventoryPositionId) {} public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, + String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, String status, boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, - String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, - int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + String destinationType, String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String privateDestinationAddress, + String privateDestinationPostalCode, + String privateDestinationContactName, String privateDestinationContactPhone, + String pickupContactName, String pickupContactPhone, + int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt, String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + LocalDateTime labelDeadline, String relabelContactName, String relabelContactPhone, + String relabelDeliveryMethod, String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt, List sourceOrders, List items, List cartons, List pallets, List relabelBindings, - List allocations, LocalDateTime customerConfirmedAt, + List allocations, List measurementPhotos, + LocalDateTime customerConfirmedAt, LocalDateTime actualShipAt, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java new file mode 100644 index 000000000..52668595a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_measurement_photo") +public class WarehouseTransferMeasurementPhoto { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String uploadedBy; + private LocalDateTime uploadedAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java index 6ce3b354f..a994d1730 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -18,7 +18,17 @@ public class WarehouseTransferOrder { private String lastlegOrderNo; private String operatingWarehouseId; private String fbaWarehouseCode; + private String destinationType; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String privateDestinationAddress; + private String privateDestinationPostalCode; + private String privateDestinationContactName; + private String privateDestinationContactPhone; private String deliveryAddress; + private String pickupContactName; + private String pickupContactPhone; private Integer packageCount; private String deliveryMethod; private LocalDateTime estimatedArrivalAt; @@ -50,6 +60,8 @@ public class WarehouseTransferOrder { private String palletizeRequestHash; private String confirmationIdempotencyKey; private String confirmationRequestHash; + private String destinationBindingIdempotencyKey; + private String destinationBindingRequestHash; private LocalDateTime customerConfirmedAt; private String idempotencyKey; private String requestHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java index 749eeb1af..409910321 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java @@ -27,6 +27,11 @@ public class WarehouseTransferPallet { private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private String status; + private String draftIdempotencyKey; + private String draftRequestHash; + private String generateIdempotencyKey; + private String bindIdempotencyKey; + private LocalDateTime boundAt; private LocalDateTime completedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java new file mode 100644 index 000000000..0ee0f88a0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.transfer.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.*; +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 HttpTransferMeasurementPhotoFileGateway implements TransferMeasurementPhotoFileGateway { + private static final String TYPE = "OVERSEAS_TRANSFER_MEASUREMENT_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getRelabelPhotoFile()); + } + + @Override public UploadedFile upload(String customerId, long transferId, long 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, "Cannot read transfer measurement photo", exception); + } + MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}", + tenant(customerId), TYPE, transferId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo upload failed"); }) + .body(MetadataEnvelope.class); + Metadata data = success(response, "Transfer measurement photo upload failed"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override public void validate(String customerId, long transferId, long fileId) { + long tenant = tenant(customerId); + MetadataEnvelope response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}", + tenant, TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo ownership is invalid"); }) + .body(MetadataEnvelope.class); + Metadata data = success(response, "Transfer measurement photo ownership is invalid"); + if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenant + || !TYPE.equals(data.businessType()) || !String.valueOf(transferId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Transfer measurement photo ownership is invalid"); + } + } + + @Override public DownloadedFile download(String customerId, long transferId, long fileId) { + validate(customerId, transferId, fileId); + ResponseEntity response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}/content", + tenant(customerId), TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo download failed"); }) + .toEntity(byte[].class); + String name = response.getHeaders().getContentDisposition().getFilename(); + String type = response.getHeaders().getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE + : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + name == null ? "transfer-measurement-photo" : name, type); + } + + private static long tenant(String value) { + try { return Long.parseLong(value); } + catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } + } + private static Metadata success(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if (!"200".equals(response.code())) { + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + if (response.data() == null || response.data().fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no transfer measurement photo metadata"); + } + return response.data(); + } + private static ResponseStatusException failure(HttpStatusCode status, String message) { + return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, message); + } + 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/transfer/gateway/TransferMeasurementPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java new file mode 100644 index 000000000..9324c10c6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.transfer.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface TransferMeasurementPhotoFileGateway { + UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file); + void validate(String customerId, long transferId, long fileId); + DownloadedFile download(String customerId, long transferId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {} + record DownloadedFile(byte[] content, String fileName, String contentType) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java new file mode 100644 index 000000000..829eb0360 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +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 WarehouseTransferMeasurementPhotoMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY uploaded_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND submitted_at IS NOT NULL ORDER BY submitted_at,id") + List listSubmitted(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} LIMIT 1") + WarehouseTransferMeasurementPhoto findFile(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("fileId") Long fileId); + @Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} AND submitted_at IS NOT NULL LIMIT 1") + WarehouseTransferMeasurementPhoto findSubmittedFile(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("fileId") Long fileId); + @Update("") + int markSubmitted(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId, + @Param("fileIds") java.util.Collection fileIds, + @Param("submittedAt") java.time.LocalDateTime submittedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java index bc86eed62..ceb53edcc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -14,6 +14,8 @@ import org.apache.ibatis.annotations.Update; WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1") + WarehouseTransferOrder findNoByWarehouse(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java index 6526636ea..fb9361e0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java @@ -13,4 +13,9 @@ public interface WarehouseTransferPalletMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND id=#{id} FOR UPDATE") + WarehouseTransferPallet lockOne(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("id") Long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java new file mode 100644 index 000000000..271c2287f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.transfer.model; + +import java.util.Arrays; + +/** 仓储转运单状态。状态编码持久化到数据库,中文名称用于接口和前端展示。 */ +public enum WarehouseTransferStatus { + DRAFT("草稿"), + PENDING_REVIEW("待审核"), + APPROVED("审核通过"), + REJECTED("审核驳回"), + WAIT_PDA_RECEIVING("待PDA收货"), + INBOUND_MEASURING("入库测量中"), + WAIT_CUSTOMER_CONFIRMATION("待客户确认实测"), + WAIT_RELABEL("待换标"), + WAIT_FBA_DESTINATION("待确认目的仓"), + READY_FOR_DISPATCH("待排车"), + WAIT_OUTBOUND("待出库"), + PENDING_ASSIGNMENT("待分配"), + ALLOCATED("已分配库存"), + OUTBOUNDED("已出库"), + PARTIALLY_RECEIVED("部分收货"), + RECEIVED("已收货"), + INBOUND_COMPLETED("入库完成"), + COMPLETED("已完成"); + + private final String label; + + WarehouseTransferStatus(String label) { this.label = label; } + + public String code() { return name(); } + public String label() { return label; } + + public static WarehouseTransferStatus fromCode(String code) { + return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported warehouse transfer status: " + code)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index e34173b70..2d5cec2a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -46,6 +46,8 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; import com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem; import com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; @@ -56,6 +58,7 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -64,6 +67,7 @@ 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.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -85,6 +89,7 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; + public static final String DRAFT = "DRAFT"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; @@ -96,7 +101,11 @@ public class WarehouseTransferService { public static final String WAIT_PDA_RECEIVING = "WAIT_PDA_RECEIVING"; public static final String WAIT_CUSTOMER_CONFIRMATION = "WAIT_CUSTOMER_CONFIRMATION"; public static final String READY_FOR_DISPATCH = "READY_FOR_DISPATCH"; + /** PDA/customer measurement is complete, but an FBA destination is still required before dispatch. */ + public static final String WAIT_FBA_DESTINATION = "WAIT_FBA_DESTINATION"; public static final String WAIT_RELABEL = "WAIT_RELABEL"; + public static final String INBOUND_MEASURING = "INBOUND_MEASURING"; + public static final String INBOUND_COMPLETED = "INBOUND_COMPLETED"; @Resource private WarehouseTransferOrderMapper transfers; @Resource private WarehouseTransferItemMapper transferItems; @@ -105,6 +114,8 @@ public class WarehouseTransferService { @Resource private WarehouseTransferPalletMapper transferPallets; @Resource private WarehouseTransferPalletItemMapper transferPalletItems; @Resource private WarehouseTransferRelabelBindingMapper relabelBindings; + @Resource private WarehouseTransferMeasurementPhotoMapper measurementPhotos; + @Resource private TransferMeasurementPhotoFileGateway measurementPhotoFiles; @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Resource private WarehouseTransferReceiptMapper transferReceipts; @Resource private InboundSnapshotMapper inboundSnapshots; @@ -133,6 +144,9 @@ public class WarehouseTransferService { throw badRequest("Cargo item package total must equal transfer package count"); } validateRelabelRequest(request); + validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), + request.privateDestinationAddress(), request.privateDestinationPostalCode(), + request.privateDestinationContactName(), request.privateDestinationContactPhone()); String key = request.idempotencyKey().trim(); String requestHash = hash(request); WarehouseTransferOrder replay = transfers.findKey(customerId, key); @@ -179,14 +193,17 @@ public class WarehouseTransferService { transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); - transfer.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); + transfer.setFbaWarehouseCode(destinationCode(request)); + applyDestination(transfer, request); transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setPickupContactName(trim(request.pickupContactName())); + transfer.setPickupContactPhone(trim(request.pickupContactPhone())); transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); transfer.setEstimatedShipAt(request.estimatedShipAt()); transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); - transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT); transfer.setLabelDeadline(request.labelDeadline()); transfer.setRelabelContactName(trim(request.relabelContactName())); transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); @@ -224,23 +241,171 @@ public class WarehouseTransferService { } insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); } - insertOrderHistory(customerId, order.getId(), null, PENDING_REVIEW, transferNo, - "Warehouse transfer submitted for review", now); + insertOrderHistory(customerId, order.getId(), null, DRAFT, transferNo, + "Warehouse transfer draft created", now); + return detail(customerId, transferNo); + } + + /** Platform-created transfers keep the existing create-and-submit behavior. */ + @Transactional + public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId, + WarehouseTransferRequests.Create request) { + WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request); + return submit(customerId, draft.transferNo(), operatorId); + } + + @Transactional + public WarehouseTransferResponses.Detail updateDraft(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Create request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!DRAFT.equals(transfer.getStatus())) { + throw conflict("Only a draft warehouse transfer order can be edited"); + } + int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); + if (itemPackages != request.packageCount()) { + throw badRequest("Cargo item package total must equal transfer package count"); + } + validateRelabelRequest(request); + validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(), + request.privateDestinationAddress(), request.privateDestinationPostalCode(), + request.privateDestinationContactName(), request.privateDestinationContactPhone()); + + LastlegOrder order = requireTailOrder(customerId, transfer); + LocalDateTime now = LocalDateTime.now(); + String requestHash = hash(request); + transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); + transfer.setFbaWarehouseCode(destinationCode(request)); + applyDestination(transfer, request); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); + transfer.setPackageCount(request.packageCount()); + transfer.setPickupContactName(trim(request.pickupContactName())); + transfer.setPickupContactPhone(trim(request.pickupContactPhone())); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); + transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setEstimatedShipAt(request.estimatedShipAt()); + transfer.setFbaShipmentNo(trim(request.fbaShipmentNo())); + transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) + ? trim(request.trackingNo()) : null); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); + transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); + transfer.setLabelDeadline(request.labelDeadline()); + transfer.setRelabelContactName(trim(request.relabelContactName())); + transfer.setRelabelContactPhone(trim(request.relabelContactPhone())); + transfer.setRelabelChargeAmount(request.relabelChargeAmount()); + transfer.setRelabelDeliveryMethod(trim(request.relabelDeliveryMethod())); + transfer.setRequestHash(requestHash); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(destinationCode(request)); + order.setPackageCount(request.packageCount()); + order.setVolumeCbm(request.volumeCbm()); + order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); + order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); + order.setCustomerRemark(trim(request.remark())); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setUpdatedAt(now); + orders.updateById(order); + + if (transferNo.equals(transfer.getInboundCode())) { + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + inbound.setDestinationWarehouseId(request.operatingWarehouseId().trim()); + inbound.setPackageCount(request.packageCount()); + inbound.setVolumeCbm(request.volumeCbm()); + inbound.setPayloadHash("sha256:" + requestHash); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + + transferCartons.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferCarton::getCustomerId, customerId) + .eq(WarehouseTransferCarton::getTransferOrderId, transfer.getId())); + transferItems.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId())); + relabelBindings.delete(new LambdaQueryWrapper() + .eq(WarehouseTransferRelabelBinding::getCustomerId, customerId) + .eq(WarehouseTransferRelabelBinding::getTransferOrderId, transfer.getId())); + orderDetails.delete(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId) + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + if (request.needRelabel()) insertRelabelBindings(customerId, transfer.getId(), request.relabelItems(), now); + int cartonIndex = 0; + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + long itemId = insertTransferItem(customerId, transfer.getId(), source, now); + List customerMarks = splitMarks(source.shippingMark()); + for (int itemCarton = 0; itemCarton < source.packageCount(); itemCarton++) { + cartonIndex++; + insertTransferCarton(customerId, transfer.getId(), itemId, transferNo, cartonIndex, + request.packageCount(), itemCarton < customerMarks.size() ? customerMarks.get(itemCarton) : null, now); + } + insertOrderDetail(customerId, order.getId(), transferNo, index, source, request, now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); + if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); + + LastlegOrder order = requireTailOrder(customerId, transfer); + LocalDateTime now = LocalDateTime.now(); + transfer.setStatus(PENDING_REVIEW); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + order.setCurrentStatus(PENDING_REVIEW); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + order.setUpdatedAt(now); + orders.updateById(order); + + LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode()) + ? inboundOrders.selectById(transfer.getInboundOrderId()) : null; + if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) { + inbound.setStatus(PENDING_REVIEW); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review by " + operatorId, now); return detail(customerId, transferNo); } @Transactional(readOnly = true) public PageResult page(String customerId, WarehouseTransferRequests.Query query) { + return page(customerId, query, true); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query, + boolean includeDrafts) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw badRequest("createdFrom must not be after createdTo"); } + List groupedStatuses = customerMainStatuses(query.status()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) .eq(StringUtils.hasText(query.operatingWarehouseId()), WarehouseTransferOrder::getOperatingWarehouseId, query.operatingWarehouseId()) .like(StringUtils.hasText(query.fbaWarehouseCode()), WarehouseTransferOrder::getFbaWarehouseCode, query.fbaWarehouseCode()) - .eq(StringUtils.hasText(query.status()), WarehouseTransferOrder::getStatus, query.status()) + .in(groupedStatuses != null, WarehouseTransferOrder::getStatus, + groupedStatuses == null ? List.of() : groupedStatuses) + .eq(groupedStatuses == null && StringUtils.hasText(query.status()), + WarehouseTransferOrder::getStatus, query.status()) + .ne(!includeDrafts, WarehouseTransferOrder::getStatus, DRAFT) .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) @@ -249,12 +414,77 @@ public class WarehouseTransferService { List records = result.getRecords().stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), - value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), + value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } + static List customerMainStatuses(String status) { + if (!StringUtils.hasText(status)) return null; + return switch (status.trim()) { + case INBOUND_MEASURING -> List.of(APPROVED, WAIT_PDA_RECEIVING); + case INBOUND_COMPLETED -> List.of(WAIT_CUSTOMER_CONFIRMATION, WAIT_FBA_DESTINATION, + WAIT_RELABEL, READY_FOR_DISPATCH, "WAIT_OUTBOUND", ALLOCATED, OUTBOUNDED, + PARTIALLY_RECEIVED, RECEIVED, "COMPLETED", INBOUND_COMPLETED); + default -> null; + }; + } + + @Transactional + public List bindDestination(String operatorId, + WarehouseTransferRequests.BindDestination request) { + validateDestination(request.destinationType(), request.destinationWarehouseCode(), null, + request.privateDestinationAddress(), request.privateDestinationPostalCode(), request.privateDestinationContactName(), + request.privateDestinationContactPhone()); + String requestHash = hash(request); + Set selected = new HashSet<>(); + List results = new ArrayList<>(); + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferRequests.DestinationSelection selection : request.transfers()) { + String customerId = selection.customerId().trim(); + String transferNo = selection.transferNo().trim(); + if (!selected.add(customerId + "\n" + transferNo)) throw badRequest("Warehouse transfer selections must be unique"); + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found: " + transferNo); + if (READY_FOR_DISPATCH.equals(transfer.getStatus()) + && request.idempotencyKey().equals(transfer.getDestinationBindingIdempotencyKey())) { + if (!requestHash.equals(transfer.getDestinationBindingRequestHash())) { + throw conflict("Destination binding idempotency key was reused with different data"); + } + results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); + continue; + } + if (!WAIT_FBA_DESTINATION.equals(transfer.getStatus()) || hasValidDestination(transfer)) { + throw conflict("Only an inbound-completed transfer without a destination can be bound: " + transferNo); + } + applyDestination(transfer, request); + transfer.setStatus(READY_FOR_DISPATCH); + transfer.setDestinationBindingIdempotencyKey(request.idempotencyKey().trim()); + transfer.setDestinationBindingRequestHash(requestHash); + transfer.setUpdatedAt(now); + transfers.updateById(transfer); + + LastlegOrder order = requireTailOrder(customerId, transfer); + order.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + READY_FOR_DISPATCH + ";RELABEL:" + + Objects.toString(order.getRelabelStatus(), "NOT_REQUIRED")); + order.setUpdatedAt(now); + orders.updateById(order); + for (LastlegOrderDetail detail : orderDetails.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, order.getId()))) { + detail.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + orderDetails.updateById(detail); + } + syncInboundPlanDestination(customerId, transfer); + insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo, + "Warehouse transfer destination bound by " + trim(operatorId), now); + results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo)); + } + return results.stream().map(result -> detail(result.customerId(), result.transferNo())).toList(); + } + @Transactional(readOnly = true) public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); @@ -283,11 +513,14 @@ public class WarehouseTransferService { value.getWarehousePalletId(), value.getPalletNo(), value.getWarehouseLocationId(), value.getWarehouseLocationCode(), value.getCartonCount(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getVolumeCbm(), value.getStatus(), + trim(transfer.getFbaWarehouseCode()), value.getWarehousePalletId() == null ? null + : "/api/pda/v1/last-leg/warehouse-transfers/" + transfer.getTransferNo() + + "/pallets/" + value.getWarehousePalletId() + "/label", palletItemsByPallet.getOrDefault(value.getId(), List.of()).stream().map(row -> { WarehouseTransferResponses.Item item = itemById.get(row.getTransferItemId()); return new WarehouseTransferResponses.PalletItem(row.getTransferItemId(), item == null ? null : item.skuCode(), row.getPackageCount()); - }).toList(), value.getCompletedAt())).toList(); + }).toList(), value.getBoundAt(), value.getCompletedAt())).toList(); List bindings = relabelBindings == null ? List.of() : relabelBindings.listOrder(customerId, transfer.getId()) .stream().map(value -> new WarehouseTransferResponses.RelabelBinding(value.getOperationType(), value.getCustomerCartonMark(), value.getOriginalSku(), value.getNewSku(), value.getQuantity(), @@ -307,6 +540,10 @@ public class WarehouseTransferService { value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); }).toList(); + List photos = measurementPhotos.listSubmitted(customerId, transfer.getId()) + .stream().map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); String relabelStatus = null; if (transfer.getRelabelNo() != null) { RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo()); @@ -321,10 +558,18 @@ public class WarehouseTransferService { Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), - transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), + transfer.getDestinationType(), transfer.getDestinationWarehouseId(), transfer.getDestinationWarehouseCode(), + transfer.getDestinationWarehouseName(), transfer.getPrivateDestinationAddress(), + transfer.getPrivateDestinationPostalCode(), + transfer.getPrivateDestinationContactName(), transfer.getPrivateDestinationContactPhone(), + transfer.getPickupContactName(), transfer.getPickupContactPhone(), + transfer.getPackageCount(), tailOrder == null ? null : tailOrder.getVolumeCbm(), + transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getEstimatedShipAt(), transfer.getFbaShipmentNo(), transfer.getTrackingNo(), transfer.getFbaBoxNos(), - transfer.getRemark(), transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), - transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, + transfer.getRemark(), transfer.getLabelDeadline(), transfer.getRelabelContactName(), + transfer.getRelabelContactPhone(), transfer.getRelabelDeliveryMethod(), + transfer.getReviewDecision(), transfer.getReviewRemark(), transfer.getReviewedBy(), + transfer.getReviewedAt(), sources, items, cartons, pallets, bindings, allocations, photos, transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt()); } @@ -332,6 +577,7 @@ public class WarehouseTransferService { public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) { WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels"); List cartons = transferCartons.listOrder(customerId, transfer.getId()).stream() .map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList(); StringBuilder labels = new StringBuilder(); @@ -366,8 +612,14 @@ public class WarehouseTransferService { if (transfer == null) throw notFound("Warehouse transfer order not found"); if (!PENDING_REVIEW.equals(transfer.getStatus())) { if (next.equals(transfer.getStatus()) || APPROVED.equals(next) - && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, WAIT_RELABEL) - .contains(transfer.getStatus())) return detail(customerId, transferNo); + && Set.of(WAIT_PDA_RECEIVING, WAIT_CUSTOMER_CONFIRMATION, READY_FOR_DISPATCH, + WAIT_FBA_DESTINATION, WAIT_RELABEL) + .contains(transfer.getStatus())) { + if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) { + activateTransferInbound(customerId, transfer, LocalDateTime.now()); + } + return detail(customerId, transferNo); + } throw conflict("Warehouse transfer order was already reviewed"); } LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); @@ -383,11 +635,8 @@ public class WarehouseTransferService { transfer.setReviewedAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); - String orderStatus = APPROVED.equals(next) - ? (incomingTransit - ? ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod()) ? "WAIT_PICKUP" : "WAIT_DELIVERY") - : PENDING_ASSIGNMENT) - : "CANCELLED"; + if (APPROVED.equals(next) && incomingTransit) activateTransferInbound(customerId, transfer, now); + String orderStatus = APPROVED.equals(next) ? INBOUND_MEASURING : "CANCELLED"; order.setCurrentStatus(orderStatus); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next + ";RELABEL:" + (StringUtils.hasText(order.getRelabelStatus()) ? order.getRelabelStatus() : "NOT_REQUIRED")); @@ -400,6 +649,112 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional + public WarehouseTransferResponses.Detail savePalletDraft(long warehouseId, String transferNo, + WarehouseTransferRequests.PalletDraft request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + String customerId = transfer.getCustomerId(); + validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm()); + String requestHash = hash(request); + List existing = transferPallets.listOrder(customerId, transfer.getId()); + WarehouseTransferPallet pallet; + if (request.palletId() == null) { + pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey())) + .findFirst().orElse(null); + if (pallet != null) { + if (!requestHash.equals(pallet.getDraftRequestHash())) throw conflict("Pallet draft idempotency key was reused"); + return detail(customerId, transferNo); + } + if (existing.size() >= 100) throw badRequest("A transfer supports at most 100 pallets"); + pallet = new WarehouseTransferPallet(); + pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); + pallet.setSequenceNo(existing.stream().mapToInt(WarehouseTransferPallet::getSequenceNo).max().orElse(0) + 1); + pallet.setStatus("DRAFT"); pallet.setCreatedAt(LocalDateTime.now()); + } else { + pallet = transferPallets.lockOne(customerId, transfer.getId(), request.palletId()); + if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked"); + } + pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm()); + pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm()); + pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg()); + pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash); + pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail deletePalletDraft(long warehouseId, String transferNo, long palletId) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) return detail(transfer.getCustomerId(), transferNo); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet cannot be removed"); + transferPallets.deleteById(palletId); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail generatePallet(long warehouseId, String transferNo, long palletId, + WarehouseTransferRequests.PalletAction request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) throw notFound("Warehouse transfer pallet draft not found"); + if (Set.of("GENERATED", "BOUND").contains(pallet.getStatus()) + && request.idempotencyKey().equals(pallet.getGenerateIdempotencyKey())) return detail(transfer.getCustomerId(), transferNo); + if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Pallet was already generated"); + String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLET:" + pallet.getId(); + var item = new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(transfer.getCustomerId()), + transferNo + ":" + pallet.getId(), transferNo, pallet.getSequenceNo(), "STANDARD", + trim(transfer.getFbaWarehouseCode()), null); + warehouseResources.ensurePalletBatch(new WarehouseResourceGateway.PalletBatchEnsure(warehouseId, batchKey, List.of(item))); + var confirmed = warehouseResources.confirmPalletBatch(new WarehouseResourceGateway.PalletBatchCommand( + warehouseId, batchKey, request.idempotencyKey() + ":CONFIRM")); + if (confirmed == null || confirmed.items() == null || confirmed.items().size() != 1) { + throw conflict("Warehouse pallet generation did not return exactly one pallet"); + } + var resource = confirmed.items().get(0); + var location = warehouseResources.resolveLocation(warehouseId, resource.locationId()); + pallet.setWarehousePalletId(resource.palletId()); pallet.setPalletNo(resource.palletCode()); + pallet.setWarehouseLocationId(resource.locationId()); pallet.setWarehouseLocationCode(location.locationCode()); + pallet.setWarehouseResourceLockId(resource.resourceLockId()); pallet.setGenerateIdempotencyKey(request.idempotencyKey()); + pallet.setStatus("GENERATED"); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail bindPallet(long warehouseId, String transferNo, long palletId, + WarehouseTransferRequests.PalletBind request) { + WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo); + WarehouseTransferPallet pallet = transferPallets.lockOne(transfer.getCustomerId(), transfer.getId(), palletId); + if (pallet == null) throw notFound("Warehouse transfer pallet not found"); + if ("BOUND".equals(pallet.getStatus()) && request.idempotencyKey().equals(pallet.getBindIdempotencyKey()) + && pallet.getPalletNo().equals(request.palletCode().trim())) return detail(transfer.getCustomerId(), transferNo); + if (!"GENERATED".equals(pallet.getStatus())) throw conflict("Only a generated pallet can be scan-bound"); + if (!pallet.getPalletNo().equals(request.palletCode().trim())) throw conflict("Scanned pallet code does not match this generated pallet"); + var physical = warehouseResources.resolvePallet(request.palletCode().trim()); + if (physical.palletId() != pallet.getWarehousePalletId() || physical.warehouseId() != warehouseId) { + throw conflict("Scanned pallet does not belong to this transfer and warehouse"); + } + pallet.setStatus("BOUND"); pallet.setBindIdempotencyKey(request.idempotencyKey()); + pallet.setBoundAt(LocalDateTime.now()); pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.updateById(pallet); + return detail(transfer.getCustomerId(), transferNo); + } + + private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("Warehouse transfer is not awaiting PDA receiving"); + return transfer; + } + + private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { + BigDecimal calculated = length.multiply(width).multiply(height) + .divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + if (calculated.subtract(volume).abs().compareTo(new BigDecimal("0.000001")) > 0) { + throw badRequest("Pallet volume must match measured length, width and height"); + } + } + @Transactional public WarehouseTransferResponses.Detail palletize(String operatorId, long overseasWarehouseId, String transferNo, @@ -424,77 +779,53 @@ public class WarehouseTransferService { List items = transferItems.selectList(new LambdaQueryWrapper() .eq(WarehouseTransferItem::getCustomerId, customerId) .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)); - Map itemById = items.stream() - .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); - Map totals = new java.util.HashMap<>(); - int palletCartons = 0; - for (WarehouseTransferRequests.Pallet pallet : request.pallets()) { - int itemTotal = pallet.items().stream().mapToInt(WarehouseTransferRequests.PalletItem::packageCount).sum(); - if (itemTotal != pallet.cartonCount()) throw badRequest("Each pallet carton count must equal its SKU carton total"); - palletCartons = Math.addExact(palletCartons, pallet.cartonCount()); - Set uniqueItems = new HashSet<>(); - for (WarehouseTransferRequests.PalletItem item : pallet.items()) { - if (!uniqueItems.add(item.transferItemId()) || !itemById.containsKey(item.transferItemId())) { - throw badRequest("Pallet SKU rows must be unique and belong to this transfer"); - } - totals.merge(item.transferItemId(), item.packageCount(), Math::addExact); - } + if (items.isEmpty() || items.stream().mapToInt(WarehouseTransferItem::getPackageCount).sum() != transfer.getPackageCount()) { + throw conflict("Warehouse transfer declared cargo is incomplete"); } - if (palletCartons != transfer.getPackageCount()) throw badRequest("Pallet carton total must equal transfer package count"); - for (WarehouseTransferItem item : items) { - if (totals.getOrDefault(item.getId(), 0) != item.getPackageCount()) { - throw badRequest("Palletized carton total does not match declared SKU " + item.getSkuCode()); - } + if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) { + throw conflict("Measurement and palletization require 1 to 20 on-site photos"); } - - String batchKey = "WAREHOUSE_TRANSFER:" + transferNo + ":PALLETIZE"; - List batchItems = new ArrayList<>(); - for (int index = 0; index < request.pallets().size(); index++) { - WarehouseTransferRequests.Pallet pallet = request.pallets().get(index); - batchItems.add(new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(customerId), - transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", - trim(transfer.getFbaWarehouseCode()), trim(pallet.palletCode()))); + Set photoIds = new java.util.LinkedHashSet<>(request.photoFileIds()); + if (photoIds.size() != request.photoFileIds().size()) throw badRequest("Measurement photo IDs must be unique"); + for (Long fileId : photoIds) { + WarehouseTransferMeasurementPhoto photo = measurementPhotos.findFile(customerId, transfer.getId(), fileId); + if (photo == null) throw badRequest("Measurement photo does not belong to this transfer: " + fileId); + measurementPhotoFiles.validate(customerId, transfer.getId(), fileId); } - WarehouseResourceGateway.PalletBatchResponse ensured = warehouseResources.ensurePalletBatch( - new WarehouseResourceGateway.PalletBatchEnsure(overseasWarehouseId, batchKey, batchItems)); - WarehouseResourceGateway.PalletBatchResponse confirmed = warehouseResources.confirmPalletBatch( - new WarehouseResourceGateway.PalletBatchCommand(overseasWarehouseId, batchKey, - request.idempotencyKey() + ":CONFIRM")); - if (confirmed == null || confirmed.items() == null || confirmed.items().size() != request.pallets().size()) { - throw conflict("Warehouse pallet batch did not return every pallet"); + List persistedPallets = transferPallets.listOrder(customerId, transfer.getId()); + if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) { + throw conflict("Every pallet must be generated and scan-bound before completing palletization"); } + int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum(); + List derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets); LocalDateTime now = LocalDateTime.now(); - for (int index = 0; index < request.pallets().size(); index++) { - int sequenceNo = index + 1; - WarehouseTransferRequests.Pallet source = request.pallets().get(index); - WarehouseResourceGateway.PalletBatchItem resource = confirmed.items().stream() - .filter(value -> value.sequenceNo() == sequenceNo).findFirst() - .orElseThrow(() -> conflict("Warehouse pallet sequence is incomplete")); - WarehouseResourceGateway.WarehouseLocationReference location = warehouseResources.resolveLocation( - overseasWarehouseId, resource.locationId()); - WarehouseTransferPallet pallet = new WarehouseTransferPallet(); - pallet.setId(IdWorker.getId()); pallet.setTransferOrderId(transfer.getId()); pallet.setCustomerId(customerId); - pallet.setSequenceNo(sequenceNo); pallet.setWarehousePalletId(resource.palletId()); - pallet.setPalletNo(resource.palletCode()); pallet.setWarehouseLocationId(resource.locationId()); - pallet.setWarehouseLocationCode(location.locationCode()); pallet.setWarehouseResourceLockId(resource.resourceLockId()); - // 转运测量以打托后的托盘为单位,体积由 PDA 直接录入托盘总体积,不再按单箱尺寸换算。 - // 旧表字段仍为 NOT NULL;按托模式不采集长宽高,以 0 兼容存储且不得参与总体积计算。 - pallet.setLengthCm(BigDecimal.ZERO); pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO); - pallet.setCartonCount(source.cartonCount()); pallet.setGrossWeightKg(source.grossWeightKg()); - pallet.setVolumeCbm(source.volumeCbm().setScale(6, RoundingMode.HALF_UP)); - pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setCreatedAt(now); pallet.setUpdatedAt(now); - transferPallets.insert(pallet); - for (WarehouseTransferRequests.PalletItem sourceItem : source.items()) { + List completedPalletItems = new ArrayList<>(); + for (int index = 0; index < persistedPallets.size(); index++) { + WarehouseTransferPallet pallet = persistedPallets.get(index); + for (DerivedPalletAllocation allocation : derivedAllocations) { + if (allocation.palletIndex() != index) continue; WarehouseTransferPalletItem row = new WarehouseTransferPalletItem(); row.setId(IdWorker.getId()); row.setTransferOrderId(transfer.getId()); row.setTransferPalletId(pallet.getId()); - row.setTransferItemId(sourceItem.transferItemId()); row.setCustomerId(customerId); - row.setPackageCount(sourceItem.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + row.setTransferItemId(allocation.transferItemId()); row.setCustomerId(customerId); + row.setPackageCount(allocation.packageCount()); row.setCreatedAt(now); transferPalletItems.insert(row); + completedPalletItems.add(row); } + pallet.setStatus("COMPLETED"); pallet.setCompletedAt(now); pallet.setUpdatedAt(now); transferPallets.updateById(pallet); } + if (measurementPhotos.markSubmitted(customerId, transfer.getId(), photoIds, now) != photoIds.size()) { + throw conflict("Measurement photo submission is incomplete"); + } + WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( + Long.parseLong(transfer.getOperatingWarehouseId())); + stockInMeasuredPallets(customerId, operatorId, transfer, items, persistedPallets, + completedPalletItems, warehouse, now); + completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now); + completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now); transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim()); transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); - order.setCurrentStatus(WAIT_CUSTOMER_CONFIRMATION); order.setPalletCount(request.pallets().size()); + order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size()); + order.setReceivedPackageCount(palletCartons); order.setVolumeCbm(transferPallets.listOrder(customerId, transfer.getId()).stream() .map(WarehouseTransferPallet::getVolumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add)); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + WAIT_CUSTOMER_CONFIRMATION); order.setUpdatedAt(now); orders.updateById(order); @@ -503,6 +834,18 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + @Transactional(readOnly = true) + public WarehouseResourceGateway.PrintableFile printPdaPalletLabel(long warehouseId, String transferNo, + long warehousePalletId, + String requestId) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + boolean owned = transferPallets.listOrder(transfer.getCustomerId(), transfer.getId()).stream() + .anyMatch(value -> java.util.Objects.equals(value.getWarehousePalletId(), warehousePalletId)); + if (!owned) throw notFound("Warehouse transfer pallet not found"); + return warehouseResources.printPalletLabel(warehouseId, warehousePalletId, transferNo, requestId); + } + @Transactional public WarehouseTransferResponses.Detail confirmMeasurement(String customerId, String transferNo, String operatorId, @@ -510,7 +853,7 @@ public class WarehouseTransferService { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); if (transfer == null) throw notFound("Warehouse transfer order not found"); String requestHash = hash(request); - if (Set.of(READY_FOR_DISPATCH, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) + if (Set.of(READY_FOR_DISPATCH, WAIT_FBA_DESTINATION, WAIT_RELABEL, OUTBOUNDED).contains(transfer.getStatus()) && request.idempotencyKey().equals(transfer.getConfirmationIdempotencyKey())) { if (!requestHash.equals(transfer.getConfirmationRequestHash())) { throw conflict("Confirmation idempotency key was reused with different data"); @@ -527,64 +870,130 @@ public class WarehouseTransferService { .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); List pallets = transferPallets.listOrder(customerId, transfer.getId()); List palletItems = transferPalletItems.listOrder(customerId, transfer.getId()); - WarehouseResourceGateway.WarehouseReference warehouse = warehouseResources.requireActiveSharedWarehouse( - Long.parseLong(transfer.getOperatingWarehouseId())); - Map> stockSourcesBySku = new java.util.LinkedHashMap<>(); + Map> stockSourcesBySku = confirmedStockSources( + itemById, stockReceipts.listTask(customerId, transfer.getId())); LocalDateTime now = LocalDateTime.now(); - for (WarehouseTransferPallet pallet : pallets) { - for (WarehouseTransferPalletItem row : palletItems.stream() - .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { - WarehouseTransferItem item = itemById.get(row.getTransferItemId()); - String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transferNo + ":" + pallet.getSequenceNo() + ":" + item.getId(); - InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, - new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, - transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), - null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), - transfer.getInboundOrderId(), transferNo, transferNo, item.getSkuCode(), item.getProductNameZh(), null, - pallet.getWarehousePalletId(), pallet.getPalletNo(), transferNo, pallet.getWarehouseLocationId(), - pallet.getWarehouseLocationCode(), row.getPackageCount(), transferNo, - "WAREHOUSE_TRANSFER_CONFIRMED:" + transferNo, operationKey, trim(request.remark()))); - stockSourcesBySku.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) - .add(new ConfirmedStockSource(result.balanceId(), result.positionId(), row.getPackageCount())); - InboundStockReceipt receipt = new InboundStockReceipt(); - receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); - // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 - receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); - receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); - receipt.setOrderId(transfer.getLastlegOrderId()); - receipt.setInboundCode(transferNo); receipt.setSkuCode(item.getSkuCode()); receipt.setProductNameZh(item.getProductNameZh()); - receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); - receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); - receipt.setBatchNo(transferNo); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); - receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); - receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); stockReceipts.insert(receipt); - } - } if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { RelabelResponses.Detail relabel = createLinkedRelabel(customerId, operatorId, transfer, itemById, stockSourcesBySku); transfer.setRelabelApplicationId(relabel.application().id()); transfer.setRelabelNo(relabel.application().relabelNo()); transfer.setStatus(WAIT_RELABEL); } else { - if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { - throw conflict("A destination warehouse is required before dispatch"); + if (hasValidDestination(transfer)) { + transfer.setStatus(READY_FOR_DISPATCH); + } else { + // Measurement confirmation is allowed without an FBA destination. Such + // transfers remain visible for destination completion but cannot dispatch. + transfer.setStatus(WAIT_FBA_DESTINATION); } - createDispatchPlan(customerId, transfer, items, pallets, palletItems, now); - transfer.setStatus(READY_FOR_DISPATCH); } transfer.setConfirmationIdempotencyKey(request.idempotencyKey().trim()); transfer.setConfirmationRequestHash(requestHash); transfer.setCustomerConfirmedAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus(); - order.setCurrentStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? WAIT_RELABEL : READY_FOR_DISPATCH); + order.setCurrentStatus(INBOUND_COMPLETED); order.setRelabelStatus(Boolean.TRUE.equals(transfer.getNeedRelabel()) ? "PENDING_PROCESSING" : "NOT_REQUIRED"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus()); order.setUpdatedAt(now); orders.updateById(order); - insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, - "Customer confirmed PDA measurements; actual inventory stocked in", now); + if (!java.util.Objects.equals(before, order.getCurrentStatus())) { + insertOrderHistory(customerId, order.getId(), before, order.getCurrentStatus(), transferNo, + "Customer confirmed PDA measurements; actual inventory stocked in", now); + } return detail(customerId, transferNo); } + private void stockInMeasuredPallets(String customerId, String operatorId, WarehouseTransferOrder transfer, + List items, + List pallets, + List palletItems, + WarehouseResourceGateway.WarehouseReference warehouse, + LocalDateTime now) { + Map itemById = items.stream() + .collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); + for (WarehouseTransferPallet pallet : pallets) { + for (WarehouseTransferPalletItem row : palletItems.stream() + .filter(value -> value.getTransferPalletId().equals(pallet.getId())).toList()) { + WarehouseTransferItem item = itemById.get(row.getTransferItemId()); + if (item == null) throw conflict("Pallet allocation references missing transfer cargo"); + String operationKey = "WAREHOUSE_TRANSFER_STOCK_IN:" + transfer.getTransferNo() + ":" + + pallet.getSequenceNo() + ":" + item.getId(); + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, "客户-" + customerId, + transfer.getOperatingWarehouseId(), warehouse.warehouseCode(), warehouse.warehouseName(), + null, trim(transfer.getFbaWarehouseCode()), trim(transfer.getFbaWarehouseCode()), + transfer.getInboundOrderId(), transfer.getTransferNo(), transfer.getTransferNo(), + item.getSkuCode(), item.getProductNameZh(), null, + pallet.getWarehousePalletId(), pallet.getPalletNo(), transfer.getTransferNo(), + pallet.getWarehouseLocationId(), pallet.getWarehouseLocationCode(), row.getPackageCount(), + transfer.getTransferNo(), "WAREHOUSE_TRANSFER_PDA_COMPLETED:" + transfer.getTransferNo(), + operationKey, null)); + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); receipt.setCustomerId(customerId); + // 转运收货没有独立 PDA 入仓任务,使用本业务链的稳定主键填充通用收货快照维度。 + receipt.setPlanId(transfer.getInboundOrderId()); receipt.setTaskId(transfer.getId()); + receipt.setAllocationId(row.getId()); receipt.setLineId(item.getId()); + receipt.setOrderId(transfer.getLastlegOrderId()); + receipt.setInboundCode(transfer.getTransferNo()); receipt.setSkuCode(item.getSkuCode()); + receipt.setProductNameZh(item.getProductNameZh()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setWarehouseLocationCode(pallet.getWarehouseLocationCode()); + receipt.setBatchNo(transfer.getTransferNo()); receipt.setReceivedPackages(Long.valueOf(row.getPackageCount())); + receipt.setInventoryBalanceId(result.balanceId()); receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); receipt.setReceivedAt(now); receipt.setCreatedAt(now); + stockReceipts.insert(receipt); + } + } + } + + private void completeTransferInboundSnapshot(String customerId, WarehouseTransferOrder transfer, + List pallets, + WarehouseResourceGateway.WarehouseReference warehouse, + LocalDateTime now) { + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) return; + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + inbound.setStatus("RECEIVED"); + inbound.setDestinationWarehouseCode(warehouse.warehouseCode()); + inbound.setDestinationWarehouseName(warehouse.warehouseName()); + inbound.setPackageCount(pallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum()); + inbound.setPalletCount(pallets.size()); + inbound.setGrossWeightKg(pallets.stream().map(WarehouseTransferPallet::getGrossWeightKg) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + inbound.setVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + + private void activateTransferInbound(String customerId, WarehouseTransferOrder transfer, LocalDateTime now) { + LastlegInboundOrder inbound = inboundOrders.selectById(transfer.getInboundOrderId()); + if (inbound == null || !customerId.equals(inbound.getCustomerId())) { + throw conflict("Linked inbound order is missing"); + } + if (!Set.of(RECEIVED, INBOUND_COMPLETED, "COMPLETED").contains(inbound.getStatus())) { + inbound.setStatus("PENDING_INBOUND"); + inbound.setUpdatedAt(now); + inboundOrders.updateById(inbound); + } + ensureTransferInboundPlan(customerId, transfer, now); + } + + private static Map> confirmedStockSources( + Map itemById, List receipts) { + Map> result = new java.util.LinkedHashMap<>(); + for (InboundStockReceipt receipt : receipts) { + WarehouseTransferItem item = itemById.get(receipt.getLineId()); + if (item == null) continue; + result.computeIfAbsent(item.getSkuCode(), ignored -> new ArrayList<>()) + .add(new ConfirmedStockSource(receipt.getInventoryBalanceId(), receipt.getInventoryPositionId(), + Math.toIntExact(receipt.getReceivedPackages()))); + } + return result; + } + @Transactional public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Allocate request) { @@ -680,6 +1089,9 @@ public class WarehouseTransferService { if (!ALLOCATED.equals(transfer.getStatus())) { throw conflict("Warehouse transfer inventory must be allocated before outbound"); } + if (!StringUtils.hasText(transfer.getFbaWarehouseCode())) { + throw conflict("A destination warehouse is required before outbound"); + } List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum() != transfer.getPackageCount()) { @@ -786,11 +1198,262 @@ public class WarehouseTransferService { return transfers.pdaCandidates(String.valueOf(overseasWarehouseId)).stream().map(value -> new WarehouseTransferResponses.Summary(value.getId(), value.getTransferNo(), value.getInboundCode(), value.getLastlegOrderId(), value.getLastlegOrderNo(), value.getOperatingWarehouseId(), - value.getFbaWarehouseCode(), value.getPackageCount(), value.getDeliveryMethod(), + value.getFbaWarehouseCode(), value.getDestinationType(), value.getDestinationWarehouseCode(), + value.getDestinationWarehouseName(), value.getPackageCount(), value.getDeliveryMethod(), Boolean.TRUE.equals(value.getNeedRelabel()), value.getRelabelNo(), value.getStatus(), value.getEstimatedArrivalAt(), value.getCreatedAt())).toList(); } + @Transactional(readOnly = true) + public WarehouseTransferResponses.Detail pdaDetail(long overseasWarehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return detail(transfer.getCustomerId(), transferNo); + } + + @Transactional + public WarehouseTransferResponses.MeasurementPhoto uploadMeasurementPhoto(String operatorId, long operatorUserId, + long overseasWarehouseId, + String transferNo, MultipartFile file) { + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { + throw conflict("Measurement photos can only be uploaded before palletization completes"); + } + if (file == null || file.isEmpty()) throw badRequest("Measurement photo is required"); + if (file.getSize() > 10L * 1024 * 1024) throw badRequest("Measurement photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) { + throw badRequest("Measurement photo must be an image"); + } + if (measurementPhotos.listOrder(transfer.getCustomerId(), transfer.getId()).size() >= 20) { + throw badRequest("A warehouse transfer supports at most 20 measurement photos"); + } + TransferMeasurementPhotoFileGateway.UploadedFile uploaded = measurementPhotoFiles.upload( + transfer.getCustomerId(), transfer.getId(), operatorUserId, file); + LocalDateTime now = LocalDateTime.now(); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setId(IdWorker.getId()); photo.setTransferOrderId(transfer.getId()); + photo.setCustomerId(transfer.getCustomerId()); photo.setFileId(uploaded.fileId()); + photo.setFileName(uploaded.fileName()); photo.setContentType(uploaded.contentType()); + photo.setSizeBytes(uploaded.sizeBytes()); photo.setUploadedBy(operatorId); photo.setUploadedAt(now); + measurementPhotos.insert(photo); + return new WarehouseTransferResponses.MeasurementPhoto(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes(), now); + } + + @Transactional(readOnly = true) + public List measurementPhotos(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return measurementPhotos.listSubmitted(customerId, transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public List pdaMeasurementPhotos(long warehouseId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + return measurementPhotos.listOrder(transfer.getCustomerId(), transfer.getId()).stream() + .map(value -> new WarehouseTransferResponses.MeasurementPhoto(value.getFileId(), value.getFileName(), + value.getContentType(), value.getSizeBytes() == null ? 0 : value.getSizeBytes(), value.getUploadedAt())) + .toList(); + } + + @Transactional(readOnly = true) + public TransferMeasurementPhotoFileGateway.DownloadedFile downloadMeasurementPhoto(String customerId, + String transferNo, + long fileId) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (measurementPhotos.findSubmittedFile(customerId, transfer.getId(), fileId) == null) { + throw notFound("Measurement photo not found"); + } + return measurementPhotoFiles.download(customerId, transfer.getId(), fileId); + } + + @Transactional(readOnly = true) + public TransferMeasurementPhotoFileGateway.DownloadedFile downloadPdaMeasurementPhoto(long warehouseId, + String transferNo, + long fileId) { + WarehouseTransferOrder transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (measurementPhotos.findFile(transfer.getCustomerId(), transfer.getId(), fileId) == null) { + throw notFound("Measurement photo not found"); + } + return measurementPhotoFiles.download(transfer.getCustomerId(), transfer.getId(), fileId); + } + + static List derivePalletAllocations(List items, + List pallets) { + if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + List result = new ArrayList<>(); + int itemIndex = 0; + int itemRemaining = items.get(0).getPackageCount(); + for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { + int palletRemaining = pallets.get(palletIndex).cartonCount(); + while (palletRemaining > 0) { + WarehouseTransferItem item = items.get(itemIndex); + if (itemIndex + 1 == items.size()) { + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining)); + break; + } + int allocated = Math.min(palletRemaining, itemRemaining); + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated)); + palletRemaining -= allocated; + itemRemaining -= allocated; + if (itemRemaining == 0 && itemIndex + 1 < items.size()) { + itemIndex++; + itemRemaining = items.get(itemIndex).getPackageCount(); + } + } + } + return result; + } + + static List derivePersistedPalletAllocations(List items, + List pallets) { + if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete"); + List result = new ArrayList<>(); + int itemIndex = 0; + int itemRemaining = items.get(0).getPackageCount(); + for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) { + int palletRemaining = pallets.get(palletIndex).getCartonCount(); + while (palletRemaining > 0) { + WarehouseTransferItem item = items.get(itemIndex); + if (itemIndex + 1 == items.size()) { + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining)); + break; + } + int allocated = Math.min(palletRemaining, itemRemaining); + result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated)); + palletRemaining -= allocated; + itemRemaining -= allocated; + if (itemRemaining == 0 && itemIndex + 1 < items.size()) { + itemIndex++; + itemRemaining = items.get(itemIndex).getPackageCount(); + } + } + } + return result; + } + + record DerivedPalletAllocation(int palletIndex, long transferItemId, int packageCount) {} + + static List newTransferPalletRequests( + long customerId, String transferNo, String fbaWarehouseCode, int palletCount) { + List result = new ArrayList<>(); + for (int index = 0; index < palletCount; index++) { + result.add(new WarehouseResourceGateway.PalletBatchItemRequest(customerId, + transferNo + ":" + (index + 1), transferNo, index + 1, "STANDARD", + trim(fbaWarehouseCode), null)); + } + return result; + } + + private static void validateDestination(String destinationType, String destinationWarehouseCode, + String legacyFbaWarehouseCode, String privateAddress, String privatePostalCode, + String privateContactName, String privateContactPhone) { + String type = trim(destinationType); + if (type == null && StringUtils.hasText(legacyFbaWarehouseCode)) type = "FBA"; + if (type == null || "NONE".equalsIgnoreCase(type)) return; + type = type.toUpperCase(java.util.Locale.ROOT); + if (!Set.of("FBA", "PRIVATE").contains(type)) throw badRequest("Unsupported destination type"); + if ("FBA".equals(type) && !StringUtils.hasText(firstText(destinationWarehouseCode, legacyFbaWarehouseCode))) { + throw badRequest("FBA destination warehouse is required"); + } + if ("PRIVATE".equals(type) && (!StringUtils.hasText(privateAddress) || !StringUtils.hasText(privatePostalCode) + || !StringUtils.hasText(privateContactName) || !StringUtils.hasText(privateContactPhone))) { + throw badRequest("Private warehouse address, contact name and phone are required"); + } + } + + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) { + String type = trim(request.destinationType()); + if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA"; + if ("NONE".equalsIgnoreCase(Objects.toString(type, ""))) type = null; + transfer.setDestinationType(type == null ? null : type.toUpperCase(java.util.Locale.ROOT)); + transfer.setDestinationWarehouseId("FBA".equals(transfer.getDestinationType()) ? trim(request.destinationWarehouseId()) : null); + transfer.setDestinationWarehouseCode("FBA".equals(transfer.getDestinationType()) ? destinationCode(request) : null); + transfer.setDestinationWarehouseName("FBA".equals(transfer.getDestinationType()) + ? firstText(request.destinationWarehouseName(), destinationCode(request)) : null); + transfer.setPrivateDestinationAddress("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationAddress()) : null); + transfer.setPrivateDestinationPostalCode("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationPostalCode()) : null); + transfer.setPrivateDestinationContactName("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationContactName()) : null); + transfer.setPrivateDestinationContactPhone("PRIVATE".equals(transfer.getDestinationType()) ? trim(request.privateDestinationContactPhone()) : null); + transfer.setFbaWarehouseCode("FBA".equals(transfer.getDestinationType()) ? transfer.getDestinationWarehouseCode() : null); + } + + private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.BindDestination request) { + String type = request.destinationType().trim().toUpperCase(java.util.Locale.ROOT); + transfer.setDestinationType(type); + transfer.setDestinationWarehouseId("FBA".equals(type) ? trim(request.destinationWarehouseId()) : null); + transfer.setDestinationWarehouseCode("FBA".equals(type) ? trim(request.destinationWarehouseCode()) : null); + transfer.setDestinationWarehouseName("FBA".equals(type) + ? firstText(request.destinationWarehouseName(), request.destinationWarehouseCode()) : null); + transfer.setPrivateDestinationAddress("PRIVATE".equals(type) ? trim(request.privateDestinationAddress()) : null); + transfer.setPrivateDestinationPostalCode("PRIVATE".equals(type) ? trim(request.privateDestinationPostalCode()) : null); + transfer.setPrivateDestinationContactName("PRIVATE".equals(type) ? trim(request.privateDestinationContactName()) : null); + transfer.setPrivateDestinationContactPhone("PRIVATE".equals(type) ? trim(request.privateDestinationContactPhone()) : null); + transfer.setFbaWarehouseCode("FBA".equals(type) ? transfer.getDestinationWarehouseCode() : null); + } + + private static boolean hasValidDestination(WarehouseTransferOrder transfer) { + if ("FBA".equals(transfer.getDestinationType())) return StringUtils.hasText(transfer.getDestinationWarehouseCode()); + if ("PRIVATE".equals(transfer.getDestinationType())) { + return StringUtils.hasText(transfer.getPrivateDestinationAddress()) + && StringUtils.hasText(transfer.getPrivateDestinationPostalCode()) + && StringUtils.hasText(transfer.getPrivateDestinationContactName()) + && StringUtils.hasText(transfer.getPrivateDestinationContactPhone()); + } + return StringUtils.hasText(transfer.getFbaWarehouseCode()); + } + + private static String destinationCode(WarehouseTransferRequests.Create request) { + return firstText(request.destinationWarehouseCode(), request.fbaWarehouseCode()); + } + + private static void copyDestination(WarehouseTransferOrder transfer, InboundPlanLine line) { + String type = hasValidDestination(transfer) ? Objects.toString(transfer.getDestinationType(), "FBA") : null; + line.setDestinationType(type); + line.setFbaWarehouseCode("FBA".equals(type) ? firstText(transfer.getDestinationWarehouseCode(), transfer.getFbaWarehouseCode()) : null); + line.setDestinationWarehouseId(transfer.getDestinationWarehouseId()); + line.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + line.setDestinationWarehouseName("PRIVATE".equals(type) ? transfer.getPrivateDestinationAddress() : transfer.getDestinationWarehouseName()); + line.setDeliveryAddress(transfer.getPrivateDestinationAddress()); + line.setPostalCode(transfer.getPrivateDestinationPostalCode()); + line.setContactName(transfer.getPrivateDestinationContactName()); + line.setContactPhone(transfer.getPrivateDestinationContactPhone()); + } + + private static void copyDestination(WarehouseTransferOrder transfer, InboundPlanPallet pallet) { + String type = hasValidDestination(transfer) ? Objects.toString(transfer.getDestinationType(), "FBA") : null; + pallet.setDestinationType(type); + pallet.setDestinationWarehouseId(transfer.getDestinationWarehouseId()); + pallet.setDestinationWarehouseCode(transfer.getDestinationWarehouseCode()); + pallet.setDestinationWarehouseName("PRIVATE".equals(type) ? transfer.getPrivateDestinationAddress() : transfer.getDestinationWarehouseName()); + pallet.setDeliveryAddress(transfer.getPrivateDestinationAddress()); + pallet.setPostalCode(transfer.getPrivateDestinationPostalCode()); + pallet.setContactName(transfer.getPrivateDestinationContactName()); + pallet.setContactPhone(transfer.getPrivateDestinationContactPhone()); + } + + private void syncInboundPlanDestination(String customerId, WarehouseTransferOrder transfer) { + InboundPlan plan = inboundPlans.findBySourceOrder(customerId, transfer.getLastlegOrderId()); + if (plan == null) throw conflict("Warehouse transfer inbound plan is missing"); + for (InboundPlanLine line : inboundPlanLines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + copyDestination(transfer, line); + inboundPlanLines.updateById(line); + } + for (InboundPlanPallet pallet : inboundPlanPallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, customerId).eq(InboundPlanPallet::getPlanId, plan.getId()))) { + copyDestination(transfer, pallet); + inboundPlanPallets.updateById(pallet); + } + } + private void validateRelabelRequest(WarehouseTransferRequests.Create request) { if (!request.needRelabel()) { if (request.relabelItems() != null && !request.relabelItems().isEmpty()) { @@ -800,30 +1463,45 @@ public class WarehouseTransferService { } if (request.relabelItems() == null || request.relabelItems().isEmpty()) return; if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName()) - || !StringUtils.hasText(request.relabelContactPhone()) || request.relabelChargeAmount() == null) { - throw badRequest("Relabel deadline, contact and charge are required"); + || !StringUtils.hasText(request.relabelContactPhone())) { + throw badRequest("Relabel deadline and contact are required"); } - Set declaredSkus = request.items().stream().map(item -> item.skuCode().trim()).collect(Collectors.toSet()); + Set declaredSkus = request.items().stream().map(item -> trim(item.skuCode())) + .filter(Objects::nonNull).collect(Collectors.toSet()); Set marks = new HashSet<>(); + Set destinations = new HashSet<>(); for (WarehouseTransferRequests.RelabelItem row : request.relabelItems()) { String type = row.operationType().trim().toUpperCase(); if (!Set.of("CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU").contains(type)) { throw badRequest("Unsupported relabel operation type"); } - if (!declaredSkus.contains(row.originalSku().trim())) { - throw badRequest("Relabel SKU must exist in transfer cargo: " + row.originalSku()); + String originalSku = trim(row.originalSku()); + boolean changesSku = "SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type); + if (changesSku && originalSku == null) { + throw badRequest("SKU relabel requires the original SKU"); } + if (originalSku != null && !declaredSkus.contains(originalSku)) { + throw badRequest("Relabel SKU must exist in transfer cargo: " + originalSku); + } + String destination = trim(row.destinationWarehouseCode()); + if (destination == null) { + throw badRequest("Warehouse transfer relabel requires one destination warehouse for the whole order"); + } + destinations.add(destination.toUpperCase()); if (("CARTON_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) - && (!StringUtils.hasText(row.fbaCartonMark()) || !StringUtils.hasText(row.destinationWarehouseCode()))) { - throw badRequest("Carton relabel requires new FBA carton mark and destination warehouse"); + && !StringUtils.hasText(row.fbaCartonMark())) { + throw badRequest("Carton relabel requires a new FBA carton mark"); } - if (("SKU_LABEL".equals(type) || "CARTON_AND_SKU".equals(type)) && !StringUtils.hasText(row.newSku())) { + if (changesSku && !StringUtils.hasText(row.newSku())) { throw badRequest("SKU relabel requires a new SKU"); } for (String mark : row.customerCartonMarks()) { if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique"); } } + if (destinations.size() != 1) { + throw badRequest("Warehouse transfer relabel only supports one destination warehouse per order"); + } if (marks.size() > request.packageCount()) throw badRequest("Relabel carton marks cannot exceed transfer cartons"); } @@ -835,7 +1513,7 @@ public class WarehouseTransferService { WarehouseTransferRelabelBinding row = new WarehouseTransferRelabelBinding(); row.setId(IdWorker.getId()); row.setTransferOrderId(transferId); row.setCustomerId(customerId); row.setOperationType(source.operationType().trim().toUpperCase()); - row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(source.originalSku().trim()); + row.setCustomerCartonMark(mark.trim()); row.setOriginalSku(Objects.toString(trim(source.originalSku()), "")); row.setNewSku(trim(source.newSku())); row.setQuantity(source.quantity()); row.setFbaCartonMark(trim(source.fbaCartonMark())); row.setDestinationWarehouseCode(trim(source.destinationWarehouseCode())); @@ -852,16 +1530,28 @@ public class WarehouseTransferService { .collect(Collectors.toMap(WarehouseTransferItem::getSkuCode, Function.identity(), (left, right) -> left)); List items = new ArrayList<>(); for (WarehouseTransferRelabelBinding binding : relabelBindings.listOrder(customerId, transfer.getId())) { - WarehouseTransferItem source = itemsBySku.get(binding.getOriginalSku()); - ConfirmedStockSource stockSource = nextStockSource(stockSourcesBySku.get(binding.getOriginalSku())); + String effectiveSku = trim(binding.getOriginalSku()); + WarehouseTransferItem source = effectiveSku == null ? null : itemsBySku.get(effectiveSku); + ConfirmedStockSource stockSource = effectiveSku == null ? null : nextStockSource(stockSourcesBySku.get(effectiveSku)); + if (effectiveSku == null && "CARTON_LABEL".equals(binding.getOperationType())) { + for (Map.Entry> entry : stockSourcesBySku.entrySet()) { + ConfirmedStockSource candidate = nextStockSource(entry.getValue()); + if (candidate != null) { + effectiveSku = entry.getKey(); + source = itemsBySku.get(effectiveSku); + stockSource = candidate; + break; + } + } + } if (source == null || stockSource == null) { - throw conflict("Relabel binding cannot find confirmed inventory for SKU " + binding.getOriginalSku()); + throw conflict("Relabel binding cannot find confirmed transfer inventory"); } List skuItems = Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(binding.getOperationType()) - ? List.of(new RelabelRequests.SkuItem(binding.getOriginalSku(), binding.getNewSku(), + ? List.of(new RelabelRequests.SkuItem(effectiveSku, binding.getNewSku(), binding.getQuantity(), binding.getRemark())) : List.of(); items.add(new RelabelRequests.Item(stockSource.balanceId, stockSource.positionId, transfer.getInboundOrderId(), - binding.getOriginalSku(), null, null, null, source.getProductNameZh(), 1, 1, + effectiveSku, null, null, null, source.getProductNameZh(), 1, 1, StringUtils.hasText(transfer.getFbaShipmentNo()) ? transfer.getFbaShipmentNo() : transfer.getTransferNo(), transfer.getTrackingNo(), binding.getNewSku(), "仓储转运自动换标", binding.getRemark(), null, null, null, binding.getCustomerCartonMark(), binding.getFbaCartonMark(), @@ -872,7 +1562,8 @@ public class WarehouseTransferService { transfer.getRelabelContactPhone(), transfer.getRelabelChargeAmount(), "仓储转运确认后自动创建换标单", "WAREHOUSE_TRANSFER:" + transfer.getTransferNo() + ":RELABEL", items, trim(transfer.getRelabelDeliveryMethod()), false); - return relabelService.create(customerId, operatorId, request); + return relabelService.createForExistingTailOrder(customerId, operatorId, + transfer.getLastlegOrderId(), request); } private static ConfirmedStockSource nextStockSource(List sources) { @@ -898,34 +1589,48 @@ public class WarehouseTransferService { } } - private void createDispatchPlan(String customerId, WarehouseTransferOrder transfer, - List items, List pallets, - List palletItems, LocalDateTime now) { + private InboundPlan ensureTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, + LocalDateTime now) { + InboundPlan existing = inboundPlans.findBySourceOrder(customerId, transfer.getLastlegOrderId()); + if (existing != null) return existing; long planId = IdWorker.getId(); InboundPlan plan = new InboundPlan(); plan.setId(planId); plan.setPlanNo("WT-" + transfer.getTransferNo()); plan.setCustomerId(customerId); plan.setSourceOrderId(transfer.getLastlegOrderId()); plan.setContainerNo(transfer.getTransferNo()); plan.setOverseasWarehouseId(Long.parseLong(transfer.getOperatingWarehouseId())); plan.setPlannedArrivalDate(transfer.getEstimatedArrivalAt().toLocalDate()); plan.setPalletType("STANDARD"); - plan.setAssignedFlag(true); plan.setMergeRequired(false); plan.setUrgentFlag(false); - plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Customer-confirmed transfer measurements"); - plan.setStatus("COMPLETED"); plan.setLineCount(items.size()); plan.setPalletCount(pallets.size()); - plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) - .reduce(BigDecimal.ZERO, BigDecimal::add)); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); + plan.setAssignedFlag(false); plan.setMergeRequired(false); plan.setUrgentFlag(false); + plan.setCreationMode("WAREHOUSE_TRANSFER"); plan.setPlanRemark("Warehouse transfer awaiting PDA receiving"); + plan.setStatus("PDA_PENDING"); plan.setLineCount(1); plan.setPalletCount(0); + plan.setTotalVolumeCbm(BigDecimal.ZERO); plan.setCreatedAt(now); plan.setUpdatedAt(now); plan.setVersion(0); inboundPlans.insert(plan); - Map lineIds = new java.util.HashMap<>(); - for (WarehouseTransferItem source : items) { - InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); - line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); - line.setDestinationType("FBA"); line.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); - line.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); line.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); - line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(source.getPackageCount()); - line.setPlannedPalletCount((int) palletItems.stream().filter(row -> row.getTransferItemId().equals(source.getId())).count()); - inboundPlanLines.insert(line); lineIds.put(source.getId(), line.getId()); - } + InboundPlanLine line = new InboundPlanLine(); line.setId(IdWorker.getId()); line.setCustomerId(customerId); + line.setPlanId(planId); line.setOrderId(transfer.getLastlegOrderId()); line.setInboundCode(transfer.getTransferNo()); + copyDestination(transfer, line); + line.setVolumeCbm(BigDecimal.ZERO); line.setRequiredPackageCount(transfer.getPackageCount()); + line.setPlannedPalletCount(0); inboundPlanLines.insert(line); + return plan; + } + + private void completeTransferInboundPlan(String customerId, WarehouseTransferOrder transfer, + List pallets, + List palletItems, + LocalDateTime now) { + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) return; + InboundPlan plan = ensureTransferInboundPlan(customerId, transfer, now); + if ("COMPLETED".equals(plan.getStatus())) return; + InboundPlanLine line = inboundPlanLines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .eq(InboundPlanLine::getOrderId, transfer.getLastlegOrderId())); + if (line == null) throw conflict("Warehouse transfer inbound plan line is missing"); + copyDestination(transfer, line); + line.setVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + line.setPlannedPalletCount(pallets.size()); + inboundPlanLines.updateById(line); for (WarehouseTransferPallet source : pallets) { InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(IdWorker.getId()); pallet.setCustomerId(customerId); - pallet.setPlanId(planId); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); - pallet.setDestinationWarehouseCode(transfer.getFbaWarehouseCode()); pallet.setDestinationWarehouseName(transfer.getFbaWarehouseCode()); + pallet.setPlanId(plan.getId()); pallet.setPalletNo(source.getPalletNo()); pallet.setPalletType("STANDARD"); + copyDestination(transfer, pallet); pallet.setAllocatedVolumeCbm(source.getVolumeCbm()); pallet.setSequenceNo(source.getSequenceNo()); pallet.setWarehousePalletId(source.getWarehousePalletId()); pallet.setWarehouseLocationId(source.getWarehouseLocationId()); pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED"); @@ -934,13 +1639,21 @@ public class WarehouseTransferService { for (WarehouseTransferPalletItem sourceItem : palletItems.stream() .filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) { InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); - allocation.setCustomerId(customerId); allocation.setPlanId(planId); allocation.setLineId(lineIds.get(sourceItem.getTransferItemId())); + allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO); allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount()); - allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(source.getVolumeCbm()); + // PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数” + // 直接汇总该字段,不能再保留初始化时的 0。 + allocation.setPickedPackageCount(sourceItem.getPackageCount()); + allocation.setActualVolumeCbm(source.getVolumeCbm()); allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation); } } + plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size()); + plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setPlanRemark("Warehouse transfer PDA receiving completed"); plan.setUpdatedAt(now); + inboundPlans.updateById(plan); } private static List splitMarks(String value) { @@ -970,7 +1683,7 @@ public class WarehouseTransferService { inbound.setPalletCount(0); inbound.setVolumeCbm(request.volumeCbm()); inbound.setSourceEventId(sourceId); inbound.setSourceLoadingPlanId(ORDER_TYPE); inbound.setSourceLoadingPlanVersion(1L); inbound.setHandoverVersion(1L); - inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(PENDING_REVIEW); + inbound.setPayloadHash("sha256:" + requestHash); inbound.setStatus(DRAFT); inbound.setExceptionFlag("NONE"); inbound.setCreatedAt(now); inbound.setUpdatedAt(now); inboundOrders.insert(inbound); return inbound.getId(); @@ -984,14 +1697,14 @@ public class WarehouseTransferService { order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER"); order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); - order.setFbaWarehouseCode(trim(request.fbaWarehouseCode())); order.setPackageCount(request.packageCount()); + order.setFbaWarehouseCode(destinationCode(request)); order.setPackageCount(request.packageCount()); order.setVolumeCbm(request.volumeCbm()); order.setContainerNo(transferNo); order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); - order.setCurrentStatus(PENDING_REVIEW); + order.setCurrentStatus(DRAFT); order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED"); - order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + order.getRelabelStatus()); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus()); order.setCustomerRemark(trim(request.remark())); order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); @@ -1003,7 +1716,8 @@ public class WarehouseTransferService { LocalDateTime now) { WarehouseTransferItem item = new WarehouseTransferItem(); item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); - item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED")); + item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物")); item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); transferItems.insert(item); return item.getId(); @@ -1025,8 +1739,8 @@ public class WarehouseTransferService { LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setCustomerId(customerId); detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); - detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); - detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(trim(request.fbaWarehouseCode())); + detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh())); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(destinationCode(request)); detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null); detail.setShipmentNo(trim(request.fbaShipmentNo())); @@ -1059,6 +1773,7 @@ public class WarehouseTransferService { } } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); } private static long packages(Long value) { return value == null ? 0L : value; } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java index a33e914af..ad70a8558 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") -@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING + "')") public class MerchantUsTruckOrderController { @Resource private UsTruckOrderService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody UsTruckOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid UsTruckOrderRequests.MerchantQuery query) { return ApiResponse.success(service.page(customer(user), query.scoped())); } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { return ApiResponse.success(service.detail(customer(user), orderNo)); } @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')") public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 01e04f1c7..07e47ba18 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -76,9 +76,9 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} domestic-file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9205} + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e92e0168c..81285ffcb 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + received_package_count INT NULL COMMENT 'PDA实际入库箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', @@ -267,14 +268,19 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号', - dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', - dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号', + dispatch_order_id BIGINT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + dispatch_no VARCHAR(64) NULL COMMENT '关联派车单号,仓储转运直接出库时为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照', - destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照', + destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓派送地址', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态', waybill_no VARCHAR(128) NULL COMMENT '承运运单号', @@ -318,6 +324,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name')); PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name')); +PREPARE delivery_private_destination_stmt FROM @ddl; EXECUTE delivery_private_destination_stmt; DEALLOCATE PREPARE delivery_private_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); +PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''')); +PREPARE delivery_transfer_nullable_stmt FROM @ddl; EXECUTE delivery_transfer_nullable_stmt; DEALLOCATE PREPARE delivery_transfer_nullable_stmt; SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound'); SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination'); SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)')); @@ -599,7 +611,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', - creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运', plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', @@ -648,6 +660,10 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', PRIMARY KEY (id), @@ -682,6 +698,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', sequence_no INT NOT NULL COMMENT '计划内托盘序号', @@ -721,8 +742,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +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(32) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'' AFTER urgent_flag')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +ALTER TABLE ll_inbound_plan MODIFY COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'; +UPDATE ll_inbound_plan_pallet_allocation a +JOIN ll_inbound_plan p ON p.id=a.plan_id +SET a.picked_package_count=COALESCE(a.counted_package_count,a.planned_package_count,0) +WHERE p.creation_mode='WAREHOUSE_TRANSFER' AND p.status='COMPLETED' + AND COALESCE(a.picked_package_count,0)=0 + AND COALESCE(a.counted_package_count,a.planned_package_count,0)>0; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; ALTER TABLE ll_inbound_plan @@ -1176,6 +1204,32 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( KEY idx_ll_fba_appointment_item_order (order_id) ) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; +-- Upgrade the pre-customer-scope appointment table created by older deployments. +SET @ddl = (SELECT IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inbound_fba_appointment' AND column_name='customer_id') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inbound_fba_appointment' AND column_name='tenant_id'), + 'ALTER TABLE ll_inbound_fba_appointment CHANGE COLUMN tenant_id customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', + 'SELECT 1')); +PREPARE fba_appointment_customer_scope FROM @ddl; EXECUTE fba_appointment_customer_scope; DEALLOCATE PREPARE fba_appointment_customer_scope; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN expected_loading_at DATETIME(3) NULL COMMENT ''预计装车时间'' AFTER appointment_at')); +PREPARE fba_appointment_expected_loading FROM @ddl; EXECUTE fba_appointment_expected_loading; DEALLOCATE PREPARE fba_appointment_expected_loading; + +UPDATE ll_inbound_fba_appointment +SET expected_loading_at=appointment_at +WHERE expected_loading_at IS NULL; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inbound_fba_appointment' AND column_name='expected_loading_at' AND is_nullable='YES'), + 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN expected_loading_at DATETIME(3) NOT NULL COMMENT ''预计装车时间''', + 'SELECT 1')); +PREPARE fba_appointment_expected_loading_required FROM @ddl; EXECUTE fba_appointment_expected_loading_required; DEALLOCATE PREPARE fba_appointment_expected_loading_required; + ALTER TABLE ll_loading_order_status_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', @@ -1329,7 +1383,14 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', + volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照', @@ -1346,6 +1407,8 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); 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_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); 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_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_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_dispatch_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg')); 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_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1447,6 +1510,8 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', 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 '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', @@ -1461,7 +1526,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', 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 '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '毛重(千克)', volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', volume_cbm DECIMAL(18,4) 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 '入仓单号快照', 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,inbound_code,sku_code), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) @@ -1502,6 +1567,8 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER pallet_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg, ADD COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)'' AFTER volume_weight_kg')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg, ADD COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)'' AFTER volume_weight_kg')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU''')); @@ -1521,7 +1588,11 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + destination_type VARCHAR(16) NULL COMMENT '目的类型:FBA或PRIVATE', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人', + private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', @@ -1559,6 +1630,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at')); PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); +PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name')); +PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式''')); PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no')); @@ -1567,6 +1642,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by')); PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希''')); +PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_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='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address')); +PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_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='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE dispatch_item_postal_stmt FROM @ddl; EXECUTE dispatch_item_postal_stmt; DEALLOCATE PREPARE dispatch_item_postal_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', @@ -1603,18 +1692,22 @@ PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carto CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号', - warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', - warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', - warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', + warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', + warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', - volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态', - completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', + generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', + bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key')); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', @@ -1625,6 +1718,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填'; +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at')); +PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt; + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 16fea794d..c0d00b457 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -42,16 +42,20 @@ paths: /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 - description: 权限 lastleg:pda:operate;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度;每个入仓码仍按自身客户隔离。 + description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次,允许物理集装箱号在不同柜次重复使用;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] - parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + parameters: + - { name: containerNo, in: path, required: true, schema: { type: string } } + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、当前作业柜全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] - parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + parameters: + - { name: containerNo, in: path, required: true, schema: { type: string } } + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } } /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: @@ -126,21 +130,21 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: post: summary: FBA 入仓码按箱数收货 - description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。 + description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,单托最多 66 箱;服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划但未超出单托上限的部分只记差异,不生成可用库存。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } - responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } } + responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、单托超过66箱、版本变化或托盘不匹配 } } /api/pda/v1/last-leg/receiving-containers/current/receipts/carton: post: summary: 海外仓入仓码逐箱收货 - description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。 + description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,单托最多 66 箱,拒绝跨柜、重复箱和不匹配托盘。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } } /api/pda/v1/last-leg/receiving-containers/current/receipts/pallet: post: summary: 一件代发按完整来源托盘收货 - description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。 + description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;单托最多 66 箱,所有客户事件分片清单一致且齐全后才允许收货。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } } @@ -185,7 +189,7 @@ paths: /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 - description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -206,7 +210,7 @@ paths: /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 - description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } @@ -232,7 +236,7 @@ paths: /api/platform/v1/last-leg/orders/{id}/hold: put: summary: 设置或解除尾程订单暂扣 - description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。仅完成入库且尚未进入出库流程的订单允许新增暂扣;解除暂扣不受该状态限制。暂扣只作用于当前票并仅阻断排车和出库,不影响入库计划或同柜其他订单。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } @@ -245,7 +249,7 @@ paths: schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' } responses: '200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } } - '409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 } + '409': { description: 版本冲突、幂等键冲突、重复状态、尚未完成入库或订单已进入出库流程 } /api/platform/v1/last-leg/orders/{id}/manual-nodes: get: summary: 查询尾程订单人工展示节点 @@ -463,7 +467,7 @@ paths: /api/platform/v1/last-leg/inbound-plans: get: summary: 平台分页查询海外入库订单 - description: 权限 platform:overseas-warehouse:inbound-order:list;列表中的 overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,warehouseCode/warehouseName 仍表示目的仓。 + description: 权限 platform:overseas-warehouse:inbound-order:list;overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实,并按入库计划冻结接柜仓ID从海外仓权威目录解析;warehouseCode/warehouseName 仍表示目的仓。 security: [{ bearerAuth: [] }] parameters: - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } @@ -496,7 +500,7 @@ paths: /api/platform/v1/last-leg/inbound-plans/manual: post: summary: 平台按单票手工生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 + description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、真实入仓码、客户归属及有效公共接柜仓。暂扣只限制排车和出库,不阻断入库计划。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 security: [{ bearerAuth: [] }] parameters: - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } @@ -508,7 +512,7 @@ paths: responses: '200': { description: 统一 ApiResponse 包装的入库计划详情 } '400': { description: 客户、入仓码、仓库主数据或请求字段无效 } - '409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 } + '409': { description: 订单版本、状态、柜级预计到仓时间、重复计划或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/convert-container: post: summary: 按整柜批量生成剩余入库计划 @@ -959,16 +963,85 @@ paths: security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } } + /api/merchant/v1/last-leg/orders: + get: + summary: 客户分页查询自己的海外尾程订单 + description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } } + /api/merchant/v1/last-leg/orders/{id}: + get: + summary: 客户查询自己的海外尾程订单详情 + description: 权限 merchant:overseas-warehouse:last-mile-order:list;不泄露其他客户订单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }] + responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } } + /api/merchant/v1/last-leg/warehouse-transfers: + get: + summary: 客户分页查询自己的仓储转运单 + description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } } + post: + summary: 客户创建仓储转运单 + description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } } + /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}: + get: + summary: 客户查询自己的仓储转运单详情 + description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。 + security: [{ bearerAuth: [] }] + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户仓储转运单详情 }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 } } + /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement: + post: + summary: 客户确认仓储转运实测数据 + description: 权限 merchant:overseas-warehouse:transfer:confirm;仅当前客户满足状态门禁的转运单可确认。 + security: [{ bearerAuth: [] }] + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 确认后的仓储转运单 }, '403': { description: 缺少实测确认权限 }, '409': { description: 状态或版本冲突 } } + /api/merchant/v1/last-leg/cargo-discards: + get: + summary: 客户分页查询自己的货物废弃申请 + description: 权限 merchant:overseas-warehouse:discard:list;列表权限包含详情和库存候选只读。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前客户货物废弃申请分页 }, '403': { description: 缺少货物废弃查看权限 } } + post: + summary: 客户创建货物废弃申请 + description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 创建后的货物废弃申请 }, '403': { description: 缺少货物废弃创建权限 }, '409': { description: 库存或状态冲突 } } + /api/merchant/v1/last-leg/cargo-discards/inventory-candidates: + get: + summary: 查询当前客户可废弃库存候选 + description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 可废弃库存候选 }, '403': { description: 缺少货物废弃查看权限 } } + /api/merchant/v1/last-leg/cargo-discards/{discardNo}: + get: + summary: 客户查询自己的货物废弃申请详情 + description: 权限 merchant:overseas-warehouse:discard:list;不泄露其他客户申请。 + security: [{ bearerAuth: [] }] + parameters: [{ name: discardNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 当前客户货物废弃申请详情 }, '403': { description: 缺少货物废弃查看权限 }, '404': { description: 当前客户下申请不存在 } } + /api/merchant/v1/last-leg/cargo-discards/{discardNo}/cancel: + post: + summary: 客户取消自己的货物废弃申请 + description: 权限 merchant:overseas-warehouse:discard:cancel;仅满足状态门禁的申请可取消。 + security: [{ bearerAuth: [] }] + parameters: [{ name: discardNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 取消后的货物废弃申请 }, '403': { description: 缺少货物废弃取消权限 }, '409': { description: 状态冲突 } } /api/merchant/v1/last-leg/overseas-procurements: post: summary: 客户创建海外采购订单 - description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 + description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } } get: summary: 客户分页查询自己的海外采购订单 - description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -984,14 +1057,14 @@ paths: /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: get: summary: 客户查询自己的海外采购订单详情及流水 - description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。 + description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } /api/merchant/v1/last-leg/us-truck-orders: get: summary: 客户分页查询自己的美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:domestic-trucking:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1005,7 +1078,7 @@ paths: responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } } post: summary: 客户创建美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。 + description: 权限 merchant:cross-border:domestic-trucking:create;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } } @@ -1013,19 +1086,19 @@ paths: parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] get: summary: 客户查询自己的美国本土卡订单详情 - description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。 + description: 权限 merchant:cross-border:domestic-trucking:list;不泄露其他客户订单。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } patch: summary: 客户编辑待审核的美国本土卡订单 - description: 权限 merchant:cross-border:domestic-trucking;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。 + description: 权限 merchant:cross-border:domestic-trucking:edit;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } /api/merchant/v1/last-leg/local-looseboard-orders: get: summary: 客户分页查询自己的本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1039,7 +1112,7 @@ paths: responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } } post: summary: 客户创建本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 + description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } } @@ -1047,12 +1120,12 @@ paths: parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] get: summary: 客户查询自己的本土散板订单详情 - description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。 + description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。 security: [{ bearerAuth: [] }] responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } put: summary: 客户编辑待接单的本土散板订单 - description: 权限 merchant:cross-border:pallet-shipping;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 + description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } } @@ -1310,7 +1383,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/inventory-candidates: get: summary: 查询客户可换标库存候选 - description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。 + description: 权限 merchant:overseas-warehouse:relabel:list;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。 security: [{ bearerAuth: [] }] parameters: - { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } } @@ -1321,7 +1394,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications: get: summary: 分页查询客户换标申请 - description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 customerId。 + description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token,不接收 customerId。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1337,7 +1410,7 @@ paths: '200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } } post: summary: 创建客户换标申请 - description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 + description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } responses: @@ -1357,7 +1430,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}: get: summary: 查询客户换标申请详情 - description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。 + description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] responses: @@ -1366,7 +1439,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label: post: summary: 上传换标快递面单 - description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 + description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } @@ -1374,10 +1447,21 @@ paths: '200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } } '400': { description: 文件类型或大小不合法 } '409': { description: 当前状态不允许上传 } + /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/files: + post: + summary: 上传客户换标总文件或明细文件 + description: 权限 merchant:overseas-warehouse:relabel:files;文件只能绑定到当前客户的换标申请。 + security: [{ bearerAuth: [] }] + parameters: + - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } + - { name: scope, in: query, required: true, schema: { type: string, enum: [CARTON_TOTAL, SKU_TOTAL, ITEM] } } + - { name: itemId, in: query, required: false, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: 更新后的换标详情 }, '403': { description: 缺少换标文件维护权限 }, '409': { description: 状态或归属冲突 } } /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download: get: summary: 下载换标快递面单 - description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。 + description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载当前申请已绑定的面单文件。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } @@ -1388,7 +1472,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download: get: summary: 下载换标完成照片 - description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。 + description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载该申请 PDA 完工证据中的照片。 security: [{ bearerAuth: [] }] parameters: - { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } } @@ -1399,7 +1483,7 @@ paths: /api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion: post: summary: 客户确认换标完成 - description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。 + description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。 security: [{ bearerAuth: [] }] parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } } @@ -1409,7 +1493,7 @@ paths: /api/merchant/v1/last-leg/return-orders: get: summary: 客户分页查询退货签收订单 - description: 客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。 + description: 权限 merchant:overseas-warehouse:return-order:list;客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -1421,17 +1505,31 @@ paths: responses: { '200': { description: 当前客户退货订单分页 } } post: summary: 客户创建退货预报订单 - description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 + description: 权限 merchant:overseas-warehouse:return-order:create;客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } } responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } } /api/merchant/v1/last-leg/return-orders/{returnNo}: get: summary: 客户查询退货订单与签收批次 - description: 返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。 + description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。 security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } + /api/merchant/v1/last-leg/return-orders/{returnNo}/relabel-candidates: + get: + summary: 查询退货单可转换标候选 + description: 权限 merchant:overseas-warehouse:return-order:relabel;只返回当前客户退货单的可转换标明细。 + security: [{ bearerAuth: [] }] + parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 可转换标候选 }, '403': { description: 缺少退货转换标权限 }, '404': { description: 当前客户下退货单不存在 } } + /api/merchant/v1/last-leg/return-orders/{returnNo}/relabel: + post: + summary: 将退货库存转换为换标申请 + description: 权限 merchant:overseas-warehouse:return-order:relabel;只能处理当前客户且满足状态门禁的退货库存。 + security: [{ bearerAuth: [] }] + parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 创建后的换标申请 }, '403': { description: 缺少退货转换标权限 }, '409': { description: 状态、库存或幂等冲突 } } /api/pda/v1/last-leg/return-receipts/start: post: summary: PDA 以退件单号开始签收 @@ -1950,7 +2048,7 @@ components: properties: inboundCode: { type: string, minLength: 1 } palletCode: { type: string, minLength: 1 } - packageCount: { type: integer, minimum: 1 } + packageCount: { type: integer, minimum: 1, maximum: 66 } expectedPalletVersion: { type: integer, minimum: 0, nullable: true } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } ReceivingCarton: @@ -2162,10 +2260,12 @@ components: planNo: { type: string, nullable: true } orderType: { type: string, nullable: true } volumeWeightKg: { type: number, nullable: true } + grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重(千克);按入库计划分配比例汇总,缺失时回退体积重 } containerNo: { type: string, nullable: true } appointmentAt: { type: string, format: date-time, nullable: true } expectedLoadingAt: { type: string, format: date-time, nullable: true } overseasWarehouseId: { type: string } + overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码,用于智能排车区域筛选 } overseasWarehouseName: { type: string } orderNo: { type: string } orderRefs: { type: string } @@ -2176,6 +2276,8 @@ components: destinationWarehouseName: { type: string } fbaWarehouseCode: { type: string } packageCount: { type: integer } + grossWeightKg: { type: number, description: 排车托盘冻结毛重(千克) } + volumeWeightKg: { type: number, description: 排车托盘冻结材积重(千克) } volumeCbm: { type: number } sourceStage: { type: string, enum: [PLAN, RECEIVED] } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } @@ -2230,12 +2332,13 @@ components: palletIds: { type: array, items: { type: string } } DispatchUpdate: type: object - description: 普通编辑字段;不包含 status、departAt 或路线,路线由已排托盘关联的 FBA 仓自动汇总。 + description: 普通编辑字段;不包含 status 或路线,路线由已排托盘关联的 FBA 仓自动汇总。 properties: vehiclePlate: { type: string, nullable: true, maxLength: 64 } driverName: { type: string, nullable: true, maxLength: 128 } driverPhone: { type: string, nullable: true, maxLength: 64 } carrierName: { type: string, nullable: true, maxLength: 255 } + departAt: { type: string, format: date-time, nullable: true } fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32 } vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32 } @@ -2375,7 +2478,7 @@ components: items: { type: string, minLength: 1, maxLength: 128 } OneClickOutboundCandidate: type: object - required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages] + required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages, grossWeightKg, volumeWeightKg, volumeCbm] properties: inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$', description: 创建命令提交键,不作为用户展示编号 } overseasWarehouseId: { type: string } @@ -2385,6 +2488,9 @@ components: skuCode: { type: string } productName: { type: string, nullable: true } availablePackages: { type: integer, format: int64, minimum: 1, description: 库存位置、SKU余额和AVAILABLE箱唛三者的可用最小值 } + grossWeightKg: { type: number, minimum: 0, description: 当前可出箱数按装柜实装箱数比例分摊的毛重 } + volumeWeightKg: { type: number, minimum: 0, description: 当前可出箱数按装柜实装箱数比例分摊的材积重 } + volumeCbm: { type: number, minimum: 0, description: 当前可出箱数按装柜实装箱数比例分摊的体积 } OutboundCancel: type: object required: [reason] @@ -2531,6 +2637,19 @@ components: availablePackages: { type: integer, format: int64 } version: { type: integer, format: int64 } positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } + sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] } + returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 } + cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 } + skuItems: + type: array + description: 退货箱内 SKU 与件数 + items: + type: object + properties: + skuCode: { type: string } + newSkuCode: { type: string, nullable: true } + quantity: { type: integer, minimum: 1 } + remark: { type: string, nullable: true } RelabelInventoryPosition: type: object properties: @@ -2559,10 +2678,10 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } RelabelCreate: type: object - required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] properties: customerOrderNo: { type: string, maxLength: 128 } - overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 } + operationWarehouseId: { type: string, maxLength: 128, description: 当前库存所在海外操作仓 } labelDeadline: { type: string, format: date-time } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } @@ -2576,6 +2695,10 @@ components: type: object required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark] properties: + sourceInventoryBalanceId: { type: string, nullable: true, description: 普通入仓库存来源;与 returnCartonId 二选一 } + sourceInventoryPositionId: { type: string, nullable: true, description: 普通入仓库存仓位;与 returnCartonId 二选一 } + sourceInboundOrderId: { type: string, nullable: true, description: 普通入仓单;与 returnCartonId 二选一 } + returnCartonId: { type: string, nullable: true, description: 仓库合箱产生的退货箱库存;与普通入仓库存字段二选一 } skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) } productImageFileId: { type: string, nullable: true, maxLength: 128 } productImageFileName: { type: string, nullable: true, maxLength: 255 } @@ -2892,7 +3015,7 @@ components: properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } } InboundPlanSummary: type: object - required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, palletCount, assigned, mergeAllowed, urgent, status, createdAt, version] + required: [lineId, planId, planNo, customerId, orderId, inboundCode, overseasWarehouseCode, overseasWarehouseName, packageCount, pickedPackageCount, grossWeightKg, netWeightKg, palletCount, assigned, mergeAllowed, urgent, status, createdAt, version] properties: lineId: { type: string, pattern: '^[0-9]+$' } planId: { type: string, pattern: '^[0-9]+$' } @@ -2901,8 +3024,9 @@ components: orderId: { type: string, pattern: '^[0-9]+$' } inboundCode: { type: string } orderType: { type: string, nullable: true } - overseasWarehouseCode: { type: string, description: 接柜仓编码,取自尾程订单预分配接收仓事实 } - overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 } + overseasWarehouseId: { type: string, pattern: '^[0-9]+$', nullable: true, description: 入库计划冻结的接柜海外仓ID,不是FBA目的仓ID } + overseasWarehouseCode: { type: string, description: 按入库计划冻结接柜仓ID从海外仓权威目录解析的编码 } + overseasWarehouseName: { type: string, description: 按入库计划冻结接柜仓ID从海外仓权威目录解析的名称 } warehouseId: { type: string, nullable: true } warehouseCode: { type: string, nullable: true } warehouseName: { type: string, nullable: true } @@ -2910,6 +3034,8 @@ components: customerName: { type: string, nullable: true } packageCount: { type: integer } pickedPackageCount: { type: integer } + grossWeightKg: { type: number, description: 头程冻结货物毛重(千克) } + netWeightKg: { type: number, description: 头程冻结货物净重(千克) } volumeCbm: { type: number, nullable: true } palletCount: { type: integer } assigned: { type: boolean, description: 入库计划是否已分配 } @@ -2925,7 +3051,7 @@ components: version: { type: integer } InboundPlanDetail: type: object - required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version, createdAt, updatedAt] + required: [id, planNo, customerId, overseasWarehouseCode, overseasWarehouseName, status, lineCount, palletCount, totalGrossWeightKg, totalNetWeightKg, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } planNo: { type: string } @@ -2940,6 +3066,8 @@ components: status: { type: string } lineCount: { type: integer } palletCount: { type: integer } + totalGrossWeightKg: { type: number, description: 入库单关联头程货物毛重合计(千克) } + totalNetWeightKg: { type: number, description: 入库单关联头程货物净重合计(千克) } totalVolumeCbm: { type: number } expectedPackageCount: { type: integer } pickedPackageCount: { type: integer } @@ -3033,6 +3161,8 @@ components: destinationWarehouseId: { type: string, nullable: true } destinationWarehouseCode: { type: string, nullable: true } destinationWarehouseName: { type: string, nullable: true } + grossWeightKg: { type: number, description: 本入仓码头程冻结货物毛重(千克) } + netWeightKg: { type: number, description: 本入仓码头程冻结货物净重(千克) } volumeCbm: { type: number } requiredPackageCount: { type: integer } plannedPalletCount: { type: integer } diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index bb904ef34..f1ae643ca 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -163,12 +163,32 @@ paths: summary: 扫码查询托盘及库存内容 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } } + /api/pda/v1/last-leg/pallet-operations/relabel-sources: + post: + summary: 查询换标原入仓单在各托盘上的库存数量 + description: 范围取Token冻结仓和换标单客户;返回库存组成而非历史箱唛定位。movablePackages不包括其他单据冻结、已分配或已拣货箱数。cartonMarks为同来源库存池候选,不代表这些箱历史上位于该托盘。 + requestBody: + required: true + content: + application/json: + schema: { type: object, required: [relabelNo], properties: { relabelNo: { type: string, maxLength: 64 } } } + responses: + '200': + description: 原入仓单托盘组成 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { type: array, items: { $ref: '#/components/schemas/RelabelSource' } } + '409': { description: 状态、认领人或仓库不符 } /api/pda/v1/last-leg/pallet-operations/relabel-pallet-label: post: summary: 为换标拆托幂等生成并返回可打印的新托盘标签 description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } } - responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } } + responses: { '200': { description: 新托盘及可打印标签,已认领处理中无需客户确认即可创建 }, '409': { description: 状态、认领人或Token绑定仓不符 } } /api/pda/v1/last-leg/pallet-operations/assemble: post: summary: 将多个来源托盘的指定库存行组到目标托盘 @@ -182,7 +202,7 @@ paths: /api/pda/v1/last-leg/pallet-operations/relabel-disassemble: post: summary: 按换标箱唛拆托并绑定目标托盘 - description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。 + description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。 requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } } responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } } /api/pda/v1/last-leg/pallet-operations/swap: @@ -218,6 +238,27 @@ paths: parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/skus/scan: + post: + summary: 仅扫描旧SKU完成当前箱的一条SKU换标明细 + description: itemId与skuIndex精确指定一条明细,服务端验证旧SKU并读取已批准的新SKU及件数;同旧SKU多箱由PDA选择目标,不批量隐式完成。逐条写SKU_SCAN历史并按轮次防重,全部SKU处理后完成SKU阶段,混合单仍须完成箱唛阶段。响应history包含旧新SKU、件数及索引,供提示与隐藏已处理明细。 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [itemId, skuIndex, oldSku, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + skuIndex: { type: integer, minimum: 0 } + oldSku: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + responses: + '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } + '409': { description: 已处理、映射不符、认领人或版本冲突 } /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: post: summary: 登记一件有原因的跳过事实 @@ -260,6 +301,107 @@ paths: parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/candidates: + get: + summary: 查询Token绑定仓待测量的仓储转运单 + description: 仅返回当前PDA冻结海外仓内已审核且等待测量打托的转运单;不接收customerId或warehouseId。 + responses: + '200': + description: 待测量转运单列表 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferSummaryListResponse' } } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}: + get: + summary: 查询当前仓仓储转运测量详情 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: + '200': + description: 转运申报、已生成的新托盘及测量照片;items仅为服务端自动分摊结果,PDA不提交每托SKU。 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } } + '404': { description: 当前Token绑定仓无此转运单 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files: + get: + summary: 查询当前转运单已上传的测量现场照片 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 照片元数据列表 } + post: + summary: 上传仓储转运测量现场照片 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: { file: { type: string, format: binary } } + responses: + '200': { description: 已绑定当前转运单的照片元数据 } + '404': { description: 当前Token绑定仓无此转运单 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files/{fileId}/download: + get: + summary: 鉴权下载仓储转运测量现场照片 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: 照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } } + '404': { description: 照片不属于当前转运单或绑定仓 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/palletize: + post: + summary: 完成仓储转运整单测量打托 + description: 只提交当前转运单照片和整单幂等键;所有托盘必须已逐托生成并扫码绑定,箱数必须核平。此接口不接收测量字段、托盘码或每托SKU,也绝不再次生成托盘。 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletize' } } } } + responses: + '200': + description: WAIT_CUSTOMER_CONFIRMATION详情 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } } + '409': { description: 状态、照片归属、箱数核平或幂等冲突 } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts: + post: + summary: 新建或更新一个转运测量托盘草稿 + description: palletId为空时创建DRAFT;传入既有DRAFT的palletId时更新。GENERATED及以后测量字段锁定,必须拒绝修改。 + parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletDraft' } } } } + responses: { '200': { description: 包含持久化草稿的最新整单详情 }, '409': { description: 草稿已生成、状态或幂等冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}: + delete: + summary: 删除未生成的转运托盘草稿 + description: 只允许删除DRAFT;GENERATED、BOUND和COMPLETED不得删除。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 删除后的整单详情 }, '409': { description: 本托已经生成真实托盘 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/generate: + post: + summary: 为测量草稿生成一个全新实物托盘 + description: 只允许DRAFT调用一次;服务端始终向Warehouse Overseas传空palletCode,不能复用已有托盘。有FBA则写入目的仓,无FBA则标签显示“目的仓待定”。成功后实测字段锁定且可立即下载PDF标签。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletAction' } } } } + responses: { '200': { description: 包含GENERATED新托盘号和标签路径的最新详情 }, '409': { description: 已生成或资源生成冲突 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/bind: + post: + summary: 扫描并确认本托新生成的托盘码 + description: 扫描码必须精确等于本草稿服务端生成码,同时验证Warehouse Overseas物理托盘ID和Token冻结仓一致;其他已有托盘不得冒充。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletBind' } } } } + responses: { '200': { description: 包含BOUND事实和boundAt的最新详情 }, '409': { description: 扫描码、归属、仓库或状态不匹配 } } + /api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{warehousePalletId}/label: + get: + summary: 下载仓储转运实物托盘PDF标签 + description: 校验Token冻结仓、转运单和托盘归属后,代理Warehouse Overseas使用统一托盘标签打印服务生成真实PDF。已知FBA显示目的仓编码,未知FBA显示“目的仓待定”。 + parameters: + - { name: transferNo, in: path, required: true, schema: { type: string } } + - { name: warehousePalletId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: X-Request-Id, in: header, required: false, schema: { type: string } } + responses: + '200': { description: 托盘标签PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } + '404': { description: 转运单或托盘不属于当前Token绑定仓 } components: securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } @@ -274,6 +416,107 @@ components: requestId: { type: string, nullable: true } traceId: { type: string, nullable: true } timestamp: { type: string, format: date-time, nullable: true } + WarehouseTransferSummaryListResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferSummary' } } + WarehouseTransferDetailResponse: + allOf: + - { $ref: '#/components/schemas/ApiResponse' } + - type: object + properties: { data: { $ref: '#/components/schemas/WarehouseTransferDetail' } } + WarehouseTransferSummary: + type: object + required: [id, transferNo, operatingWarehouseId, packageCount, deliveryMethod, needRelabel, status] + properties: + id: { type: integer, format: int64 } + transferNo: { type: string } + inboundCode: { type: string, nullable: true } + operatingWarehouseId: { type: string } + fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 } + packageCount: { type: integer, minimum: 1 } + deliveryMethod: { type: string } + needRelabel: { type: boolean } + relabelNo: { type: string, nullable: true } + status: { type: string } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time, nullable: true } + WarehouseTransferDetail: + type: object + required: [id, transferNo, operatingWarehouseId, packageCount, status, pallets, measurementPhotos] + properties: + id: { type: integer, format: int64 } + transferNo: { type: string } + operatingWarehouseId: { type: string } + fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 } + pickupContactName: { type: string, nullable: true } + pickupContactPhone: { type: string, nullable: true } + packageCount: { type: integer, minimum: 1 } + status: { type: string } + pallets: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferPallet' } } + measurementPhotos: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferMeasurementPhoto' } } + WarehouseTransferPalletize: + type: object + required: [idempotencyKey, photoFileIds] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + photoFileIds: + type: array + minItems: 1 + maxItems: 20 + uniqueItems: true + items: { type: integer, format: int64, minimum: 1 } + WarehouseTransferPalletDraft: + type: object + required: [idempotencyKey, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg] + properties: + palletId: { type: integer, format: int64, nullable: true, description: 更新现有DRAFT时传入;创建时不传 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + cartonCount: { type: integer, minimum: 1 } + lengthCm: { type: number, format: double, minimum: 0.01 } + widthCm: { type: number, format: double, minimum: 0.01 } + heightCm: { type: number, format: double, minimum: 0.01 } + volumeCbm: { type: number, format: double, minimum: 0.000001 } + grossWeightKg: { type: number, format: double, minimum: 0.01 } + WarehouseTransferPalletAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + WarehouseTransferPalletBind: + type: object + required: [palletCode, idempotencyKey] + properties: + palletCode: { type: string, minLength: 1, maxLength: 128 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + WarehouseTransferPallet: + type: object + required: [id, sequenceNo, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg, status] + properties: + id: { type: integer, format: int64 } + sequenceNo: { type: integer, minimum: 1 } + warehousePalletId: { type: integer, format: int64, nullable: true } + palletNo: { type: string, nullable: true, description: GENERATED后由Warehouse Overseas为本次测量新生成 } + cartonCount: { type: integer, minimum: 1 } + lengthCm: { type: number, format: double } + widthCm: { type: number, format: double } + heightCm: { type: number, format: double } + volumeCbm: { type: number, format: double } + grossWeightKg: { type: number, format: double } + status: { type: string, enum: [DRAFT, GENERATED, BOUND, COMPLETED] } + fbaWarehouseCode: { type: string, nullable: true, description: 为空时标签显示目的仓待定 } + labelDownloadPath: { type: string, nullable: true, description: GENERATED后返回当前PDA鉴权下的真实PDF下载路径 } + boundAt: { type: string, format: date-time, nullable: true } + WarehouseTransferMeasurementPhoto: + type: object + required: [fileId, sizeBytes, uploadedAt] + properties: + fileId: { type: integer, format: int64 } + fileName: { type: string, nullable: true } + contentType: { type: string, nullable: true } + sizeBytes: { type: integer, format: int64, minimum: 0 } + uploadedAt: { type: string, format: date-time } StocktakeSummaryPageResponse: allOf: - { $ref: '#/components/schemas/ApiResponse' } @@ -732,7 +975,19 @@ components: source: { $ref: '#/components/schemas/PalletExpectation' } target: { $ref: '#/components/schemas/PalletExpectation' } cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } } + actualSource: { $ref: '#/components/schemas/PalletMoveLine' } reason: { type: string, maxLength: 500 } + RelabelSource: + type: object + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + positionVersion: { type: integer, format: int64 } + palletCode: { type: string } + locationCode: { type: string, nullable: true } + inboundCode: { type: string } + stockPackages: { type: integer, format: int64 } + movablePackages: { type: integer, format: int64 } + cartonMarks: { type: array, items: { type: string } } PalletSwap: type: object required: [idempotencyKey, source, target] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index 0af56242b..d3bd1ed4e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -67,6 +67,7 @@ class DeliveryLongIdJsonContractTest { null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null, - com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null); + com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null, + null, null, null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index 42155e065..f7af714d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -112,7 +112,7 @@ class PlatformDispatchOutboundContractTest { assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) .noneMatch(method -> "depart".equals(method.getName()))); assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName", - "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"), + "departAt", "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"), Arrays.stream(DispatchRequests.Update.class.getRecordComponents()) .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 1251a4f31..2d119917d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; class DeliveryCompletedOutboxServiceTest { @Test @@ -48,6 +49,23 @@ class DeliveryCompletedOutboxServiceTest { .containsExactly("ONT8", "ACY2"); } + @Test + void skipsRelabelApplicationWithoutDomesticSourceOrder() { + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService(); + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule())); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(1L); delivery.setDeliveryNo("DL-RELABEL"); delivery.setVersion(2L); + LastlegOrder relabel = new LastlegOrder(); + relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION"); + relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1"); + + service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z")); + + verifyNoInteractions(mapper); + } + private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) { LastlegOrder value = new LastlegOrder(); value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 64226bf1e..30f8f4b93 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -142,6 +142,8 @@ class DeliveryTransferServiceTest { assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); assertEquals(2, outbound.getValue().getPalletCount()); assertEquals(20, outbound.getValue().getPackageCount()); + assertEquals(new BigDecimal("20"), outbound.getValue().getGrossWeightKg()); + assertEquals(new BigDecimal("2.0"), outbound.getValue().getVolumeCbm()); assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}")); assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId()))); @@ -152,6 +154,10 @@ class DeliveryTransferServiceTest { .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() .map(OutboundPalletItem::getInboundCode).toList()); + assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), pallets.getAllValues().stream() + .map(OutboundPalletItem::getGrossWeightKg).toList()); + assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), delivery.getAllValues().stream() + .map(DeliveryOrder::getTotalWeightKg).toList()); assertEquals(20, result.packageCount()); assertEquals(2, result.palletCount()); assertEquals(2, result.deliveries().size()); @@ -485,6 +491,40 @@ class DeliveryTransferServiceTest { assertEquals(2, order.getValue().getPackageCount()); } + @Test + void oneClickOutboundAllocatesLoadedMetricsBySelectedPackages() { + DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "DP-SPLIT", "SKU-1", 4L); + candidate.setSourcePackageCount(4L); + candidate.setSourceGrossWeightKg(new BigDecimal("40.000")); + candidate.setSourceVolumeWeightKg(new BigDecimal("80.000")); + candidate.setSourceVolumeCbm(new BigDecimal("4.0000")); + when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "SKU-1")).thenReturn(candidate); + when(dropshipCartons.lockAvailablePhysical("TENANT-A", 1001L, 2L)) + .thenReturn(List.of(new DropshipCarton(), new DropshipCarton())); + InventoryPosition position = new InventoryPosition(); + position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L); + position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("DP-SPLIT"); + InventoryBalance balance = new InventoryBalance(); + balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-SPLIT"); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + + service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1")), null, "split-metrics")); + + ArgumentCaptor item = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundPallets).insert(item.capture()); + assertEquals(new BigDecimal("20.000"), item.getValue().getGrossWeightKg()); + assertEquals(new BigDecimal("40.000"), item.getValue().getVolumeWeightKg()); + assertEquals(new BigDecimal("2.0000"), item.getValue().getVolumeCbm()); + ArgumentCaptor order = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).updateById(order.capture()); + assertEquals(2, order.getValue().getPackageCount()); + assertEquals(new BigDecimal("20.000"), order.getValue().getGrossWeightKg()); + assertEquals(new BigDecimal("40.000"), order.getValue().getVolumeWeightKg()); + assertEquals(new BigDecimal("2.0000"), order.getValue().getVolumeCbm()); + } + @Test void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); @@ -557,6 +597,9 @@ class DeliveryTransferServiceTest { assertEquals("DS-A002", result.get(0).palletNo()); assertEquals("SKU-1", result.get(0).skuCode()); assertEquals(3L, result.get(0).availablePackages()); + assertEquals(new BigDecimal("30.000"), result.get(0).grossWeightKg()); + assertEquals(new BigDecimal("60.000"), result.get(0).volumeWeightKg()); + assertEquals(new BigDecimal("3.0000"), result.get(0).volumeCbm()); verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1"); verify(inventoryPositions, never()).selectById(any()); } @@ -929,6 +972,9 @@ class DeliveryTransferServiceTest { value.setDestinationWarehouseId("DEST-" + destinationCode); value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + value.setGrossWeightKg(BigDecimal.valueOf(packages)); + value.setVolumeWeightKg(BigDecimal.valueOf(packages * 2L)); + value.setVolumeCbm(BigDecimal.valueOf(packages, 1)); value.setSourceStage("RECEIVED"); return value; } @@ -972,7 +1018,11 @@ class DeliveryTransferServiceTest { DropshipOutboundCandidate value = new DropshipOutboundCandidate(); value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L); value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku); - value.setAvailablePackages(packages); return value; + value.setAvailablePackages(packages); value.setSourcePackageCount(packages); + value.setSourceGrossWeightKg(BigDecimal.valueOf(packages * 10L)); + value.setSourceVolumeWeightKg(BigDecimal.valueOf(packages * 20L)); + value.setSourceVolumeCbm(BigDecimal.valueOf(packages)); + return value; } private static DropshipCartonSku cartonSku(String sku, long quantity) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 7cb4ac1b5..4ff57ebcf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -12,10 +12,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class DispatchCandidateMapperContractTest { private static final List CONSTRUCTOR_ALIASES = List.of( "planId", "planPalletId", "warehousePalletId", "orderId", "customerId", "customerName", - "planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", - "overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", + "planNo", "orderType", "volumeWeightKg", "grossWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt", + "overseasWarehouseId", "overseasWarehouseCode", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo", "destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode", - "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks" + "packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks", + "destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone" ); @Test @@ -37,6 +38,29 @@ class DispatchCandidateMapperContractTest { assertTrue(sql.contains("pos.available_packages > 0")); } + @Test + void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL")); + } + } + + @Test + void candidateKeywordSearchIncludesSourceInboundCode() throws Exception { + Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class, + String.class, String.class, String.class, String.class); + String availableSql = String.join(" ", available.getAnnotation(Select.class).value()); + assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')")); + + Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class); + String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value()); + assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')")); + } + @Test void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception { for (Method method : List.of( @@ -46,7 +70,12 @@ class DispatchCandidateMapperContractTest { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL")); assertTrue(sql.contains("pos.overseas_warehouse_id")); - assertTrue(sql.contains("CAST(ip.overseas_warehouse_id AS CHAR)")); + assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')")); + assertTrue(sql.contains("source_order.id=ip.source_order_id")); + assertTrue(sql.contains("source_order.customer_id=ip.customer_id")); + assertTrue(sql.contains("source_order.overseas_warehouse_id")); + assertTrue(sql.contains("AS overseasWarehouseCode")); + assertTrue(sql.contains("b.overseas_warehouse_code")); assertTrue(sql.contains("ll_inventory_balance")); } } @@ -65,6 +94,22 @@ class DispatchCandidateMapperContractTest { } } + @Test + void candidateGrossWeightIsAllocatedByPalletAndFallsBackToVolumeWeight() throws Exception { + for (Method method : List.of( + DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, + String.class, String.class, String.class), + DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + assertTrue(sql.contains("SUM(d.gross_weight_kg)")); + assertTrue(sql.contains("inbound.gross_weight_kg")); + assertTrue(sql.contains("ll_inbound_order inbound")); + assertTrue(sql.contains("a.allocated_volume_cbm")); + assertTrue(sql.contains("a2.line_id=a.line_id")); + assertTrue(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg")); + } + } + private void assertConstructorColumnOrder(Method method) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); int previous = -1; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 24fac6a54..57ec44595 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -31,12 +31,15 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); + assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码")); + assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重")); String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"), yaml.indexOf(" SmartDispatchDraftPallet:")); String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"), yaml.indexOf(" DeliveryTransfer:")); assertFalse(preArrangeSchema.contains("route:")); assertFalse(updateSchema.contains("route:")); + assertTrue(updateSchema.contains("departAt: { type: string, format: date-time, nullable: true }")); assertTrue(yaml.contains("name: planIds")); String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"), yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index f40235374..b0c28e5e6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -190,6 +190,22 @@ class DispatchServiceTest { verify(orders, never()).insert(any(DispatchOrder.class)); } + @Test + void preArrangeRejectsPalletWithoutFbaWarehouse() { + DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L, + "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001", + null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED"); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + @Test void removingPalletFromOwnedDraftImmediatelyReleasesLock() { when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); @@ -307,6 +323,14 @@ class DispatchServiceTest { verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1"); } + @Test + void relabelSourceDoesNotRequireOrQueryAnInboundPlan() { + when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of()); + org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty()); + verify(candidates).relabelAvailable(null, "RL-1", null); + verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); + } + @Test void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); @@ -346,13 +370,16 @@ class DispatchServiceTest { DispatchItem ont8 = item(11L, "ONT8"); DispatchItem abe4 = item(12L, "ABE4"); when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8"))); + LocalDateTime departAt = LocalDateTime.of(2026, 9, 18, 10, 30); DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, - null, null, null, LocalDateTime.now()); + departAt, null, null, null, LocalDateTime.now()); DispatchResponse result = service.update("PC1", request); assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); assertEquals("ABC-1", result.vehiclePlate()); + assertEquals(departAt, result.departAt()); + assertEquals(departAt, dispatch.getDepartAt()); assertEquals("ONT8,ABE4", result.route()); assertEquals("ONT8,ABE4", dispatch.getRoute()); verify(orders).updateById(dispatch); @@ -366,7 +393,7 @@ class DispatchServiceTest { dispatch.setStatus("DEPARTED"); when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, - null, null, null, null); + null, null, null, null, null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.update("PC1", request)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 0e3609ce5..1f2689cc4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -28,8 +28,11 @@ class InboundPlanSchemaContractTest { assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); assertTrue(schema.contains("expected_loading_at")); + assertTrue(schema.contains("change column tenant_id customer_id")); + assertTrue(schema.contains("set expected_loading_at=appointment_at")); assertTrue(schema.contains("planned_arrival_date")); - assertTrue(schema.contains("creation_mode")); + assertTrue(schema.contains("creation_mode varchar(32)")); + assertTrue(schema.contains("modify column creation_mode varchar(32)")); assertTrue(schema.contains("pallet_type")); assertTrue(schema.contains("create table if not exists ll_order_command_record")); assertTrue(schema.contains("create table if not exists ll_order_hold_history")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java index a595bfbbc..cb5fe1ea8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java @@ -16,6 +16,9 @@ class InboundPlanQueryMapperContractTest { String sql = String.join(" ", select.value()); assertTrue(sql.contains("p.version version")); + assertTrue(sql.contains("l.order_id orderId,l.inbound_code inboundCode,o.lx_code lxCode,o.order_type orderType")); + assertTrue(sql.contains("p.overseas_warehouse_id overseasWarehouseId")); + assertTrue(sql.contains("p.overseas_warehouse_id=#{q.overseasWarehouseId}")); assertTrue(sql.contains("WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 788d0657f..8a53f13d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -153,6 +153,8 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3")); assertTrue(yaml.contains("required: [role, fileId, downloadPath]")); + assertTrue(yaml.contains("packageCount: { type: integer, minimum: 1, maximum: 66 }")); + assertTrue(receivingPaths.contains("单托最多 66 箱")); assertTrue(yaml.contains("overseasWarehouseId: { type: string")); assertTrue(yaml.contains("overseasWarehouseName: { type: string")); assertFalse(receivingPaths.contains("photoFileIds")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index bc2e38cc8..7aa26fc05 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -49,6 +49,7 @@ 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.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -116,7 +117,7 @@ class ReceivingContainerServiceBehaviorTest { when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0); assertThat(service.candidates("7", 100L)).isEmpty(); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("not-ready"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -132,7 +133,7 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); - var response = service.prepare("7", "receiver", 100L, "CONT-A", + var response = service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("same-key")); assertThat(response.overseasWarehouseId()).isEqualTo(100L); assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); @@ -144,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest { verify(operations).insert(saved.capture()); when(operations.find("7", "same-key")).thenReturn(saved.getValue()); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-A", new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("409"); } @@ -248,13 +249,44 @@ class ReceivingContainerServiceBehaviorTest { when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); var candidate = service.candidates("7", 100L).get(0); - var detail = service.detail("7", 100L, "TSTU0000204"); + var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204"); assertThat(candidate.operable()).isTrue(); assertThat(detail.overseasWarehouseId()).isEqualTo(100L); assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX"); } + @Test + void detailUsesBookingOccurrenceWhenPhysicalContainerNumberIsReused() { + InboundPlan historicalPlan = plan("VAXU2748524"); + historicalPlan.setId(2L); historicalPlan.setPlanNo("PLAN-OLD"); historicalPlan.setStatus("COMPLETED"); + InboundPlan currentPlan = plan("VAXU2748524"); + currentPlan.setId(3L); currentPlan.setPlanNo("PLAN-CURRENT"); + InboundPlanLine historicalLine = line("FBA"); + historicalLine.setId(10L); historicalLine.setPlanId(2L); historicalLine.setOrderId(1L); + historicalLine.setInboundCode("IN-OLD"); + InboundPlanLine currentLine = line("FBA"); + currentLine.setId(11L); currentLine.setPlanId(3L); currentLine.setOrderId(2L); + currentLine.setInboundCode("IN-CURRENT"); + LastlegOrder historicalOrder = containerOrder( + 1L, "100", "VAXU2748524", "BOOKING-OLD", "IN-OLD"); + LastlegOrder currentOrder = containerOrder( + 2L, "100", "VAXU2748524", "BOOKING-CURRENT", "IN-CURRENT"); + when(plans.selectList(any())).thenReturn(List.of(historicalPlan, currentPlan)); + when(lines.selectList(any())).thenReturn( + List.of(currentLine), + List.of(historicalLine), List.of(currentLine), + List.of(historicalLine), List.of(currentLine)); + when(orders.selectList(any())).thenReturn(List.of(currentOrder)); + when(orders.selectById(1L)).thenReturn(historicalOrder); + when(orders.selectById(2L)).thenReturn(currentOrder); + when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); + + var detail = service.detail("7", 100L, "VAXU2748524", "BOOKING-CURRENT"); + + assertThat(detail.inbounds()).extracting("inboundCode").containsExactly("IN-CURRENT"); + } + @Test void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() { InboundPlan unassigned = plan("TSTU0195472"); @@ -268,7 +300,7 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectById(1L)).thenReturn(order); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); - var result = service.prepare("7", "receiver", 100L, "TSTU0195472", + var result = service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472", new ReceivingContainerRequests.Action("bind-warehouse")); assertThat(result.overseasWarehouseId()).isEqualTo(100L); @@ -284,9 +316,10 @@ class ReceivingContainerServiceBehaviorTest { when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(planLine)); when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(pallets.selectCount(any())).thenReturn(0L); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472", new ReceivingContainerRequests.Action("missing-real-pallets"))) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("尚未完整绑定真实托盘"); @@ -338,7 +371,7 @@ class ReceivingContainerServiceBehaviorTest { assertThat(candidate.pendingPlanCount()).isEqualTo(1); assertThat(candidate.operable()).isFalse(); assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码"); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("partial"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); @@ -350,12 +383,14 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); + when(orders.selectById(1L)).thenReturn( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")); ReceivingContainerSession other = session("CONT-B", "SIGNED"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setActiveContainerKey("CONTAINER:CONT-B"); when(sessions.findActiveByContainer("CONTAINER:CONT-B")).thenReturn(other); - assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); } @@ -366,8 +401,10 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); + when(orders.selectById(1L)).thenReturn( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } @@ -377,14 +414,15 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"); when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @@ -422,6 +460,55 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(workflow); } + @Test + void fbaPalletAccepts66PackagesAndRejects67BeforeReceivingStarts() { + stubCurrent("FBA", "CONT-A"); + InboundPlanLine fbaLine = line("FBA"); + fbaLine.setRequiredPackageCount(66); + when(lines.selectList(any())).thenReturn(List.of(fbaLine)); + when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 66))); + when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of()); + stubReservedFbaPallet(); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenAnswer(invocation -> invocation.getArgument(4)); + when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var accepted = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 66, "capacity-66")); + + assertThat(accepted.acceptedPackages()).isEqualTo(66); + + org.mockito.Mockito.reset(sessions, workflow, warehouse); + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 67, "capacity-67"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("66箱"); + verifyNoInteractions(sessions, workflow, warehouse); + } + + @Test + void overseasCartonIsRejectedWhenPhysicalPalletAlreadyContains66Packages() { + stubCurrent("OVERSEAS", "CONT-A"); + when(cartons.selectOne(any())).thenReturn(carton(1L, "IN-1#67")); + InboundPlanPallet reserved = planPallet(); + reserved.setPalletNo("PAL-1"); reserved.setWarehousePalletId(30L); reserved.setWarehouseLocationId(40L); + reserved.setWarehouseResourceLockId(50L); reserved.setAssignmentStatus("RESERVED"); + when(pallets.selectList(any())).thenReturn(List.of(reserved)); + when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L)) + .thenReturn(resource(100L, null)); + when(cartons.selectCount(any())).thenReturn(66L); + + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", + new ReceivingContainerRequests.CartonReceive("IN-1#67", "PAL-1", "carton-capacity"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("66箱"); + + verify(workflow, never()).validateCartonResolved(any(), any(), any(), any(), any()); + verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); + } + @Test void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() { stubCurrent("OVERSEAS", "CONT-A"); @@ -625,7 +712,8 @@ class ReceivingContainerServiceBehaviorTest { void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); - when(lines.selectList(any())).thenReturn(List.of()); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectById(1L)).thenReturn(order("FBA")); when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( @@ -950,6 +1038,29 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(warehouse); } + @Test + void dropshipWholePalletOver66PackagesIsRejectedBeforePicking() { + stubCurrent("OVERSEAS", "CONT-A"); + LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectById(1L)).thenReturn(dropship); + InboundPlanPallet pallet = planPallet(); + pallet.setPalletNo("DP-260909-0067"); pallet.setSourcePalletNo("DP-260909-0067"); + pallet.setWarehousePalletId(30L); + InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation(); + pending.setId(67L); pending.setCustomerId("100"); pending.setPlanId(2L); + pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(67); + when(pallets.selectList(any())).thenReturn(List.of(pallet)); + when(allocations.selectList(any())).thenReturn(List.of(pending)); + stubCompleteManifest("DP-260909-0067", 67); + + assertThatThrownBy(() -> service.receiveWholePallet("7", "receiver", + new ReceivingContainerRequests.WholePalletReceive("DP-260909-0067", "whole-capacity"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("66箱"); + + verify(workflow, never()).pickWholePallet(any(), any(), anyLong(), any()); + } + @Test void domesticLoadingPhotoDownloadIsScopedToTheCurrentPhysicalContainer() { ReceivingContainerSession current = session("CONT-A", "SIGNED"); @@ -973,9 +1084,12 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); LastlegOrder order = order(destinationType); + order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A"); + order.setInboundCode("IN-1"); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); + org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order)); when(orders.selectById(1L)).thenReturn(order); } @@ -1003,6 +1117,8 @@ class ReceivingContainerServiceBehaviorTest { private static LastlegOrder order(String destinationType) { LastlegOrder value = new LastlegOrder(); value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType); + value.setContainerNo("CONT-A"); value.setSourceContainerBookingId("BOOKING-A"); + value.setInboundCode("IN-1"); value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null); @@ -1027,6 +1143,7 @@ class ReceivingContainerServiceBehaviorTest { private static ReceivingContainerSession session(String containerNo, String status) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7"); + value.setSourceContainerBookingId("BOOKING-A"); value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setStatus(status); value.setVersion(0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 1838590df..fc143f7b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -345,7 +345,7 @@ class ContainerInboundPlanConversionCoordinatorTest { when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2"); when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( - "MSCU6639870-101-001", "MSCU6639870-101-002")); + "D0004-101-001", "D0004-101-002")); ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", 1L, 2L, BOOKING_ID, "FIRST-KEY", request, @@ -359,7 +359,7 @@ class ContainerInboundPlanConversionCoordinatorTest { throw new AssertionError(exception); } - assertEquals(List.of("MSCU6639870-101-001", "MSCU6639870-101-002", "MSCU6639870-101-003"), + assertEquals(List.of("D0004-101-001", "D0004-101-002", "D0004-101-003"), preparation.items().stream().map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList()); } @@ -376,7 +376,7 @@ class ContainerInboundPlanConversionCoordinatorTest { 1, 1, 3, 2, 1, 3, 3, 3, List.of(order)))); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( - "MSCU6639870-101-001", "MSCU6639870-101-002")); + "D10000-101-001", "D10000-101-002")); when(plans.existingPlanIdentity("100", 11L)) .thenReturn(new InboundPlanService.PlanIdentity(21L, "IP-MANUAL", 3)); @@ -434,7 +434,7 @@ class ContainerInboundPlanConversionCoordinatorTest { private InboundPlanResponse plan(String planNo, List pallets) { return new InboundPlanResponse(21L, planNo, "100", 11L, "FL-WH-1", "验收客户", "ONE_CLICK_DROPSHIP", "MSCU6639870", "US-LAX-01", - "Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0, + "Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.TEN, new BigDecimal("8"), BigDecimal.ONE, 10, 0, List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false, false, false, "MANUAL", "自动生成", null, null); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java index a0e37a1fa..ccd9e9fd8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -85,6 +85,22 @@ class ContainerInboundPlanPreviewTest { assertFalse(result.ready()); } + @Test + void heldHistoricalOrderRemainsReadyBecauseHoldOnlyBlocksOutbound() { + when(orderMapper.containerPreviewOrders("BOOKING-HELD")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-HELD", "FO-1", + "测试客户", "FIRST_LEG", "FBA", "IN-1", "PENDING_ASSIGNMENT", null, + "MSCU6639870", 1, 10, new BigDecimal("1.0000"), "193000000000200003", "US-LAX-01", + "洛杉矶仓", "ACY2", null, null, "Amazon ACY2", 0, true, 1, + null, null, null, null, 1))); + + var result = service.previewContainer("BOOKING-HELD"); + + assertEquals(true, result.ready()); + assertEquals("READY", result.groups().get(0).orders().get(0).readiness()); + assertEquals(List.of(), result.groups().get(0).orders().get(0).blockers()); + } + @Test void dropshipPreviewKeepsUnderlyingFbaFactSeparateFromItsDisplayCategory() { when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP")).thenReturn(List.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 84eeda997..3db84f19c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import jakarta.validation.Validation; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,12 +23,14 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundPlanPaginationTest { @Mock InboundPlanQueryMapper queryMapper; + @Mock WarehouseResourceGateway warehouseGateway; private InboundPlanService service; @BeforeEach void setUp() { service = new InboundPlanService(); ReflectionTestUtils.setField(service, "queryMapper", queryMapper); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); } @Test @@ -48,19 +51,23 @@ class InboundPlanPaginationTest { null, "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED", LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59)); InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100", - 3L, "IN-1", null, "FIRST_LEG", "US-LAX-01", "洛杉矶仓", "W1", "ONT", "安大略仓", - "ONT8", "客户A", 10, 0, BigDecimal.ONE, 1, true, true, true, "PENDING_ASSIGNMENT", null, null, null, + 3L, "IN-1", null, "FIRST_LEG", 193000000000200003L, "ACY2", "错误目的仓", "W1", "ONT", "安大略仓", + "ONT8", "客户A", 10, 0, new BigDecimal("30"), new BigDecimal("22"), BigDecimal.ONE, + 1, true, true, true, "PENDING_ASSIGNMENT", null, null, null, null, "UNSCHEDULED", LocalDateTime.of(2026, 8, 20, 10, 0), 0); when(queryMapper.count("100", query)).thenReturn(11L); when(queryMapper.rows("100", query, 10, 10)).thenReturn(List.of(row)); + when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference(193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); var result = service.page("100", query); - assertThat(result.records()).containsExactly(row); + assertThat(result.records()).containsExactly(row.withReceivingWarehouse("US-LAX-01", "洛杉矶仓")); assertThat(result.records().get(0).lineId()).isEqualTo(9007199254740993L); assertThat(result.records().get(0).appointmentStatus()).isNull(); assertThat(result.records().get(0).overseasWarehouseCode()).isEqualTo("US-LAX-01"); assertThat(result.records().get(0).overseasWarehouseName()).isEqualTo("洛杉矶仓"); + assertThat(result.records().get(0).overseasWarehouseId()).isEqualTo(193000000000200003L); assertThat(result.records().get(0).assigned()).isTrue(); assertThat(result.records().get(0).mergeAllowed()).isTrue(); assertThat(result.records().get(0).urgent()).isTrue(); @@ -76,11 +83,12 @@ class InboundPlanPaginationTest { .getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER; assertThat(sql).contains("a2.customer_id=#{customerId}", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1"); assertThat(sql).contains("di.customer_id=#{customerId} AND di.order_id=l.order_id"); - assertThat(sql).contains("o.overseas_warehouse_code overseasWarehouseCode", + assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode", "o.overseas_warehouse_name overseasWarehouseName", - "o.overseas_warehouse_id=#{q.overseasWarehouseId}"); + "p.overseas_warehouse_id=#{q.overseasWarehouseId}"); assertThat(sql).contains("p.assigned_flag assigned", "p.merge_required mergeAllowed", "p.urgent_flag urgent"); + assertThat(sql).contains("grossWeightKg", "netWeightKg", "ll_order_detail"); assertThat(sql).doesNotContain("l.destination_warehouse_id=#{q.overseasWarehouseId}"); assertThat(sql).contains("q.planNo", "q.inboundCode", "q.status", "q.overseasWarehouseId", "q.destinationWarehouseCode", "q.warehouse", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 1fd408f2c..b810f9981 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -352,21 +352,23 @@ class InboundPlanServiceTest { } @Test - void refusesManualPlanForHeldOrderBeforeWriting() { + void createsManualPlanForHeldHistoricalOrderBecauseHoldOnlyBlocksOutbound() { SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", "PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0, "193000000000200003", "US-LAX-01", "洛杉矶仓", null, - null, "WH-A", "仓A", 1, 0, true, 5); + "WH-A", "WH-A", "仓A", 1, 0, true, 5); when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); + when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(5), any())) + .thenReturn(1); - ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( + var result = service.convertManual( new ManualInboundPlanRequest("100", 2L, 5, 1, "STANDARD", false, false, false, null), - "manual-held", "900")); + "manual-held", "900"); - assertEquals(409, error.getStatusCode().value()); - verify(planMapper, never()).insert(any(InboundPlan.class)); - verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); + assertEquals(2L, result.sourceOrderId()); + verify(planMapper).insert(any(InboundPlan.class)); + verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(5), any()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index ab4d4b9b1..8066aad96 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -88,6 +88,21 @@ class InventorySchemaContractTest { void balanceQueriesHideZeroRowsAndMutationsKeepWarehouseDimensionCanonical() throws Exception { assertTrue(InventoryQueryMapper.BALANCE_FILTER.contains("procurement_packages)>0")); assertTrue(InventoryQueryMapper.JOINED_BALANCE_FILTER.contains("b.procurement_packages)>0")); + String balancesSql = String.join(" ", InventoryQueryMapper.class + .getMethod("balances", String.class, InventoryQueryRequests.BalanceQuery.class, long.class, long.class) + .getAnnotation(Select.class).value()); + assertTrue(balancesSql.contains("SUM(available_packages)")); + assertTrue(balancesSql.contains("GROUP BY customer_id,overseas_warehouse_id,destination_warehouse_id,destination_warehouse_code,sku_code")); + assertTrue(balancesSql.contains("NULL inbound_order_id")); + String balanceCountSql = String.join(" ", InventoryQueryMapper.class + .getMethod("countBalances", String.class, InventoryQueryRequests.BalanceQuery.class) + .getAnnotation(Select.class).value()); + assertTrue(balanceCountSql.contains("FROM (SELECT 1 FROM ll_inventory_balance")); + assertTrue(balanceCountSql.contains("GROUP BY customer_id,overseas_warehouse_id,destination_warehouse_id,destination_warehouse_code,sku_code")); + String sourceBalancesSql = String.join(" ", InventoryQueryMapper.class + .getMethod("sourceBalances", String.class, InventoryQueryRequests.BalanceQuery.class, long.class, long.class) + .getAnnotation(Select.class).value()); + assertFalse(sourceBalancesSql.contains("GROUP BY")); String bindSql = String.join(" ", com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper.class .getMethod("bindInboundPlan", String.class, String.class, Long.class, String.class, String.class, java.time.LocalDateTime.class) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 96323994c..1dee12878 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -280,6 +280,23 @@ class InventoryCommandServiceTest { verify(balances, never()).updateById(any(InventoryBalance.class)); } + @Test + void holdsAllAvailablePositionsForOneInboundOrder() { + InventoryBalance balance = balance(200L, 5); + InventoryPosition position = position(201L, 200L, 5); + when(commands.lockInboundAvailablePositions("100", 101L)).thenReturn(List.of(position)); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + + service.holdInboundOrderInventory("100", "7", 101L, "ORD-1", "质检暂扣"); + + assertEquals(0L, position.getAvailablePackages()); + assertEquals(5L, position.getHoldPackages()); + assertEquals(0L, balance.getAvailablePackages()); + assertEquals(5L, balance.getHoldPackages()); + verify(ledgers).insert(any(InventoryLedger.class)); + } + @Test void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception { var request = stockIn("IN-1", "stock-in-1", 12, 24); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index 5003effd8..3a8053b2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -207,7 +207,7 @@ class InventoryQueryServiceTest { @Test void balanceExportUsesInboundOrderPackageColumnsOnly() { InventoryQueryRequests.BalanceQuery query = balanceQuery(null); - when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of()); + when(mapper.sourceBalances("100", query, 0, 10_000)).thenReturn(List.of()); String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index affa0f795..c9092655e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -1,14 +1,19 @@ package com.baiyue.lastleg.merchant; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.controller.MerchantLastlegOrderController; import com.baiyue.lastleg.inboundplan.controller.MerchantInboundPlanController; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.discard.controller.MerchantCargoDiscardController; import com.baiyue.lastleg.looseboard.controller.MerchantLocalLooseboardController; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; import com.baiyue.lastleg.procurement.controller.MerchantOverseasProcurementController; +import com.baiyue.lastleg.relabel.controller.MerchantRelabelApplicationController; +import com.baiyue.lastleg.returnreceipt.controller.MerchantReturnOrderController; import com.baiyue.lastleg.security.OverseasPermissions; +import com.baiyue.lastleg.transfer.controller.MerchantWarehouseTransferController; import com.baiyue.lastleg.trucking.controller.MerchantUsTruckOrderController; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.server.ResponseStatusException; import java.util.Arrays; +import java.util.Map; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -43,15 +49,57 @@ class MerchantOverseasCustomerApiContractTest { } @Test - void merchantControllersUseFrozenPathsAndExistingClientPermissionCodes() { - assertController(MerchantOverseasProcurementController.class, - "/api/merchant/v1/last-leg/overseas-procurements", OverseasPermissions.MERCHANT_PROCUREMENT); - assertController(MerchantUsTruckOrderController.class, - "/api/merchant/v1/last-leg/us-truck-orders", OverseasPermissions.MERCHANT_TRUCKING); - assertController(MerchantLocalLooseboardController.class, - "/api/merchant/v1/last-leg/local-looseboard-orders", OverseasPermissions.MERCHANT_LOOSEBOARD); + void merchantControllersUseFrozenPathsAndOperationPermissions() { + assertPath(MerchantOverseasProcurementController.class, "/api/merchant/v1/last-leg/overseas-procurements"); + assertPermissions(MerchantOverseasProcurementController.class, Map.of( + "create", OverseasPermissions.MERCHANT_PROCUREMENT_CREATE, + "page", OverseasPermissions.MERCHANT_PROCUREMENT_LIST, + "detail", OverseasPermissions.MERCHANT_PROCUREMENT_LIST)); + assertPath(MerchantUsTruckOrderController.class, "/api/merchant/v1/last-leg/us-truck-orders"); + assertPermissions(MerchantUsTruckOrderController.class, Map.of( + "create", OverseasPermissions.MERCHANT_TRUCKING_CREATE, + "page", OverseasPermissions.MERCHANT_TRUCKING_LIST, + "detail", OverseasPermissions.MERCHANT_TRUCKING_LIST, + "update", OverseasPermissions.MERCHANT_TRUCKING_EDIT)); + assertPath(MerchantLocalLooseboardController.class, "/api/merchant/v1/last-leg/local-looseboard-orders"); + assertPermissions(MerchantLocalLooseboardController.class, Map.of( + "create", OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE, + "page", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST, + "detail", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST, + "update", OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT)); assertController(MerchantInboundPlanController.class, "/api/merchant/v1/last-leg/inbound-plans", OverseasPermissions.MERCHANT_INBOUND_LIST); + assertController(MerchantLastlegOrderController.class, + "/api/merchant/v1/last-leg/orders", OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST); + + assertPermissions(MerchantWarehouseTransferController.class, Map.of( + "create", OverseasPermissions.MERCHANT_TRANSFER_CREATE, + "page", OverseasPermissions.MERCHANT_TRANSFER_LIST, + "detail", OverseasPermissions.MERCHANT_TRANSFER_LIST, + "confirmMeasurement", OverseasPermissions.MERCHANT_TRANSFER_CONFIRM)); + assertPermissions(MerchantCargoDiscardController.class, Map.of( + "candidates", OverseasPermissions.MERCHANT_DISCARD_LIST, + "create", OverseasPermissions.MERCHANT_DISCARD_CREATE, + "page", OverseasPermissions.MERCHANT_DISCARD_LIST, + "detail", OverseasPermissions.MERCHANT_DISCARD_LIST, + "cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL)); + assertPermissions(MerchantReturnOrderController.class, Map.of( + "create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE, + "page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, + "detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST, + "relabelCandidates", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL, + "createRelabel", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL)); + assertPermissions(MerchantRelabelApplicationController.class, Map.of( + "candidates", OverseasPermissions.MERCHANT_RELABEL_LIST, + "historicalOrders", OverseasPermissions.MERCHANT_RELABEL_LIST, + "create", OverseasPermissions.MERCHANT_RELABEL_CREATE, + "page", OverseasPermissions.MERCHANT_RELABEL_LIST, + "detail", OverseasPermissions.MERCHANT_RELABEL_LIST, + "uploadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_FILES, + "uploadFile", OverseasPermissions.MERCHANT_RELABEL_FILES, + "downloadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_LIST, + "downloadCompletionPhoto", OverseasPermissions.MERCHANT_RELABEL_LIST, + "confirmCompletion", OverseasPermissions.MERCHANT_RELABEL_CONFIRM)); } @Test @@ -90,11 +138,27 @@ class MerchantOverseasCustomerApiContractTest { } private static void assertController(Class type, String path, String permission) { - assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path); + assertPath(type, path); assertThat(type.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); } + private static void assertPath(Class type, String path) { + assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path); + } + + private static void assertPermissions(Class type, Map permissions) { + for (var entry : permissions.entrySet()) { + var method = Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(entry.getKey())) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class)) + .as(type.getSimpleName() + "." + entry.getKey()).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + entry.getValue() + "')"); + } + } + private static AuthenticatedUser user(long customerId, String appCode) { return new AuthenticatedUser(7L, customerId, customerId, 8L, "operator", appCode, "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java index e89d8d07d..28d124284 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java @@ -16,6 +16,15 @@ class MerchantOverseasOpenApiContractTest { } for (String path : new String[] { + "/api/merchant/v1/last-leg/orders:", + "/api/merchant/v1/last-leg/orders/{id}:", + "/api/merchant/v1/last-leg/warehouse-transfers:", + "/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:", + "/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement:", + "/api/merchant/v1/last-leg/cargo-discards:", + "/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:", + "/api/merchant/v1/last-leg/cargo-discards/{discardNo}:", + "/api/merchant/v1/last-leg/cargo-discards/{discardNo}/cancel:", "/api/merchant/v1/last-leg/overseas-procurements:", "/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:", "/api/merchant/v1/last-leg/us-truck-orders:", @@ -27,9 +36,28 @@ class MerchantOverseasOpenApiContractTest { assertThat(yaml).contains(path); assertThat(pathBlock(yaml, path)).doesNotContain("name: customerId"); } - assertThat(yaml).contains("merchant:cross-border:procurement") - .contains("merchant:cross-border:domestic-trucking") - .contains("merchant:cross-border:pallet-shipping") + assertThat(yaml).contains("merchant:cross-border:procurement:list") + .contains("merchant:cross-border:procurement:create") + .contains("merchant:cross-border:domestic-trucking:list") + .contains("merchant:cross-border:domestic-trucking:create") + .contains("merchant:cross-border:domestic-trucking:edit") + .contains("merchant:cross-border:pallet-shipping:list") + .contains("merchant:cross-border:pallet-shipping:create") + .contains("merchant:cross-border:pallet-shipping:edit") + .contains("merchant:overseas-warehouse:last-mile-order:list") + .contains("merchant:overseas-warehouse:transfer:list") + .contains("merchant:overseas-warehouse:transfer:create") + .contains("merchant:overseas-warehouse:transfer:confirm") + .contains("merchant:overseas-warehouse:discard:list") + .contains("merchant:overseas-warehouse:discard:create") + .contains("merchant:overseas-warehouse:discard:cancel") + .contains("merchant:overseas-warehouse:relabel:list") + .contains("merchant:overseas-warehouse:relabel:create") + .contains("merchant:overseas-warehouse:relabel:files") + .contains("merchant:overseas-warehouse:relabel:confirm") + .contains("merchant:overseas-warehouse:return-order:list") + .contains("merchant:overseas-warehouse:return-order:create") + .contains("merchant:overseas-warehouse:return-order:relabel") .contains("merchant:warehouse:inbound:list") .contains("#/components/schemas/ProcurementDetailResponse") .contains("#/components/schemas/TruckDetailResponse") @@ -41,7 +69,7 @@ class MerchantOverseasOpenApiContractTest { .contains("version 必填").contains("幂等冲突"); assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:") .contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:") - .contains("overseasWarehouseCode/overseasWarehouseName 只取尾程订单预分配接柜仓事实") + .contains("overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实") .contains("客户范围只取认证 Token,不接收 customerId"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index 496ef1cc2..f074a326d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -25,7 +25,7 @@ class PdaPalletOperationControllerContractTest { .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble", - "/relabel-disassemble", "/swap", "/unbind"), paths); + "/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths); } @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index a803f1fbb..7743d3207 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -42,6 +42,17 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PdaPalletOperationServiceTest { + @Test void relabelRebindingRejectsDispatchAndOutboundOccupation() { + RelabelApplication app = new RelabelApplication(); + for (String status : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED", "OUTBOUNDED")) { + app.setFulfillmentStatus(status); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + } + app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + app.setOutboundOrderNo("OUT-1"); + assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); + } @Mock private PdaPalletOperationMapper mapper; @Mock private InventoryPositionMapper positions; @Mock private InventoryBalanceMapper balances; @@ -185,6 +196,13 @@ class PdaPalletOperationServiceTest { } @Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); + task.setId(900L); task.setStatus("COMPLETED"); + when(tasks.find("100", 600L)).thenReturn(task); var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, @@ -196,7 +214,7 @@ class PdaPalletOperationServiceTest { RelabelApplication application = new RelabelApplication(); application.setId(600L); application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); - application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1"); + application.setOverseasWarehouseId("88"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application); when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application); RelabelItem selected = relabelItem(701L, "CARTON-A", 100L); @@ -266,6 +284,94 @@ class PdaPalletOperationServiceTest { relabelPalletMutations); } + @Test void actualSourceReconcilesOnlySelectedHoldAndKeepsOriginalInboundBalance() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); task.setStatus("COMPLETED"); + when(tasks.find("100", 600L)).thenReturn(task); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1"); + app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app); + RelabelItem item = relabelItem(701L, "CARTON-A", 101L); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + when(relabelItems.selectById(701L)).thenReturn(item); + InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L); + InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L); + AtomicReference target = new AtomicReference<>(); + when(positions.selectById(anyLong())).thenAnswer(invocation -> { + long id = invocation.getArgument(0); return id == 101L ? anchor : id == 100L ? actual : target.get(); + }); + doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions).insert(any(InventoryPosition.class)); + when(mapper.lockPosition(eq("88"), anyLong())).thenAnswer(invocation -> { + InventoryPosition p = ((Long) invocation.getArgument(1)) == 101L ? anchor : actual; + PalletInventoryRow row = row(p.getId(), p.getPalletId(), p.getVersion(), p.getAvailablePackages()); + row.setHoldPackages(p.getHoldPackages()); return row; + }); + var src = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1); + var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2); + when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst); + when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + when(mapper.content("88", 10L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages()); + when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages()); + InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("88"); balance.setInboundCode("ORIGINAL-INBOUND"); + when(balances.selectById(200L)).thenReturn(balance); + AtomicReference op = new AtomicReference<>(); + doAnswer(invocation -> { op.set(invocation.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(invocation -> op.get()); + var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble( + "qty-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "quantity", + new PalletOperationRequests.MoveLine(100L, 1L, 4L))); + assertEquals("COMPLETED", result.status()); + assertEquals(1L, anchor.getHoldPackages()); // Other order's hold is not moved. + assertEquals(1L, anchor.getAvailablePackages()); + assertEquals(4L, actual.getAvailablePackages()); assertEquals(0L, actual.getHoldPackages()); + assertEquals(1L, target.get().getHoldPackages()); assertEquals(200L, target.get().getBalanceId()); + assertEquals(7L, anchor.getAvailablePackages() + anchor.getHoldPackages() + actual.getAvailablePackages() + + actual.getHoldPackages() + target.get().getHoldPackages()); + verify(relabelPalletMutations).moveSelectedItems(eq(app), argThat(values -> values.get(100L).equals(List.of(item))), anyMap()); + ArgumentCaptor entries = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(4)).insert(entries.capture()); + assertTrue(entries.getAllValues().stream().allMatch(entry -> "ORIGINAL-INBOUND".equals(entry.getInboundCode()))); + verify(balances, never()).updateById(any(InventoryBalance.class)); + } + + @Test void boundMarksSurviveReloadButDoNotBlockANewReworkRound() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + ReflectionTestUtils.setField(service, "relabelWorkHistory", histories); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); + when(tasks.find("100", 600L)).thenReturn(task); + var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + history.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"CARTON-A\"]}"); + when(histories.selectList(any())).thenReturn(List.of(history)); + assertEquals(java.util.Set.of("CARTON-A"), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); + task.setReviewedAt(LocalDateTime.of(2026, 9, 15, 12, 0)); + assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); + } + + @Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() { + var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); + ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1"); + app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("PROCESSING"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7"); + when(tasks.find("100", 600L)).thenReturn(task); + when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0)); + assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode()); + task.setOperatorKey("other"); + assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different"))); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index f6ab20500..2d9995cf6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -96,7 +96,10 @@ class RelabelContractTest { } assertTrue(openApi.contains("RelabelMerchantPageResponse")); assertTrue(openApi.contains("RelabelCustomerConfirm")); - assertTrue(openApi.contains("merchant:warehouse:relabel")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:list")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:create")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:files")); + assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:confirm")); String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"), openApi.indexOf("components:")); assertTrue(relabelPaths.contains("name: customerId, in: query, required: true")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 5a0718253..b111d41d9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -61,6 +61,7 @@ class PdaRelabelServiceTest { void setUp() { service = new PdaRelabelService(); ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "unitFacts", unitFacts); @@ -103,7 +104,7 @@ class PdaRelabelServiceTest { PdaRelabelTask active = task("PROCESSING", 0); active.setApplicationId(99L); active.setRelabelNo("FBR-ACTIVE"); - when(tasks.lockUnfinishedByOperator("7")).thenReturn(active); + when(tasks.lockProcessingByOperator("7")).thenReturn(active); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other"))); @@ -205,6 +206,36 @@ class PdaRelabelServiceTest { assertEquals(1, result.version()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"}) + void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) { + application.setStatus("PROCESSING"); + item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1"); + item.setNotifiedPackageCount(1); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]"); + var task = task("PROCESSING", 0); + var saved = new ArrayList(); + when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved)); + when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { saved.add(call.getArgument(0)); return 1; }); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a")); + assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version()); + assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A"))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate"))); + assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong"))); + var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b")); + assertEquals(1, second.items().get(0).skuProcessedPackages()); + if ("CARTON_AND_SKU".equals(type)) { + assertEquals(0, second.relabelledPackages()); + var last = service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", 2, "carton-last")); + assertEquals(1, last.relabelledPackages()); + } else assertEquals(1, second.relabelledPackages()); + assertEquals(1, persistedFacts.size()); + } + @Test void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() { application.setStatus("PROCESSING"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 7e0db4689..3c2cdf225 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -117,6 +117,7 @@ class RelabelApplicationServiceTest { lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource()); } + @Test void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { RelabelRequests.Create request = request("key-1", 10, 6); @@ -144,7 +145,7 @@ class RelabelApplicationServiceTest { verify(orderHistories).insert(tailHistory.capture()); assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); - assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); @@ -155,10 +156,36 @@ class RelabelApplicationServiceTest { assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); - assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus()); + assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus()); assertEquals("FBR-1", result.application().relabelNo()); } + @Test + void warehouseTransferRelabelReusesExistingTailOrder() { + RelabelRequests.Create request = request("key-transfer", 10, 6); + RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null, + RelabelApplicationService.PENDING_PROCESSING); + persisted.setLastlegOrderId(21L); + LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED"); + transferOrder.setOrderType("WAREHOUSE_TRANSFER"); + when(applications.findNo(eq("10001"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(transferOrder); + + service.createForExistingTailOrder("10001", "operator-1", 21L, request); + + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + verify(applications).insert(application.capture()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class)); + assertEquals(21L, application.getValue().getLastlegOrderId()); + assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo()); + assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus()); + assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType()); + assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus()); + } + @Test void createWithoutChargeSkipsWalletFreeze() { RelabelRequests.Create charged = request("key-free", 10, 6); @@ -242,27 +269,19 @@ class RelabelApplicationServiceTest { } @Test - void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() { + void createRejectsDifferentFbaDestinationsInOneApplication() { RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8"); RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8"); RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi", List.of(west, east)); - RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null, - RelabelApplicationService.PENDING_PROCESSING); - persisted.setLastlegOrderId(21L); - when(applications.findNo(eq("10001"), any())).thenReturn(persisted); - when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1))); - when(histories.selectList(any())).thenReturn(List.of()); - when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", - RelabelApplicationService.PENDING_PROCESSING)); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("10001", "operator-1", request)); - service.create("10001", "operator-1", request); - - ArgumentCaptor created = ArgumentCaptor.forClass(LastlegOrder.class); - verify(orders).insert(created.capture()); - assertEquals("WH-1", created.getValue().getOverseasWarehouseId()); - assertEquals(2, created.getValue().getPackageCount()); + assertEquals(400, exception.getStatusCode().value()); + assertEquals("同一张贴标申请只能选择一个 FBA 目的仓", exception.getReason()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); } @Test @@ -363,6 +382,9 @@ class RelabelApplicationServiceTest { application.setLastlegOrderId(21L); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); + LastlegOrder relabelOrder = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"); + relabelOrder.setOrderType("RELABEL_APPLICATION"); + when(orders.selectById(21L)).thenReturn(relabelOrder); RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("processing", "started")); @@ -372,12 +394,33 @@ class RelabelApplicationServiceTest { verify(applications).updateById(application); ArgumentCaptor updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).updateById(updatedOrder.capture()); - assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus()); + assertEquals("WAIT_RELABEL", updatedOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus()); verify(histories).insert(any(RelabelStatusHistory.class)); verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); } + @Test + void reviewStageIsReportedAsCompletedOnLinkedCustomerAndPlatformOrder() { + RelabelApplication application = application(11L, "FBR-REVIEW", "10001", "key-review", "hash", + "PROCESSING"); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT"); + when(applications.lockNo("10001", "FBR-REVIEW")).thenReturn(application); + when(applications.findNo("10001", "FBR-REVIEW")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("10001", "FBR-REVIEW", "operator-2", + new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted")); + + assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus()); + assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus()); + assertEquals("COMPLETED", order.getRelabelStatus()); + assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus()); + } + @Test void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED"); @@ -466,7 +509,7 @@ class RelabelApplicationServiceTest { var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class); verify(pdaTasks).updateWithVersion(review.capture(), eq(1)); assertEquals("COMPLETED", review.getValue().getStatus()); - assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision()); + assertEquals("CONFIRMED", review.getValue().getReviewDecision()); assertNull(application.getOutboundOrderNo()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); @@ -475,12 +518,36 @@ class RelabelApplicationServiceTest { verify(applications, times(1)).updateById(application); } + @Test + void skuOnlyCustomerConfirmationCompletesWithoutPalletOrOutboundFulfillment() { + RelabelApplication application = confirmationApplication(null, false); + RelabelItem skuOnly = item(31L, application.getId(), "SKU-A", 1, 1); + skuOnly.setOperationType("SKU_LABEL"); + LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(skuOnly)); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(linked); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-sku", "accepted")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("NOT_REQUIRED", application.getFulfillmentStatus()); + assertEquals("COMPLETED", linked.getCurrentStatus()); + assertNull(application.getOutboundOrderNo()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + verify(outboundItems, never()).insert(any(OutboundPalletItem.class)); + verify(outboundTasks, never()).insert(any(OutboundPdaTask.class)); + } + @Test void transferRelabelConfirmationReleasesTransferForDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); - transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8"); LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); @@ -498,6 +565,55 @@ class RelabelApplicationServiceTest { verify(orders).updateById(transferOrder); } + @Test + void transferRelabelConfirmationWithoutDestinationWaitsForFbaInsteadOfDispatch() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + stubDetail(application); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer-no-fba", "accepted")); + + assertEquals("WAIT_FBA_DESTINATION", transfer.getStatus()); + assertEquals("WAIT_FBA_DESTINATION", transferOrder.getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:WAIT_FBA_DESTINATION;RELABEL:COMPLETED", + transferOrder.getSubOrderStatus()); + assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus()); + } + + @Test + void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() { + RelabelApplication application = confirmationApplication("TRUCK", false); + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); + transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); + LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL"); + RelabelItem relabelItem = item(31L, application.getId(), "SKU-A", 10, 6); + relabelItem.setDestinationFbaWarehouseCode("ONT8"); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); + when(applications.findNo("10001", "FBR-1")).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(relabelItem)); + when(histories.selectList(any())).thenReturn(List.of()); + when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer); + when(orders.selectById(88L)).thenReturn(transferOrder); + when(orders.selectById(application.getLastlegOrderId())).thenReturn( + order(application.getLastlegOrderId(), "10001", "LMO-RELABEL-1", application.getStatus())); + + service.confirmCompletion("10001", "FBR-1", "customer-1", + new RelabelRequests.CustomerConfirm("confirm-transfer-derived-fba", "accepted")); + + assertEquals("ONT8", transfer.getFbaWarehouseCode()); + assertEquals("READY_FOR_DISPATCH", transfer.getStatus()); + assertEquals("ONT8", transferOrder.getFbaWarehouseCode()); + assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus()); + } + @Test void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() { RelabelApplication application = confirmationApplication("EXPRESS", true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java new file mode 100644 index 000000000..806625f7b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class RelabelBindingGuardTest { + @Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() { + var guard = new RelabelBindingGuard(); + var items = mock(RelabelItemMapper.class); + var history = mock(PdaRelabelHistoryMapper.class); + var positions = mock(InventoryPositionMapper.class); + ReflectionTestUtils.setField(guard, "items", items); + ReflectionTestUtils.setField(guard, "histories", history); + ReflectionTestUtils.setField(guard, "positions", positions); + ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); + var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL"); + item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1); + when(items.selectList(any())).thenReturn(List.of(item)); + var position = new InventoryPosition(); position.setPalletNo("OLD"); position.setHoldPackages(1L); + when(positions.findTenantPosition("2", 3L)).thenReturn(position); + when(history.selectList(any())).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + var binding = new PdaRelabelHistory(); binding.setAction("PALLET_BIND"); + binding.setDetailJson("{\"cartonMarks\":[\"A\"],\"targetPallets\":[\"NEW\"]}"); + when(history.selectList(any())).thenReturn(List.of(binding)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + position.setPalletNo("NEW"); + assertDoesNotThrow(() -> guard.requireBound(app)); + var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE"); + when(history.selectList(any())).thenReturn(List.of(binding, rework)); + assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + item.setOperationType("SKU_LABEL"); + assertDoesNotThrow(() -> guard.requireBound(app)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java new file mode 100644 index 000000000..36980b2da --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RelabelReturnCartonCandidateTest { + @Test + void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() { + RelabelApplicationService service = new RelabelApplicationService(); + InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class); + InventoryPositionMapper positions = mock(InventoryPositionMapper.class); + ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class); + ReturnInventoryCartonItemMapper cartonItems = mock(ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(service, "inventoryBalances", balances); + ReflectionTestUtils.setField(service, "inventoryPositions", positions); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems); + + ReturnInventoryCarton carton = new ReturnInventoryCarton(); + carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1"); + carton.setCartonMark("RC-WH-0001"); carton.setSourceReturnNo("RT-1,RT-2"); + carton.setStatus("AVAILABLE"); carton.setTotalQuantity(12L); + ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem(); + cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN"); + cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); + when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); + when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); + + List result = service.candidates("10001", "WH-1", "RC-WH"); + + assertEquals(1, result.size()); + assertEquals("RETURN_CARTON", result.get(0).sourceType()); + assertEquals(9001L, result.get(0).returnCartonId()); + assertEquals("RC-WH-0001", result.get(0).cartonMark()); + assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); + assertEquals(12, result.get(0).skuItems().get(0).quantity()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java index 697f6ffaf..68bef35ed 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,6 +49,7 @@ class LastlegOrderCommandServiceTest { @Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory; @Mock LastlegOrderOperationLogMapper operationLogs; @Mock InboundOrderDetainedOutboxService detainedOutbox; + @Mock InventoryCommandService inventory; LastlegOrderCommandService service; @BeforeEach @@ -60,12 +62,14 @@ class LastlegOrderCommandServiceTest { ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory); ReflectionTestUtils.setField(service, "operationLogs", operationLogs); ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox); + ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); } @Test void holdsOnlySelectedOrderWithVersionAndImmutableHistory() { LastlegOrder order = order(11L, 3); + order.setCurrentStatus("INBOUND_COMPLETED"); when(orders.selectById(11L)).thenReturn(order); when(orders.lockById("100", 11L)).thenReturn(order); when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), @@ -80,9 +84,42 @@ class LastlegOrderCommandServiceTest { verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any()); verify(holdHistory).insert(any(LastlegOrderHoldHistory.class)); verify(commands).insert(any(LastlegOrderCommandRecord.class)); + verify(inventory).holdInboundOrderInventory("100", "900", 11L, "LL-11", "海关查验"); verify(orders, never()).lockContainerOrders(any()); } + @Test + void releasingHoldMovesOrderInventoryBackToAvailable() { + LastlegOrder order = order(11L, 3); + order.setCurrentStatus("INBOUND_COMPLETED"); + order.setDetained(true); + when(orders.selectById(11L)).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + when(orders.updateHold(eq("100"), eq(11L), eq(false), eq(false), eq("质检通过"), eq(3), + any(LocalDateTime.class))).thenReturn(1); + + service.hold("100", 11L, "release-11", + new LastlegOrderCommandDtos.HoldRequest(false, false, "质检通过", 3), "900", "管理员"); + + verify(inventory).releaseInboundOrderHold("100", "900", 11L, "LL-11", "质检通过"); + } + + @Test + void rejectsNewHoldBeforeInboundCompletion() { + LastlegOrder order = order(11L, 3); + when(orders.selectById(11L)).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L, + "hold-before-inbound", new LastlegOrderCommandDtos.HoldRequest(true, false, "待入库", 3), + "900", "管理员")); + + assertEquals(409, error.getStatusCode().value()); + assertEquals("货物完成入库后才能暂扣", error.getReason()); + verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(), + nullable(String.class), anyInt(), any(LocalDateTime.class)); + } + @Test void rejectsStaleHoldWithoutAnyWrite() { LastlegOrder order = order(11L, 4); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 9df43b800..3ff5b751c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -20,6 +20,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; +import com.baiyue.lastleg.model.LastlegOrderStatus; import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; @@ -37,6 +38,7 @@ import java.util.List; import java.time.LocalDateTime; import java.time.LocalDate; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -49,7 +51,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -69,7 +70,6 @@ class LastlegOrderServiceTest { @Mock InboundPlanCartonMapper cartons; @Mock FbaWarehouseValidator fbaWarehouses; @Mock InventoryCommandService inventory; - @Mock InboundOrderDetainedOutboxService detainedOutbox; @Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Mock WarehouseResourceGateway warehouseResourceGateway; @@ -94,10 +94,13 @@ class LastlegOrderServiceTest { order.setSourceContainerBookingId("BOOKING-1"); LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( - service, "summary", order, List.of(), null, "UNSCHEDULED"); + service, "summary", order, List.of(), null, "UNSCHEDULED", + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22"))); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); + assertEquals(new java.math.BigDecimal("30"), summary.grossWeightKg()); + assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg()); } @BeforeEach @@ -116,7 +119,6 @@ class LastlegOrderServiceTest { Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2), "US", "CA")); ReflectionTestUtils.setField(service, "inventory", inventory); - ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox); ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations); ReflectionTestUtils.setField(service, "containerArrivalTimeOperations", containerArrivalTimeOperations); ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway); @@ -387,6 +389,31 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @Test + void exportUsesChineseBooleanAndMainStatusLabels() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + order.setAbnormal(false); + order.setDetained(true); + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 1000); + resultPage.setRecords(List.of(order)); + resultPage.setTotal(1); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + + String csv = new String(service.exportCsv(null, new LastlegOrderRequests.Query( + 1L, 20L, null, null, null, null, null, null, null, null, null, null, + null, null, null, null)), StandardCharsets.UTF_8); + + assertTrue(csv.contains("\"否\",\"是\"")); + assertTrue(csv.contains("\"待分配\"")); + assertFalse(csv.contains("\"false\"")); + assertFalse(csv.contains("\"PENDING_ASSIGNMENT\"")); + assertEquals("草稿", LastlegOrderStatus.labelOf("DRAFT")); + assertEquals("审核通过", LastlegOrderStatus.labelOf("APPROVED")); + assertEquals("已分配目的仓", LastlegOrderStatus.labelOf("DESTINATION_ASSIGNED")); + assertEquals("已生成入库计划", LastlegOrderStatus.labelOf("INBOUND_PLANNED")); + assertEquals("处理中", LastlegOrderStatus.labelOf("PROCESSING")); + } + @Test void pageWarehouseFilterMatchesFrozenWarehouseName() { assertWarehouseFilter("洛杉矶仓", "洛杉矶仓", true); @@ -559,40 +586,6 @@ class LastlegOrderServiceTest { assertEquals("NYEXX2609060063", result.records().get(2).inboundCode()); } - @Test - void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() { - LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null); - when(orders.selectOne(any())).thenReturn(order); - when(orders.lockById("100", 11L)).thenReturn(order); - - service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(true, "等待质检"), "operator"); - - assertTrue(order.getDetained()); - verify(orders).updateById(order); - ArgumentCaptor operation = - ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class); - verify(operations).insert(operation.capture()); - assertEquals("暂扣", operation.getValue().getOperationNode()); - assertEquals("等待质检", operation.getValue().getResult()); - verify(detainedOutbox).publish(eq(order), eq(true), eq("等待质检"), isNull(), eq("operator"), any(), anyLong()); - verify(inventory, never()).releaseInboundOrderHold(any(), any(), anyLong(), any(), any()); - } - - @Test - void releasingAfterInboundMovesAllOrderHeldInventoryToAvailable() { - LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IP-1"); - order.setOrderNo("LM-1"); - order.setDetained(true); - when(orders.selectOne(any())).thenReturn(order); - when(orders.lockById("100", 11L)).thenReturn(order); - - service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(false, "质检通过"), "operator"); - - verify(inventory).releaseInboundOrderHold("100", "operator", 11L, "LM-1", "质检通过"); - assertEquals(false, order.getDetained()); - verify(detainedOutbox).publish(eq(order), eq(false), eq("质检通过"), isNull(), eq("operator"), any(), anyLong()); - } - private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { LastlegOrder order = new LastlegOrder(); order.setId(id); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java index 7dcc994a7..984cc730d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java @@ -11,6 +11,8 @@ class WarehouseTransferSchemaContractTest { void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception { String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8).toLowerCase(); for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", "ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation", "ll_warehouse_transfer_receipt"}) { @@ -21,5 +23,25 @@ class WarehouseTransferSchemaContractTest { assertTrue(migration.contains("uk_ll_wh_transfer_source")); assertTrue(migration.contains("uk_ll_wh_transfer_active_position")); assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source")); + assertTrue(baseline.contains("pickup_contact_name varchar(128) null")); + assertTrue(baseline.contains("pickup_contact_phone varchar(64) null")); + assertTrue(migration.contains("column_name='pickup_contact_name'")); + assertTrue(migration.contains("column_name='pickup_contact_phone'")); + assertTrue(baseline.contains("create table if not exists ll_warehouse_transfer_measurement_photo")); + assertTrue(migration.contains("create table if not exists ll_warehouse_transfer_measurement_photo")); + assertTrue(baseline.contains("received_package_count int null comment 'pda实际入库箱数'")); + assertTrue(migration.contains("column_name='received_package_count'")); + assertTrue(migration.contains("o.current_status='inbound_completed'")); + for (String field : new String[]{"pickup_contact_name", "review_decision", "destination_type", + "destination_binding_idempotency_key", "private_destination_address", + "private_destination_postal_code", "draft_idempotency_key", "bind_idempotency_key"}) { + assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field); + } + for (String table : new String[]{"ll_warehouse_transfer_measurement_photo", "ll_inbound_plan_line", + "ll_inbound_plan_pallet", "ll_dispatch_item", "ll_delivery_order"}) { + assertTrue(deployment.contains(table), () -> "deployment SQL must include " + table); + } + assertTrue(deployment.contains("assert_relabel_transfer_latest_schema_ready")); + assertTrue(deployment.contains("贴标申请与仓储转运最新表结构已完整部署")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index b7945c828..ef731e388 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -31,6 +31,25 @@ class MerchantWarehouseTransferControllerTest { verify(service).detail("100", "TR001"); } + @Test + void merchantSubmitUsesOnlyAuthenticatedTenant() { + controller.submit(user(100L, "MERCHANT"), "TR001"); + verify(service).submit("100", "TR001", "1"); + } + + @Test + void merchantUpdateUsesOnlyAuthenticatedTenant() { + WarehouseTransferRequests.Create request = mock(WarehouseTransferRequests.Create.class); + controller.updateDraft(user(100L, "MERCHANT"), "TR001", request); + verify(service).updateDraft("100", "TR001", "1", request); + } + + @Test + void merchantPrintsSubmittedCartonLabelsWithinAuthenticatedTenant() { + controller.cartonLabel(user(100L, "MERCHANT"), "TR001"); + verify(service).cartonLabel("100", "TR001"); + } + @Test void merchantEndpointRejectsPlatformIdentity() { assertThrows(ResponseStatusException.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java new file mode 100644 index 000000000..0f859dd2a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.multipart.MultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaWarehouseTransferControllerContractTest { + @Test + void detailUsesOnlyAuthenticatedPdaWarehouseScopeAndTransferNumber() throws Exception { + var method = PdaWarehouseTransferController.class + .getMethod("detail", AuthenticatedUser.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{transferNo}"); + assertThat(method.getParameterCount()).isEqualTo(2); + } + + @Test + void measurementPhotoUploadUsesThePublicPdaRouteAndFileMultipartPart() throws Exception { + var method = PdaWarehouseTransferController.class.getMethod("uploadMeasurementPhoto", + AuthenticatedUser.class, String.class, MultipartFile.class); + + var mapping = method.getAnnotation(PostMapping.class); + assertThat(mapping.value()).containsExactly("/{transferNo}/measurement-photos/files"); + assertThat(mapping.consumes()).containsExactly("multipart/form-data"); + assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()).isEqualTo("file"); + } + + @Test + void palletLabelUsesThePdaScopedTransferAndGeneratedWarehousePallet() throws Exception { + var method = PdaWarehouseTransferController.class.getMethod("palletLabel", + AuthenticatedUser.class, String.class, long.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{transferNo}/pallets/{warehousePalletId}/label"); + assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()).isEqualTo("X-Request-Id"); + } + + @Test + void palletLifecycleExposesDraftGenerateBindAndDraftDeleteRoutes() throws Exception { + var draft = PdaWarehouseTransferController.class.getMethod("savePalletDraft", + AuthenticatedUser.class, String.class, WarehouseTransferRequests.PalletDraft.class); + var generate = PdaWarehouseTransferController.class.getMethod("generatePallet", + AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletAction.class); + var bind = PdaWarehouseTransferController.class.getMethod("bindPallet", + AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletBind.class); + var delete = PdaWarehouseTransferController.class.getMethod("deletePalletDraft", + AuthenticatedUser.class, String.class, long.class); + + assertThat(draft.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/drafts"); + assertThat(generate.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/generate"); + assertThat(bind.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/bind"); + assertThat(delete.getAnnotation(DeleteMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java new file mode 100644 index 000000000..3f680f3cb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseTransferCargoValidationTest { + @Test + void transferCargoAllowsSkuAndProductNameToBeOmitted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate( + new WarehouseTransferRequests.Item(null, null, 1, null)); + + assertTrue(violations.isEmpty()); + } + } + + @Test + void cartonRelabelAllowsOriginalSkuToBeOmitted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var violations = factory.getValidator().validate(new WarehouseTransferRequests.RelabelItem( + "CARTON_LABEL", List.of("CUSTOMER-CARTON-1"), null, null, 1, + "FBA-CARTON-1", "ONT8", null)); + + assertTrue(violations.isEmpty()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java index 92807e87a..f714d3e35 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java @@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class WarehouseTransferPalletValidationTest { @Test - void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() { + void palletAcceptsMeasuredDimensionsVolumeAndWeight() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); - var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, - new BigDecimal("1.250000"), new BigDecimal("420.50"), - List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + var pallet = new WarehouseTransferRequests.Pallet(5, + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"), + new BigDecimal("1.250000"), new BigDecimal("420.50")); assertTrue(validator.validate(pallet).isEmpty()); assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm()); @@ -28,11 +28,23 @@ class WarehouseTransferPalletValidationTest { void palletRejectsNonPositiveTotalVolume() { try (var factory = Validation.buildDefaultValidatorFactory()) { var validator = factory.getValidator(); - var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5, - BigDecimal.ZERO, new BigDecimal("420.50"), - List.of(new WarehouseTransferRequests.PalletItem(101L, 5))); + var pallet = new WarehouseTransferRequests.Pallet(5, + new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"), + BigDecimal.ZERO, new BigDecimal("420.50")); assertEquals(1, validator.validate(pallet).size()); } } + + @Test + void palletRejectsMissingOrNonPositiveDimensions() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var pallet = new WarehouseTransferRequests.Pallet(5, + BigDecimal.ZERO, null, new BigDecimal("125"), + new BigDecimal("1.250000"), new BigDecimal("420.50")); + + assertEquals(2, validator.validate(pallet).size()); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java new file mode 100644 index 000000000..b42f74175 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.gateway; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpTransferMeasurementPhotoFileGatewayContractTest { + @Test + void defaultsToTheFileServicesActualLocalPort() throws Exception { + var field = HttpTransferMeasurementPhotoFileGateway.class.getDeclaredField("baseUrl"); + + assertThat(field.getAnnotation(Value.class).value()) + .isEqualTo("${baiyue.clients.file.url:http://127.0.0.1:9206}"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java new file mode 100644 index 000000000..1177c8a77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WarehouseTransferPalletAllocationTest { + @Test + void allocatesMultipleDeclaredSkusAcrossPalletsInStableDeclaredOrder() { + WarehouseTransferItem first = item(11L, 3); + WarehouseTransferItem second = item(12L, 4); + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(first, second), List.of(pallet(2), pallet(5))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 2), + new WarehouseTransferService.DerivedPalletAllocation(1, 11L, 1), + new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 4)), allocations); + } + + @Test + void underReceiptConsumesDeclaredSkusInStableOrder() { + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3), + new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1)), allocations); + assertEquals(4, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + } + + @Test + void overReceiptAssignsExcessToFinalDeclaredSkuAcrossPallets() { + var allocations = WarehouseTransferService.derivePalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4), pallet(3))); + + assertEquals(List.of( + new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3), + new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1), + new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 3)), allocations); + assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + } + + @Test + void persistedOverReceiptUsesTheSameStableAllocationRule() { + WarehouseTransferPallet first = persistedPallet(4); + WarehouseTransferPallet second = persistedPallet(3); + + var allocations = WarehouseTransferService.derivePersistedPalletAllocations( + List.of(item(11L, 3), item(12L, 2)), List.of(first, second)); + + assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum()); + assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L) + .mapToInt(value -> value.packageCount()).sum()); + assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L) + .mapToInt(value -> value.packageCount()).sum()); + assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList()); + } + + @Test + void requestsFreshPalletNumbersAndCarriesOnlyTheKnownFbaDestination() { + var known = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-1", "ONT8", 2); + var unknown = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-2", null, 1); + + assertEquals(List.of("WTR-1:1", "WTR-1:2"), known.stream().map(value -> value.businessKey()).toList()); + assertEquals(List.of("ONT8", "ONT8"), known.stream().map(value -> value.fbaWarehouseCode()).toList()); + assertEquals(true, known.stream().allMatch(value -> value.palletCode() == null)); + assertEquals(null, unknown.get(0).fbaWarehouseCode()); + assertEquals(null, unknown.get(0).palletCode()); + } + + private static WarehouseTransferItem item(long id, int packages) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(id); item.setPackageCount(packages); + return item; + } + + private static WarehouseTransferRequests.Pallet pallet(int cartons) { + return new WarehouseTransferRequests.Pallet(cartons, BigDecimal.ONE, BigDecimal.ONE, + BigDecimal.ONE, new BigDecimal("0.000001"), BigDecimal.ONE); + } + + private static com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet persistedPallet(int cartons) { + var pallet = new com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet(); + pallet.setCartonCount(cartons); + return pallet; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index a1dba7df4..0f23c0ae3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.transfer.service; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; @@ -19,11 +20,27 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton; import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper; +import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,14 +79,36 @@ import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class WarehouseTransferServiceTest { + @Test + void customerMainStatusFiltersExpandToInternalWorkflowStates() { + assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING), + WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_MEASURING)); + assertTrue(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_COMPLETED) + .containsAll(List.of(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, + WarehouseTransferService.WAIT_FBA_DESTINATION, WarehouseTransferService.WAIT_RELABEL, + WarehouseTransferService.READY_FOR_DISPATCH, WarehouseTransferService.OUTBOUNDED))); + assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT)); + } + @Mock WarehouseTransferOrderMapper transfers; @Mock WarehouseTransferItemMapper transferItems; @Mock WarehouseTransferCartonMapper transferCartons; @Mock WarehouseTransferSourceOrderMapper sourceOrders; @Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations; @Mock WarehouseTransferReceiptMapper transferReceipts; + @Mock WarehouseTransferMeasurementPhotoMapper measurementPhotos; + @Mock WarehouseTransferPalletMapper transferPallets; + @Mock WarehouseTransferPalletItemMapper transferPalletItems; + @Mock InboundStockReceiptMapper stockReceipts; + @Mock WarehouseTransferRelabelBindingMapper relabelBindings; + @Mock WarehouseResourceGateway warehouseResources; + @Mock TransferMeasurementPhotoFileGateway measurementPhotoFiles; @Mock InboundSnapshotMapper inboundSnapshots; @Mock LastlegInboundOrderMapper inboundOrders; + @Mock InboundPlanMapper inboundPlans; + @Mock InboundPlanLineMapper inboundPlanLines; + @Mock InboundPlanPalletMapper inboundPlanPallets; + @Mock InboundPlanPalletAllocationMapper inboundPlanAllocations; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderStatusHistoryMapper orderHistories; @@ -88,8 +127,19 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations); ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts); + ReflectionTestUtils.setField(service, "measurementPhotos", measurementPhotos); + ReflectionTestUtils.setField(service, "transferPallets", transferPallets); + ReflectionTestUtils.setField(service, "transferPalletItems", transferPalletItems); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "relabelBindings", relabelBindings); + ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources); + ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles); ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); + ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans); + ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines); + ReflectionTestUtils.setField(service, "inboundPlanPallets", inboundPlanPallets); + ReflectionTestUtils.setField(service, "inboundPlanAllocations", inboundPlanAllocations); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); @@ -103,6 +153,116 @@ class WarehouseTransferServiceTest { lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(measurementPhotos.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(measurementPhotos.listSubmitted(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(transferPalletItems.listOrder(anyString(), anyLong())).thenReturn(List.of()); + lenient().when(relabelBindings.listOrder(anyString(), anyLong())).thenReturn(List.of()); + } + + @Test + void generatedPalletRetryWithSameKeyDoesNotGenerateAnotherPhysicalPallet() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + pallet.setGenerateIdempotencyKey("GEN-1"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + service.generatePallet(77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletAction("GEN-1")); + + verify(warehouseResources, never()).ensurePalletBatch(any()); + verify(warehouseResources, never()).confirmPalletBatch(any()); + } + + @Test + void scanBindingRejectsCodeOtherThanThisGeneratedPallet() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.bindPallet( + 77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletBind("OTHER-PALLET", "BIND-1"))); + + assertEquals(409, exception.getStatusCode().value()); + verify(warehouseResources, never()).resolvePallet(anyString()); + } + + @Test + void palletizeRejectsWhenAnyPersistedPalletIsNotBound() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + WarehouseTransferPallet pallet = measuredPallet("GENERATED"); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5))); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setFileId(9001L); + when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.palletize( + "OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)))); + + assertEquals(409, exception.getStatusCode().value()); + verify(warehouseResources, never()).ensurePalletBatch(any()); + } + + @Test + void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING); + transfer.setOperatingWarehouseId("77"); + transfer.setInboundCode("WTR-1"); + WarehouseTransferPallet pallet = measuredPallet("BOUND"); + WarehouseTransferItem item = transferItem(51L, "SKU-1", 5); + LastlegOrder tail = tailOrder(); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A"); + InboundPlan inboundPlan = new InboundPlan(); inboundPlan.setId(201L); inboundPlan.setStatus("PDA_PENDING"); + InboundPlanLine inboundLine = new InboundPlanLine(); inboundLine.setId(202L); + when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transferItems.selectList(any())).thenReturn(List.of(item)); + when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet)); + WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto(); + photo.setFileId(9001L); + when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo); + when(measurementPhotos.markSubmitted(eq("TENANT-A"), eq(transfer.getId()), any(), any())).thenReturn(1); + when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(tail); + when(inboundOrders.selectById(transfer.getInboundOrderId())).thenReturn(inbound); + when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(inboundPlan); + when(inboundPlanLines.selectOne(any())).thenReturn(inboundLine); + when(warehouseResources.requireActiveSharedWarehouse(77L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference(77L, "WH-77", "Warehouse 77", "ACTIVE")); + when(inventory.stockIn(eq("TENANT-A"), eq("OP-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.palletize("OP-1", 77L, "WTR-1", + new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))); + + assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status()); + assertEquals("COMPLETED", pallet.getStatus()); + assertEquals(WarehouseTransferService.INBOUND_COMPLETED, tail.getCurrentStatus()); + assertEquals(5, tail.getReceivedPackageCount()); + assertEquals("RECEIVED", inbound.getStatus()); + assertEquals(5, inbound.getPackageCount()); + assertEquals("COMPLETED", inboundPlan.getStatus()); + verify(transferPalletItems).insert(any(com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem.class)); + verify(inventory).stockIn(eq("TENANT-A"), eq("OP-1"), any()); + verify(stockReceipts).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt.class)); + verify(inboundOrders).updateById(inbound); + verify(inboundPlans).updateById(inboundPlan); + verify(inboundPlanPallets).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet.class)); + ArgumentCaptor allocation = + ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class); + verify(inboundPlanAllocations).insert(allocation.capture()); + assertEquals(5, allocation.getValue().getPickedPackageCount()); + verify(warehouseResources, never()).ensurePalletBatch(any()); + verify(warehouseResources, never()).confirmPalletBatch(any()); } @Test @@ -111,7 +271,7 @@ class WarehouseTransferServiceTest { LastlegOrder historical = historicalOrder(); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historical)); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -120,23 +280,23 @@ class WarehouseTransferServiceTest { ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); verify(orders).insert(tailOrder.capture()); assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus()); - assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); + assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus()); assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getTransferWarehouseId()); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); verify(relabelApplications, never()).insert(any(RelabelApplication.class)); assertNull(tailOrder.getValue().getRelabelOrderNo()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); + assertEquals(WarehouseTransferService.DRAFT, result.status()); + assertEquals(WarehouseTransferService.DRAFT, result.orderStatus()); } @Test void transferWithoutRelabelDoesNotCreateRelabelDocument() { when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -146,6 +306,27 @@ class WarehouseTransferServiceTest { assertNull(result.relabelNo()); } + @Test + void transferRelabelRejectsMultipleDestinationWarehouses() { + WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( + null, null, "WH-1", null, "NONE", null, null, null, + null, null, null, null, "123 Pickup Road", null, null, 2, + new BigDecimal("0.1000"), "CUSTOMER_DELIVERY", LocalDateTime.of(2026, 9, 18, 10, 0), + null, null, null, "BOX-1,BOX-2", null, true, + LocalDateTime.of(2026, 9, 19, 10, 0), "Contact", "13800000000", null, "TRUCK", + "single-destination-check", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 2, null)), + List.of( + new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-1"), "SKU-1", null, 1, "FBA-1", "ONT8", null), + new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-2"), "SKU-1", null, 1, "FBA-2", "ACY2", null))); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason()); + verifyNoInteractions(transfers); + } + @Test void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() { WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create( @@ -155,7 +336,7 @@ class WarehouseTransferServiceTest { List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); - when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -179,7 +360,7 @@ class WarehouseTransferServiceTest { null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null), new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null))); - when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT)); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -199,7 +380,7 @@ class WarehouseTransferServiceTest { assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode()); assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode()); assertEquals("OVERSEAS", inbound.getValue().getDestinationType()); - assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus()); + assertEquals(WarehouseTransferService.DRAFT, inbound.getValue().getStatus()); assertNull(transfer.getValue().getTrackingNo()); assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus()); assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream() @@ -209,7 +390,7 @@ class WarehouseTransferServiceTest { } @Test - void approvedTransferWithoutRelabelEntersPendingAssignment() { + void approvedTransferWithoutRelabelEntersInboundMeasuring() { WarehouseTransferOrder transfer = persistedTransfer(); transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setNeedRelabel(false); @@ -225,14 +406,134 @@ class WarehouseTransferServiceTest { new WarehouseTransferRequests.Review("APPROVE", null)); assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); - assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.INBOUND_MEASURING, tailOrder.getCurrentStatus()); assertEquals(WarehouseTransferService.APPROVED, result.status()); - assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); + assertEquals(WarehouseTransferService.INBOUND_MEASURING, result.orderStatus()); verify(transfers).updateById(transfer); verify(orders).updateById(tailOrder); verifyNoInteractions(inventory); } + @Test + void approvedIncomingTransferCreatesPendingInboundPlan() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setInboundCode("WTR-1"); transfer.setOperatingWarehouseId("77"); + transfer.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 17, 10, 0)); + LastlegOrder tailOrder = tailOrder(); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.review("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review("APPROVE", null)); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + ArgumentCaptor line = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(inboundPlans).insert(plan.capture()); + verify(inboundPlanLines).insert(line.capture()); + assertEquals("PDA_PENDING", plan.getValue().getStatus()); + assertEquals("WT-WTR-1", plan.getValue().getPlanNo()); + assertEquals("WTR-1", line.getValue().getInboundCode()); + assertEquals(5, line.getValue().getRequiredPackageCount()); + assertEquals("PENDING_INBOUND", inbound.getStatus()); + assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus()); + } + + @Test + void merchantSubmitsDraftForReviewBeforePrintingLabels() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + transfer.setInboundCode("WTR-1"); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setRelabelStatus("NOT_REQUIRED"); + LastlegInboundOrder inbound = new LastlegInboundOrder(); + inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(inboundOrders.selectById(101L)).thenReturn(inbound); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.submit("TENANT-A", "WTR-1", "USER-1"); + + assertEquals(WarehouseTransferService.PENDING_REVIEW, transfer.getStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verify(inboundOrders).updateById(inbound); + } + + @Test + void merchantUpdatesDraftAndRebuildsEditableSnapshots() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)); + + assertEquals(WarehouseTransferService.DRAFT, result.status()); + assertEquals("WH-1", transfer.getOperatingWarehouseId()); + assertEquals(new BigDecimal("0.2500"), tailOrder.getVolumeCbm()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verify(transferCartons).delete(any()); + verify(transferItems).delete(any()); + verify(relabelBindings).delete(any()); + verify(orderDetails).delete(any()); + verify(transferItems).insert(any(WarehouseTransferItem.class)); + verify(transferCartons, times(5)).insert(any(WarehouseTransferCarton.class)); + verify(orderDetails).insert(any(LastlegOrderDetail.class)); + } + + @Test + void merchantCannotUpdateSubmittedTransfer() { + WarehouseTransferOrder transfer = persistedTransfer(); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false))); + + assertEquals(409, error.getStatusCode().value()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void merchantDraftUpdateIsTenantScoped() { + when(transfers.lockNo("TENANT-B", "WTR-1")).thenReturn(null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.updateDraft("TENANT-B", "WTR-1", "USER-2", request(false))); + + assertEquals(404, error.getStatusCode().value()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void draftCannotPrintCartonLabelsBeforeSubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.DRAFT); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.cartonLabel("TENANT-A", "WTR-1")); + + assertEquals(409, error.getStatusCode().value()); + verify(transferCartons, never()).listOrder(anyString(), anyLong()); + } + @Test void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() { for (String decision : List.of("REJECT", "REJECTED")) { @@ -378,6 +679,67 @@ class WarehouseTransferServiceTest { assertEquals("RESERVED", allocation.getStatus()); } + @Test + void outboundRejectsTransferWithoutFbaDestinationBeforeInventoryMutation() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + transfer.setFbaWarehouseCode(null); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-NO-FBA", null))); + + assertEquals(409, exception.getStatusCode().value()); + verifyNoInteractions(inventory); + verify(inventoryAllocations, never()).lockTransfer(anyString(), anyLong()); + verify(transfers, never()).updateById(any(WarehouseTransferOrder.class)); + } + + @Test + void merchantMeasurementPhotosExposeOnlySubmittedSelection() { + WarehouseTransferOrder transfer = persistedTransfer(); + WarehouseTransferMeasurementPhoto selected = new WarehouseTransferMeasurementPhoto(); + selected.setFileId(101L); selected.setFileName("selected.jpg"); selected.setContentType("image/jpeg"); + selected.setSizeBytes(12L); selected.setUploadedAt(LocalDateTime.of(2026, 9, 16, 10, 0)); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(measurementPhotos.listSubmitted("TENANT-A", 21L)).thenReturn(List.of(selected)); + + var result = service.measurementPhotos("TENANT-A", "WTR-1"); + + assertEquals(List.of(101L), result.stream().map(value -> value.fileId()).toList()); + verify(measurementPhotos, never()).listOrder("TENANT-A", 21L); + } + + @Test + void merchantCannotDownloadUploadedButUnsubmittedMeasurementPhoto() { + WarehouseTransferOrder transfer = persistedTransfer(); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(measurementPhotos.findSubmittedFile("TENANT-A", 21L, 102L)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.downloadMeasurementPhoto("TENANT-A", "WTR-1", 102L)); + + assertEquals(404, exception.getStatusCode().value()); + verifyNoInteractions(measurementPhotoFiles); + } + + @Test + void pdaCanDownloadOwnWarehouseUploadedPhotoBeforeSubmission() { + WarehouseTransferOrder transfer = persistedTransfer(); + WarehouseTransferMeasurementPhoto draft = new WarehouseTransferMeasurementPhoto(); + draft.setFileId(102L); + var downloaded = new TransferMeasurementPhotoFileGateway.DownloadedFile( + new byte[]{1}, "draft.jpg", "image/jpeg"); + when(transfers.findNoByWarehouse("WTR-1", "92")).thenReturn(transfer); + when(measurementPhotos.findFile("TENANT-A", 21L, 102L)).thenReturn(draft); + when(measurementPhotoFiles.download("TENANT-A", 21L, 102L)).thenReturn(downloaded); + + var result = service.downloadPdaMeasurementPhoto(92L, "WTR-1", 102L); + + assertEquals("draft.jpg", result.fileName()); + verify(measurementPhotos, never()).findSubmittedFile(anyString(), anyLong(), anyLong()); + } + @Test void allocationRejectsPositionOutsideSelectedInboundOrder() { WarehouseTransferOrder transfer = transfer("APPROVED"); @@ -427,6 +789,7 @@ class WarehouseTransferServiceTest { transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5); + transfer.setFbaWarehouseCode("ONT8"); return transfer; } @@ -436,6 +799,24 @@ class WarehouseTransferServiceTest { return transfer; } + private static WarehouseTransferPallet measuredPallet(String status) { + WarehouseTransferPallet pallet = new WarehouseTransferPallet(); + pallet.setId(61L); pallet.setTransferOrderId(21L); pallet.setCustomerId("TENANT-A"); + pallet.setSequenceNo(1); pallet.setWarehousePalletId(701L); pallet.setPalletNo("NEW-PALLET-1"); + pallet.setCartonCount(5); pallet.setLengthCm(new BigDecimal("100.00")); + pallet.setWidthCm(new BigDecimal("100.00")); pallet.setHeightCm(new BigDecimal("100.00")); + pallet.setVolumeCbm(new BigDecimal("1.000000")); pallet.setGrossWeightKg(new BigDecimal("50.000")); + pallet.setStatus(status); + return pallet; + } + + private static WarehouseTransferItem transferItem(long id, String sku, int packages) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(id); item.setTransferOrderId(21L); item.setCustomerId("TENANT-A"); + item.setSkuCode(sku); item.setProductNameZh("Product"); item.setPackageCount(packages); + return item; + } + private static LastlegOrder tailOrder() { LastlegOrder order = historicalOrder(); order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT"); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 7e72dd35a..739505aa2 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -36,6 +36,10 @@ public interface FirstlegInboundClient { ApiResponse updateDeclaration(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderDeclarationDtos.UpdatedEvent event); + @PostMapping("/order-remarks-updated") + ApiResponse updateRemarks(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderRemarksSyncCommand command); + @PostMapping("/order-channel-changes/validate") ApiResponse validateOrderChannelChange( @RequestHeader("X-Internal-Service-Token") String token, @@ -51,6 +55,9 @@ public interface FirstlegInboundClient { String channelCode) {} record OrderChannelChangeValidationResponse(long orderId, long orderVersion, String channelCode, String mainStatus) {} + record OrderRemarksSyncCommand(long tenantId, long orderId, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark, + long operatorId, String operatorName) {} record OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index fdddfec73..21f720bc3 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -16,6 +16,8 @@ import org.springframework.beans.factory.annotation.Value; 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.PutMapping; +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.RestController; @@ -32,6 +34,7 @@ import java.util.stream.Collectors; @RequestMapping("/internal/order/v1/cargoes") public class InternalOrderCargoController { @Resource private OrderRepository orders; + @Resource private OrderApplicationService orderApplicationService; @Resource private PrintableInboundSnapshotService printableInboundSnapshots; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -146,6 +149,21 @@ public class InternalOrderCargoController { .collect(Collectors.joining("、")))); } + @PutMapping("/{tenantId}/{orderId}/remarks") + public ApiResponse updateRemarks( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, @PathVariable long orderId, + @RequestBody InternalRemarksUpdateRequest request) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0 || request == null || request.operatorId() <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "客户、订单和操作人必须大于0"); + } + var result = orderApplicationService.updateRemarksFromFirstleg(tenantId, request.operatorId(), orderId, + request.internalRemark(), request.warehouseRemark(), request.customerServiceRemark()); + return ApiResponse.success(new InternalRemarksUpdateResponse(result.version(), result.customerRemark(), + result.internalRemark(), result.warehouseRemark(), result.customerServiceRemark())); + } + private BigDecimal sum(List cargoes, java.util.function.Function getter) { List values = cargoes.stream().map(getter).filter(Objects::nonNull).toList(); @@ -205,4 +223,10 @@ public class InternalOrderCargoController { public record MeasurementDeclarationSnapshot( String referenceId, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String defaultActualGoodsName) {} + + public record InternalRemarksUpdateRequest(String internalRemark, String warehouseRemark, + String customerServiceRemark, long operatorId) {} + public record InternalRemarksUpdateResponse(int version, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark) {} + } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 7c18df6c5..044907daa 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -122,6 +122,7 @@ public class PlatformUnifiedOrderController { @PathVariable long orderId, @Valid @RequestBody OrderRequests.RemarksUpdateRequest request) { long scopedTenant = OrderScopeGuard.platform(user, tenantId); - return ApiResponse.success(orderService.updateRemarks(scopedTenant, user.userId(), orderId, request)); + return ApiResponse.success(orderService.updateRemarks( + scopedTenant, user.userId(), user.username(), orderId, request)); } } diff --git a/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java b/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java index b6c22abf3..5dae76824 100644 --- a/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java +++ b/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java @@ -33,6 +33,7 @@ public class CustomerAddressService { @Transactional public Long create(long tenantId, long userId, CustomerAddressDtos.SaveRequest request) { + ensureNoDuplicate(tenantId, request, null); CustomerAddressEntity value = new CustomerAddressEntity(); value.setId(IdWorker.getId()); value.setTenantId(tenantId); @@ -51,6 +52,7 @@ public class CustomerAddressService { if (!value.getAddressType().equals(request.addressType())) { throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型创建后不可修改"); } + ensureNoDuplicate(tenantId, request, id); if (request.defaultAddress()) clearDefault(tenantId, request.addressType(), id); apply(value, request); value.setUpdatedBy(userId); @@ -99,6 +101,25 @@ public class CustomerAddressService { .set(CustomerAddressEntity::getDefaultAddress, false)); } + private void ensureNoDuplicate(long tenantId, CustomerAddressDtos.SaveRequest request, Long excludedId) { + boolean duplicate = mapper.selectList(new LambdaQueryWrapper() + .eq(CustomerAddressEntity::getTenantId, tenantId) + .eq(CustomerAddressEntity::getActive, true) + .eq(CustomerAddressEntity::getAddressType, request.addressType()) + .eq(CustomerAddressEntity::getCompanyName, request.companyName().trim()) + .eq(CustomerAddressEntity::getContactName, request.contactName().trim()) + .eq(CustomerAddressEntity::getPhone, request.phone().trim()) + .eq(CustomerAddressEntity::getCountryCode, request.countryCode().trim().toUpperCase()) + .eq(CustomerAddressEntity::getCity, request.city().trim()) + .eq(CustomerAddressEntity::getAddressLine, request.addressLine().trim()) + .ne(excludedId != null, CustomerAddressEntity::getId, excludedId)) + .stream().findAny().isPresent(); + if (duplicate) { + throw new BizException(ResultCode.CONFLICT, "PICKUP".equals(request.addressType()) + ? "相同的企业提货地址已存在" : "相同的企业私人仓地址已存在"); + } + } + private static void apply(CustomerAddressEntity value, CustomerAddressDtos.SaveRequest source) { value.setAddressType(source.addressType()); value.setAddressName(source.addressName().trim()); value.setCompanyName(source.companyName().trim()); value.setContactName(source.contactName().trim()); diff --git a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java index 30eff5d11..0abda5e62 100644 --- a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java +++ b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java @@ -49,7 +49,7 @@ public class DomesticWarehouseOrderExportProviderService { column("mainStatus", "订单主状态", ExportColumnType.STRING, 16), column("customerName", "客户", ExportColumnType.STRING, 24), column("channelName", "渠道", ExportColumnType.STRING, 20), - column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN, 12), + column("channelChanged", "曾变更渠道", ExportColumnType.STRING, 12), column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING, 20), column("destinationWarehouseName", "交货仓", ExportColumnType.STRING, 20), column("expectedShipDate", "预计出运日期", ExportColumnType.DATE, 14), @@ -65,7 +65,7 @@ public class DomesticWarehouseOrderExportProviderService { column("skuNos", "SKU", ExportColumnType.STRING, 28), column("taxMode", "报关方式", ExportColumnType.STRING, 16), column("containerMode", "装柜方式", ExportColumnType.STRING, 14), - column("customsInspection", "是否商检", ExportColumnType.BOOLEAN, 12), + column("customsInspection", "是否商检", ExportColumnType.STRING, 12), column("containerNo", "集装箱号", ExportColumnType.STRING, 20), column("cabinetNo", "柜次号", ExportColumnType.STRING, 20), column("vesselName", "船名", ExportColumnType.STRING, 18), @@ -126,16 +126,16 @@ public class DomesticWarehouseOrderExportProviderService { private Map row(UnifiedOrderResponse.Row value) { Map row = new LinkedHashMap<>(); row.put("inboundCode", value.inboundCode()); row.put("orderNo", value.orderNo()); - row.put("mainStatus", value.mainStatus()); row.put("customerName", value.customerName()); - row.put("channelName", value.channelName()); row.put("channelChanged", value.channelChanged()); row.put("receivingWarehouseName", value.receivingWarehouseName()); + row.put("mainStatus", mainStatusLabel(value.mainStatus())); row.put("customerName", value.customerName()); + row.put("channelName", value.channelName()); row.put("channelChanged", yesOrNo(value.channelChanged())); row.put("receivingWarehouseName", value.receivingWarehouseName()); row.put("destinationWarehouseName", value.destinationWarehouseName()); row.put("expectedShipDate", value.expectedShipDate()); row.put("expectedArrivalAt", value.expectedArrivalAt()); row.put("expectedVolumeCbm", value.expectedVolumeCbm()); row.put("totalPackageCount", value.totalPackageCount()); row.put("totalQuantity", value.totalQuantity()); row.put("actualGrossWeightKg", value.actualGrossWeightKg()); row.put("actualVolumeCbm", value.actualVolumeCbm()); row.put("chargeableWeightKg", value.chargeableWeightKg()); row.put("chargeableVolumeCbm", value.chargeableVolumeCbm()); - row.put("feeReviewStatus", value.feeReviewStatus()); row.put("skuNos", join(value.skuNos())); - row.put("taxMode", value.taxMode()); row.put("containerMode", value.containerMode()); - row.put("customsInspection", value.customsInspection()); + row.put("feeReviewStatus", feeReviewStatusLabel(value.feeReviewStatus())); row.put("skuNos", join(value.skuNos())); + row.put("taxMode", taxModeLabel(value.taxMode())); row.put("containerMode", containerModeLabel(value.containerMode())); + row.put("customsInspection", yesOrNo(value.customsInspection())); var transport = value.transport(); row.put("containerNo", transport == null ? null : transport.containerNo()); row.put("cabinetNo", transport == null ? null : transport.cabinetNo()); @@ -233,6 +233,33 @@ public class DomesticWarehouseOrderExportProviderService { } private static String join(List values) { return values == null ? null : String.join(",", values); } + private static String mainStatusLabel(String value) { + return label(value, Map.ofEntries( + Map.entry("DRAFT", "草稿"), Map.entry("SUBMITTED", "待审核"), Map.entry("APPROVED", "已审核"), + Map.entry("ACCEPTED", "已受理"), Map.entry("REJECTED", "已驳回"), Map.entry("PROCESSING", "处理中"), + Map.entry("COMPLETED", "已完成"), Map.entry("CANCELLED", "已取消"), + Map.entry("PENDING_REVIEW", "待审核"), Map.entry("WAIT_MEASURE", "待测量数据"), + Map.entry("WAIT_PALLETIZE", "待一件代发组托"), Map.entry("INBOUNDED", "已入库"), + Map.entry("WAIT_SPLIT", "待分柜"), Map.entry("LOADING", "装柜中"), + Map.entry("LOADED", "装柜完成"), Map.entry("DELIVERED", "派送完成"))); + } + private static String feeReviewStatusLabel(String value) { + return label(value, Map.ofEntries( + Map.entry("NOT_SUBMITTED", "未提交"), Map.entry("UNRECORDED", "未录入"), + Map.entry("PENDING", "待审核"), Map.entry("APPROVED", "审核通过"), + Map.entry("REJECTED", "审核驳回"), Map.entry("CONFIRMED", "已确认"))); + } + private static String taxModeLabel(String value) { + return label(value, Map.of("BUY_DECLARATION", "买单报关", "TAX_REFUND", "退税报关")); + } + private static String containerModeLabel(String value) { + return label(value, Map.of("FULL_CONTAINER", "整柜", "FCL", "整柜", "LCL", "拼箱")); + } + private static String yesOrNo(Boolean value) { return value == null ? null : value ? "是" : "否"; } + private static String label(String value, Map labels) { + if (value == null) return null; + return labels.getOrDefault(value.trim().toUpperCase(), value); + } private static ExportColumn column(String key, String title, ExportColumnType type, int width) { return new ExportColumn(key, title, type, width); } diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java index 7c466f551..9e4e47542 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java @@ -527,10 +527,13 @@ public class MerchantBatchImportService { private static Map optionMap(List values, Function labeler) { Map result = new LinkedHashMap<>(); for (T value : values) { - String label = labeler.apply(value); - if (result.putIfAbsent(label, value) != null) { - throw new BizException(ResultCode.CONFLICT, "模板选项显示名称重复,请联系平台处理"); + String baseLabel = labeler.apply(value); + String label = baseLabel; + int duplicateNumber = 2; + while (result.containsKey(label)) { + label = baseLabel + "(同名选项 " + duplicateNumber++ + ")"; } + result.put(label, value); } return result; } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 6620f5df0..3b349c2f7 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -271,6 +271,12 @@ public class OrderApplicationService { @Transactional public OrderRequests.RemarksUpdateResponse updateRemarks(long tenantId, long userId, long id, OrderRequests.RemarksUpdateRequest request) { + return updateRemarks(tenantId, userId, null, id, request); + } + + @Transactional + public OrderRequests.RemarksUpdateResponse updateRemarks(long tenantId, long userId, String operatorName, long id, + OrderRequests.RemarksUpdateRequest request) { OrderEntity order = require(tenantId, id); String internalRemark = normalizeRemark(request.internalRemark()); String warehouseRemark = normalizeRemark(request.warehouseRemark()); @@ -284,10 +290,57 @@ public class OrderApplicationService { note.setTenantId(tenantId); note.setOrderId(id); note.setContent(internalRemark); note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); } + syncRemarksToFirstleg(tenantId, id, order.getCustomerRemark(), internalRemark, warehouseRemark, customerServiceRemark, + userId, operatorName); return new OrderRequests.RemarksUpdateResponse(order.getVersion() + 1, order.getCustomerRemark(), internalRemark, warehouseRemark, customerServiceRemark); } + @Transactional + public OrderRequests.RemarksUpdateResponse updateRemarksFromFirstleg(long tenantId, long userId, long id, + String internalValue, + String warehouseValue, + String customerServiceValue) { + requireRemarkLength(internalValue); + requireRemarkLength(warehouseValue); + requireRemarkLength(customerServiceValue); + OrderEntity order = require(tenantId, id); + String internalRemark = normalizeRemark(internalValue); + String warehouseRemark = normalizeRemark(warehouseValue); + String customerServiceRemark = normalizeRemark(customerServiceValue); + if (repository.updateRemarks(tenantId, id, order.getVersion(), userId, + internalRemark, warehouseRemark, customerServiceRemark) != 1) { + throw new BizException(ResultCode.CONFLICT, "订单备注已被其他人修改,请刷新后重试"); + } + if (!Objects.equals(order.getInternalRemark(), internalRemark) && internalRemark != null) { + OrderInternalNoteEntity note = new OrderInternalNoteEntity(); + note.setTenantId(tenantId); note.setOrderId(id); note.setContent(internalRemark); + note.setCreatedBy(userId); note.setUpdatedBy(userId); repository.insertNote(note); + } + return new OrderRequests.RemarksUpdateResponse(order.getVersion() + 1, order.getCustomerRemark(), + internalRemark, warehouseRemark, customerServiceRemark); + } + + private void syncRemarksToFirstleg(long tenantId, long orderId, String customerRemark, String internalRemark, + String warehouseRemark, String customerServiceRemark, + long operatorId, String operatorName) { + FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); + if (client == null) return; + ApiResponse response = client.updateRemarks(internalToken, + new FirstlegInboundClient.OrderRemarksSyncCommand(tenantId, orderId, customerRemark, internalRemark, + warehouseRemark, customerServiceRemark, operatorId, operatorName)); + if (response == null || !response.successful()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, + response == null ? "头程备注同步暂不可用" : response.message()); + } + } + + private static void requireRemarkLength(String value) { + if (value != null && value.length() > 1000) { + throw new BizException(ResultCode.BAD_REQUEST, "每项备注不能超过1000个字符"); + } + } + private static String normalizeRemark(String value) { if (value == null || value.trim().isEmpty()) return null; return value.trim(); diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index bacfda9e7..71cdef183 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -324,11 +324,11 @@ public class UnifiedOrderQueryService { order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null, - order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, + order.getEstimatedVolumeCbm(), volumeCbm(cargo), grossWeightKg(cargo), volumeCbm(cargo), order.getExpectedArrivalAt(), null, null, null, order.getFulfillmentType(), order.getLxCode(), order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), - null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), + null, null, null, null, netWeightKg(cargo), null, null, order.getExpressCompany(), order.getExpressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), null, null, null, null, null, coLoadGroup, order.getCustomerRemark(), latestInternalNote, order.getWarehouseRemark(), order.getCustomerServiceRemark(), @@ -348,13 +348,14 @@ public class UnifiedOrderQueryService { displayedPackageCount(order, cargo, inbound), null, distinctLocationCodes(inbound.locationCodes()), order.getEstimatedVolumeCbm(), volumeCbm(cargo), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), + measuredOrDeclared(inbound.actualGrossWeightKg(), grossWeightKg(cargo)), + measuredOrDeclared(inbound.actualVolumeCbm(), volumeCbm(cargo)), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), - inbound.actualNetWeightKg(), + measuredOrDeclared(inbound.actualNetWeightKg(), netWeightKg(cargo)), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -378,13 +379,14 @@ public class UnifiedOrderQueryService { inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.holdFlag(), inbound.currentHoldReason(), displayedPackageCount(order, cargo, inbound), null, distinctLocationCodes(inbound.locationCodes()), order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), - inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), + measuredOrDeclared(inbound.actualGrossWeightKg(), grossWeightKg(cargo)), + measuredOrDeclared(inbound.actualVolumeCbm(), volumeCbm(cargo)), order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), - inbound.actualNetWeightKg(), + measuredOrDeclared(inbound.actualNetWeightKg(), netWeightKg(cargo)), inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(), inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), measurement(inbound), preload(summary.preload()), loading(summary.loading()), @@ -490,6 +492,10 @@ public class UnifiedOrderQueryService { return cargo == null ? null : cargo.volumeCbm(); } + private BigDecimal measuredOrDeclared(BigDecimal measured, BigDecimal declared) { + return measured == null ? declared : measured; + } + private record CargoSummary(List skuNos, List shipmentIds, List referenceIds, Integer totalQuantity, Integer totalPackageCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index eb5873cb7..3270cf299 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -59,6 +59,34 @@ paths: application/json: schema: {$ref: '#/components/schemas/CargoLabelBindingListEnvelope'} '403': {description: 内部服务令牌无效} + put: + security: [] + summary: Firstleg 维护订单权威协作备注 + description: >- + 仅接受内部服务令牌。预装清单编辑备注时写回 Order 权威订单记录; + 客户备注保持只读,内部、仓库和客服备注统一按订单版本乐观更新。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + - {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [operatorId] + properties: + internalRemark: {type: string, nullable: true, maxLength: 1000} + warehouseRemark: {type: string, nullable: true, maxLength: 1000} + customerServiceRemark: {type: string, nullable: true, maxLength: 1000} + operatorId: {type: integer, format: int64, minimum: 1} + responses: + '200': {description: 返回订单最新版本和四类备注} + '403': {description: 内部服务令牌无效} /internal/order/v1/cargoes/{tenantId}/{orderId}/printable-inbound-snapshot: post: security: [] diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 99608e18e..e144c7c38 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.PostMapping; @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalOrderCargoControllerTest { @MockBean private OrderRepository orders; + @MockBean private OrderApplicationService orderApplicationService; @MockBean private PrintableInboundSnapshotService printableInboundSnapshots; @Resource private InternalOrderCargoController controller; diff --git a/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java b/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java index 24ece6108..d09a76f54 100644 --- a/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java @@ -7,9 +7,12 @@ import com.baiyue.order.mapper.CustomerAddressMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,4 +41,21 @@ class CustomerAddressServiceTest { assertThatThrownBy(() -> service.required(10001L, 90001L, "PICKUP")) .isInstanceOf(BizException.class).hasMessage("客户地址不存在或已停用"); } + + @Test + void rejectsDuplicatePickupAddressBeforeCreatingAnotherRecord() { + CustomerAddressMapper mapper = mock(CustomerAddressMapper.class); + CustomerAddressEntity duplicate = new CustomerAddressEntity(); + duplicate.setId(90001L); + when(mapper.selectList(any())).thenReturn(List.of(duplicate)); + CustomerAddressService service = new CustomerAddressService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + var request = new CustomerAddressDtos.SaveRequest("PICKUP", "深圳工厂", "柏悦物流", + "惠万昌", "13229608341", null, "CN", "江西省", "抚州市", null, + "崇仁县三山乡长仁大队廉家村", null, false); + + assertThatThrownBy(() -> service.create(10001L, 20001L, request)) + .isInstanceOf(BizException.class).hasMessage("相同的企业提货地址已存在"); + verify(mapper, never()).insert(any(CustomerAddressEntity.class)); + } } diff --git a/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java index 2a8c04772..cdbe131cc 100644 --- a/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import jakarta.annotation.Resource; @@ -130,6 +131,29 @@ class DomesticWarehouseOrderExportProviderServiceTest { .hasMessageContaining("does not match"); } + @Test + void exportsChineseDisplayValuesForStatusAndEnumColumns() { + UnifiedOrderResponse.Row row = row("ORD-1", "RK-1", "客户甲"); + when(row.mainStatus()).thenReturn("PENDING_REVIEW"); + when(row.channelChanged()).thenReturn(false); + when(row.feeReviewStatus()).thenReturn("NOT_SUBMITTED"); + when(row.taxMode()).thenReturn("BUY_DECLARATION"); + when(row.containerMode()).thenReturn("LCL"); + when(row.customsInspection()).thenReturn(false); + when(orders.platformRowsByOrderNos(List.of("ORD-1"))).thenReturn(List.of(row)); + + ExportBatchResponse response = service.exportBatch(request("1009", + Map.of("selectedOrderNos", List.of("ORD-1")), null, null, 100)); + + assertThat(response.rows().get(0)).containsEntry("mainStatus", "待审核") + .containsEntry("channelChanged", "否").containsEntry("feeReviewStatus", "未提交") + .containsEntry("taxMode", "买单报关").containsEntry("containerMode", "拼箱") + .containsEntry("customsInspection", "否"); + assertThat(response.columns()).filteredOn(column -> column.key().equals("channelChanged") + || column.key().equals("customsInspection")).extracting(column -> column.type()) + .containsOnly(ExportColumnType.STRING); + } + private ExportBatchRequest request(String taskId, Map parameters, String cursor, String snapshot, int limit) { return new ExportBatchRequest(taskId, "0", "PLATFORM", "7001", 1, diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java index 936efafcc..c89906f3b 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java @@ -229,6 +229,26 @@ class MerchantBatchImportServiceTest { verify(warehouses).page("Bearer test", 2, 200, "ACTIVE"); } + @Test + void templateKeepsDuplicateDisplayNamesAsDistinctBusinessOptions() throws Exception { + var domestic = new MerchantWarehouseCatalogClient.Warehouse(21L, "CN-YW", "义乌收货仓", + "DOMESTIC", "CN", "ACTIVE"); + var firstFba = new MerchantWarehouseCatalogClient.Warehouse(22L, "ATL6", "ATL6", + "FBA", "US", "ACTIVE"); + var secondFba = new MerchantWarehouseCatalogClient.Warehouse(24L, "ATL6", "ATL6", + "FBA", "US", "ACTIVE"); + when(warehouses.page("Bearer test", 1, 200, "ACTIVE")) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, firstFba, secondFba), 1, 200, 3, 1))); + + byte[] content = service.template(88L, "Bearer test"); + + try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + var options = workbook.getSheet("选项数据"); + assertThat(hasCellValue(options, "ATL6")).isTrue(); + assertThat(hasCellValue(options, "ATL6(同名选项 2)")).isTrue(); + } + } + @Test void templateFailsClosedWhenLaterWarehousePageCannotBeRead() { List firstPage = new ArrayList<>(); diff --git a/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java new file mode 100644 index 000000000..7c1eb5513 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java @@ -0,0 +1,53 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OrderRemarksSynchronizationTest { + @Test + void platformUpdateSynchronizesAuthoritativeRemarksToFirstleg() { + OrderRepository repository = mock(OrderRepository.class); + FirstlegInboundClient firstleg = mock(FirstlegInboundClient.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + OrderApplicationService service = new OrderApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "firstlegProvider", provider); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + + OrderEntity order = new OrderEntity(); + order.setId(20L); + order.setTenantId(10L); + order.setVersion(7); + order.setCustomerRemark("客户备注"); + when(repository.findOwned(10L, 20L)).thenReturn(order); + when(repository.updateRemarks(10L, 20L, 7, 30L, "内部", "仓库", "客服")).thenReturn(1); + when(provider.getIfAvailable()).thenReturn(firstleg); + when(firstleg.updateRemarks(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(2)); + + var response = service.updateRemarks(10L, 30L, "操作人", 20L, + new OrderRequests.RemarksUpdateRequest(7, " 内部 ", "仓库", "客服")); + + assertThat(response.version()).isEqualTo(8); + ArgumentCaptor command = + ArgumentCaptor.forClass(FirstlegInboundClient.OrderRemarksSyncCommand.class); + verify(firstleg).updateRemarks(org.mockito.ArgumentMatchers.eq("internal-token"), command.capture()); + assertThat(command.getValue().customerRemark()).isEqualTo("客户备注"); + assertThat(command.getValue().internalRemark()).isEqualTo("内部"); + assertThat(command.getValue().warehouseRemark()).isEqualTo("仓库"); + assertThat(command.getValue().customerServiceRemark()).isEqualTo("客服"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 106ff2e31..272ed6f56 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -127,7 +127,7 @@ class UnifiedOrderQueryServiceTest { } @Test - void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { + void newlyCreatedOrderUsesItsOwnCargoTotalsBeforeInboundExists() { UnifiedOrderQuery query = query(null); OrderEntity order = order(OrderStatus.APPROVED); OrderCargoEntity cargo = new OrderCargoEntity(); @@ -144,13 +144,39 @@ class UnifiedOrderQueryServiceTest { assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { assertThat(row.totalPackageCount()).isEqualTo(100); assertThat(row.expectedVolumeCbm()).isEqualByComparingTo("0.375"); - assertThat(row.actualGrossWeightKg()).isNull(); - assertThat(row.totalNetWeightKg()).isNull(); - assertThat(row.actualVolumeCbm()).isNull(); + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("12.5"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("11.2"); + assertThat(row.actualVolumeCbm()).isEqualByComparingTo("0.375"); }); verify(orders).findCargoByOrderIds(10001L, List.of(77L)); } + @Test + void unmeasuredInboundFallsBackToDeclaredCargoTotals() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setOrderId(77L); + cargo.setPackageCount(50); + cargo.setGrossWeightKg(new BigDecimal("1500")); + cargo.setNetWeightKg(new BigDecimal("25")); + cargo.setVolumeCbm(new BigDecimal("7.5")); + var summary = summary("WAIT_MEASURE", null); + when(summary.inbound().actualGrossWeightKg()).thenReturn(null); + when(summary.inbound().actualNetWeightKg()).thenReturn(null); + when(summary.inbound().actualVolumeCbm()).thenReturn(null); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(summary))); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(cargo)); + + assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { + assertThat(row.actualGrossWeightKg()).isEqualByComparingTo("1500"); + assertThat(row.totalNetWeightKg()).isEqualByComparingTo("25"); + assertThat(row.actualVolumeCbm()).isEqualByComparingTo("7.5"); + }); + } + @Test void dropshipMixedSkuRowsCountSharedCartonVolumeOnlyOnce() { UnifiedOrderQuery query = query(null); diff --git a/scripts/README.md b/scripts/README.md index 8a5c942b3..817f4a5fa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 + +`db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。 + `mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 @@ -71,6 +75,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `create-pending-review-orders.mjs` | 固定客户批量创建并提交待审核订单,不做平台审核和后续履约 | | 测试环境 HTTP | `it-test-wait-split-bulk.mjs` | 固定“IT测试”与“义乌仓”,渠道等随机,逐票推进到 `WAIT_SPLIT`;失败继续并落报告。只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,库位不足使用公共位 `A0` | +| 本地真实 API | `relabel-closure-local-acceptance.mjs` | 仅调用本机海外 Gateway/Print/File/Lastleg,创建合成换标单并验证重复下单、三类 PDF、PDA 换标、拆托/合托、返工和换标出库;会写入本地业务数据,不代表现场照片或财务验收 | | 测试环境 Excel 造数 | `real-order-xlsx-test-env.mjs` | 从外部源 XLSX 按入仓码归并,严格匹配客户可用渠道、税制、目的仓;`preflight` 只核对,`create` 创建草稿,`pending-review` 批量提交后停在待审核且不使用托盘/库位,`wait-split` 只供少量完整测量验收。参数、限制及重跑说明见 `real-order-xlsx-import.md` | | 测试环境 HTTP | `dimension-ocr-pricing-test-env.ps1` | 尺寸、OCR 和渠道计价专项,上传本地图片并写测试业务数据;包含历史默认参数,只能显式传入当前目标参数后使用 | | 测试环境 HTTP | `export-center-test-env-acceptance.ps1` | 导出中心测试环境验收,会创建导出任务和相关测试数据 | @@ -276,6 +281,10 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 ## 海外换标定向验收补充 -- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。只允许本机开发环境且须有当前轮真实写入授权。 +- `db/seed-local-relabel-ready-orders.sql`:仅供明确授权的本机测试库存准备。调用者在同一 MySQL 会话提供客户、共享海外仓、有效虚拟库位、FBA目的仓、唯一批次及ID基数;凭据由本机进程环境提供。以事务插入三单共54箱、六托及原入仓快照、计划、明细、余额、仓位和明确标记 LOCAL_TEST_FIXTURE 的库存流水;不伪造PDA照片、签收操作或HTTP验收通过,不修改原数据。重复批次失败并回滚,不用upsert重置已消耗库存;成功数据保留,结果表输出到调用者日志,不自动清理。 + +- `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。履约阶段中断后可显式设置 `OVERSEAS_RESUME_FULFILLMENT=true`,复用目录内已通过的 `relabel.json` 且只续跑出库派送。只允许本机开发环境且须有当前轮真实写入授权。 - `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。 +- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。 +- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。 - `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。 diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index 3ea3488da..a02650a34 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -30,7 +30,7 @@ export function containerFieldGaps(record, { requireContainerNo = false } = {}) cabinetNo: isNonBlank, warehouseId: isPositiveId, warehouseName: isNonBlank, - channelCode: isNonBlank, + channelGroupCode: isNonBlank, containerType: isNonBlank, capacityCbm: (value) => Number(value) > 0, maxWeightKg: (value) => Number(value) > 0, diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index b5d98d353..059d2c520 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -82,7 +82,7 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => { test('字段校验器接受正式完整投影并精确暴露缺字段', () => { const samples = [ - [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], + [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelGroupCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], [preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HQ', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 8b139fde4..f56b47f35 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse'] test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 136]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 208], [overseas, overseasSchemas, domesticSchemas, 138]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前 assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi) assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i) assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1) - assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|136) AS initialized_tables;$/) + assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (208|138) AS initialized_tables;$/) } }) @@ -47,7 +47,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 346, iam_role_permission: 521, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 364, iam_role_permission: 539, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 750fbf15f..f00f230bd 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -110,7 +110,7 @@ async function verifyPalletLabels(context, fixture, loadingPlanId) { const index = Number(label?.palletIndex) return id(label?.containerNo) === id(fixture.containerNo) && id(label?.destinationWarehouseInternalCode) - && id(label?.palletCode) === `${fixture.containerNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}` + && id(label?.palletCode) === `${fixture.cabinetNo}-${label.destinationWarehouseInternalCode}-${String(index).padStart(3, '0')}` }) const indexesValid = (manifest?.groups || []).every((group) => (group?.labels || []).every((label, index) => Number(label?.palletIndex) === index + 1)) @@ -580,6 +580,7 @@ export async function run(context) { destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, palletLabelManifest, + cabinetNo: fixture.cabinetNo || null, containerNo: fixture.containerNo || null, sourceStatus: 'LOADED', verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c25df145c..ee0b43833 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1715,7 +1715,8 @@ export async function run(context) { missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, immutableEvidenceId: id(immutablePhoto?.id), immutableEvidenceVersion: Number(immutablePhoto?.version), postLoadedFileId, - oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId, + oceanBookingId: bookingId, loadingPlanId, containerBookingId, + cabinetNo: simulation.cabinetNo, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, atomicBatchVerified: awaitingReview && atomicBatchReplayVerified, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index be5a6a9c1..ae312ffab 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -81,10 +81,13 @@ test('measurement and loading pass destination identity into the LOADED producer const finalize = read('phase3-loading-finalize.mjs') assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) + assert.match(loading, /cabinetNo: simulation\.cabinetNo, containerNo/) assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/) assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuCodes\)/) assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuLines\)/) assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) + assert.match(finalize, /cabinetNo: fixture\.cabinetNo \|\| null/) + assert.match(finalize, /`\$\{fixture\.cabinetNo\}-\$\{label\.destinationWarehouseInternalCode\}-/) assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/) assert.match(finalize, /skuCodes: Array\.isArray\(fixture\.skuCodes\) \? fixture\.skuCodes : \[\]/) assert.match(finalize, /skuLines: Array\.isArray\(fixture\.skuLines\) \? fixture\.skuLines : \[\]/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 79db67ffe..3b581ae11 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -103,6 +103,9 @@ test('PDA module covers the container-number receiving contract and excludes out for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /同键同载荷重放/) assert.match(source, /inbound-plans\/container-preview/) + assert.match(source, /orders\/container-arrival-times\/query/) + assert.match(source, /container-summaries\/\$\{encodeURIComponent\(bookingId\)\}\/expected-arrival/) + assert.match(source, /estimatedWarehouseArrivalAt, expectedVersion: Number\(arrivalTime\.version\)/) assert.match(source, /snapshotVersion: Number\(preview\.snapshotVersion\)/) assert.match(source, /snapshotHash: String\(preview\.snapshotHash\)/) assert.match(source, /rememberGeneratedPlans\(context, plans\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 7cf858e55..10e9012f7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -336,6 +336,33 @@ async function convertReceivingContainers(context, producers) { `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS) if (!convertible) return false const bookingId = id(summary.sourceContainerBookingId) + const arrivalQueryResponse = await context.requestOverseas('POST', + '/api/platform/v1/last-leg/orders/container-arrival-times/query', + { sourceContainerBookingIds: [bookingId] }, {}, context.overseasToken) + const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data) + ? arrivalQueryResponse.body.data : [] + let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId) + context.addStep(MODULE, `读取整柜预计到仓时间 ${containerNo}`, arrivalQueryResponse, + Boolean(arrivalTime) && Number.isInteger(Number(arrivalTime?.version)), + `sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalQueryResponse)}`, + OPTIONS) + if (!arrivalTime) return false + if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) { + const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19) + const arrivalUpdateResponse = await context.requestOverseas('PUT', + `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, + { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version), + remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, + { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) }, + context.overseasToken) + arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null + context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse, + id(arrivalTime?.sourceContainerBookingId) === bookingId + && nonBlank(arrivalTime?.estimatedWarehouseArrivalAt), + `sourceContainerBookingId=${bookingId}, estimatedWarehouseArrivalAt=${arrivalTime?.estimatedWarehouseArrivalAt || 'missing'}, version=${arrivalTime?.version ?? 'missing'}; ${trace(arrivalUpdateResponse)}`, + OPTIONS) + if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false + } const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}` const previewResponse = await context.requestOverseas('GET', previewPath, undefined, {}, context.overseasToken) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 1b89c166a..f9d071fde 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -103,6 +103,24 @@ $clientAgents = Read-RequiredText $clientAgentsPath $pdaAgents = Read-RequiredText $pdaAgentsPath $overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath $backendAgents = Read-RequiredText $backendAgentsPath +$backendIndexPath = Join-Path $backendRoot 'docs/backend-business-index.md' +$lastlegIndexPath = Join-Path $backendRoot 'lastleg-service/docs/business-index.md' +$backendIndex = Read-RequiredText $backendIndexPath +$lastlegIndex = Read-RequiredText $lastlegIndexPath +Assert-Contains $backendAgents 'docs/backend-business-index.md' '后端按需索引入口' +$lastlegEntry = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +Assert-Contains $lastlegEntry 'docs/business-index.md' 'Lastleg 按需索引入口' +foreach ($budget in @( + @{ Name = '后端根'; Text = $backendAgents; Limit = 13000 }, + @{ Name = 'Lastleg'; Text = $lastlegEntry; Limit = 18000 } +)) { + $script:checked++ + if ($budget.Text.Length -gt $budget.Limit) { Add-Failure "$($budget.Name) 指引超出字符预算 $($budget.Limit),请将细节按需下沉" } +} +Assert-MarkdownTableUnique $backendIndexPath '## 服务路由(按关键词定位)' 0 '后端详细索引' +Assert-MarkdownTableUnique $lastlegIndexPath '## 快速索引' 0 'Lastleg 详细索引' +# 保留全部业务语义断言,只将事实读取范围扩展到明确链接的按需文档。 +$backendAgents += "`n" + $backendIndex # 固定样本只检查目标索引行和真实文件,不递归搜索;字符数是读取量指标,不是耗时或 Token 实测。 $lookupSamples = @( @@ -118,6 +136,18 @@ $lookupSamples = @( @{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' } ) $lookupCharacters = 0 +foreach ($alias in @( + @{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' }, + @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' } +)) { + $aliasSection = (($businessIndex -split '## 常用说法与歧义分流', 2)[1] -split '\r?\n## ', 2)[0] + $hits = @($aliasSection -split '\r?\n' | Where-Object { $_.Contains($alias.Term) }) + $script:checked++ + if ($hits.Count -ne 1) { Add-Failure "口语入口必须唯一命中: $($alias.Term)"; continue } + Assert-Contains $hits[0] $alias.Entry "口语入口 $($alias.Term)" + Assert-Path $alias.Entry +} foreach ($sample in $lookupSamples) { $hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key) }) $script:checked++ @@ -231,7 +261,7 @@ Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs' Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同' Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同' $firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') -$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex $warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' @@ -589,7 +619,7 @@ foreach ($gatewayPath in @( '/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', - '/api/merchant/v1/warehouses/**', + '/api/platform/v1/warehouses/**', '/api/platform/v1/warehouse-admin/**' )) { Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas Gateway 路由' @@ -625,7 +655,7 @@ Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列 # 海外出库、派送、异常和回访必须从根路由贯通到 Admin 与 Lastleg;PDA 后端存在不等于设备端已挂载。 $adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') -$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex foreach ($overseasFulfillmentRoute in @( 'overseas-warehouse/outbounds', 'overseas-warehouse/delivery-orders', @@ -759,7 +789,7 @@ Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收 # 尾程订单按来源柜展示服务端计划进度;整柜确认必须绑定预览快照并编排真实托盘批次。 $lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') -$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') +$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex $adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') $containerConversionCoordinator = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java') $warehousePalletBatchService = Read-RequiredText (Join-Path $backendRoot 'warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java') @@ -1136,6 +1166,7 @@ $criticalPaths | ForEach-Object { Assert-Path $_ } foreach ($removedOverseasWarehouseMasterPath in @( 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java', 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java' @@ -1152,6 +1183,8 @@ $indexedAgentFiles = @( $pdaAgentsPath, $overseasPdaAgentsPath, $backendAgentsPath, + $backendIndexPath, + $lastlegIndexPath, (Join-Path $backendRoot 'firstleg/AGENTS.md'), (Join-Path $backendRoot 'finance/AGENTS.md'), (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index aafda41ed..20084f53b 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -4,6 +4,8 @@ import test from 'node:test' const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +const servicePipeline = (await readFile(new URL('../Jenkinsfile.service', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') test('coordinator releases its builder while waiting for downstream jobs', () => { assert.match(pipeline, /pipeline \{[\s\S]*?agent none/) @@ -68,3 +70,14 @@ test('production deploy uses DEPLOY as the single explicit release switch', () = assert.doesNotMatch(pipeline, /input message:/) assert.doesNotMatch(pipeline, /\u786e\u8ba4\u90e8\u7f72/) }) + +test('production deploy reports one aggregated result to WeCom', () => { + assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/) + assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/) + assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/) + assert.match(pipeline, /unstable \{[\s\S]*?'\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/) + assert.match(pipeline, /failure \{[\s\S]*?'\u90e8\u7f72\u5931\u8d25'/) + assert.match(pipeline, /aborted \{[\s\S]*?'\u90e8\u7f72\u5df2\u53d6\u6d88'/) + assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/) + assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/) +}) diff --git a/scripts/current-session-overseas-relabel-acceptance.mjs b/scripts/current-session-overseas-relabel-acceptance.mjs index df617b1d5..2e43cc6de 100644 --- a/scripts/current-session-overseas-relabel-acceptance.mjs +++ b/scripts/current-session-overseas-relabel-acceptance.mjs @@ -113,6 +113,43 @@ async function createPda(adminToken, warehouseId, onCreated) { throw new Error('Temporary PDA security projection did not converge') } +async function merchantSessionForTenant(adminToken, tenantId) { + const ticket = await api(domestic, adminToken, 'POST', + `/api/platform/v1/iam/tenants/${tenantId}/impersonation`, { reason: `${marker} 海外换标真实验收` }) + return api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) +} + +async function findRelabelScope(adminToken, preferredMerchant = null) { + const sessions = [] + if (preferredMerchant?.accessToken && preferredMerchant?.overseasAccessToken) sessions.push(preferredMerchant) + if (!sessions.length) { + const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE') + for (const tenant of tenants.records || []) { + try { sessions.push(await merchantSessionForTenant(adminToken, id(tenant.id))) } catch {} + } + } + for (const session of sessions) { + const customerId = jwtId(session.overseasAccessToken, 'tenant_id', 'tenantId') + if (!customerId) continue + let warehouses + try { warehouses = await api(domestic, session.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') } + catch { continue } + for (const warehouse of warehouses.records || []) { + if (warehouse.status !== 'ACTIVE') continue + let candidates + try { candidates = await api(overseas, session.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) } + catch { continue } + if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') + && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode + && item.positions?.some((position) => position.palletNo && position.palletNo !== 'UNBOUND' + && Number(position.availablePackages) >= 5))) { + return { merchant: session, customerId, warehouse } + } + } + } + throw new Error('No active customer has a destination warehouse and five cartons on a physical source pallet') +} + await mkdir(outputDir, { recursive: true }) let admin let pda @@ -124,29 +161,22 @@ try { } const storedMerchantToken = storedJwt('cc-client-overseas-access-token') const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId') - let merchant + let preferredMerchant = null try { - merchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') + preferredMerchant = await refreshStoredSession('cc-client-refresh-token', 'merchant') } catch { - assert(storedCustomerId, 'Current Codex merchant session does not contain a customer id') - const ticket = await api(domestic, admin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${storedCustomerId}/impersonation`, { reason: `${marker} 当前客户海外API验收` }) - merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + if (storedCustomerId) preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId) } - assert(admin.accessToken && admin.overseasAccessToken && merchant.accessToken && merchant.overseasAccessToken, 'Refresh response did not contain both regional tokens') - const customerId = jwtId(merchant.overseasAccessToken, 'tenant_id', 'tenantId') - assert(customerId, 'Current merchant customer id is missing from the overseas token') - const warehouses = await api(domestic, merchant.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') - let selected = null - for (const warehouse of warehouses.records || []) { - if (warehouse.status !== 'ACTIVE') continue - const candidates = await api(overseas, merchant.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) - if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-') && Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5))) { - selected = warehouse; break - } - } - assert(selected, 'Current merchant has no active overseas warehouse with a five-carton DS inventory source') - await disableStalePdas(admin.accessToken) + assert(admin.accessToken && admin.overseasAccessToken, 'Platform refresh response did not contain both regional tokens') + const resume = process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' + if (resume && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || !process.env.OVERSEAS_TEST_WAREHOUSE_ID)) throw new Error('Resume requires explicit customer and warehouse from the saved run') + const scope = resume ? { + merchant: await merchantSessionForTenant(admin.accessToken, process.env.OVERSEAS_TEST_CUSTOMER_ID), + customerId: process.env.OVERSEAS_TEST_CUSTOMER_ID, + warehouse: { id: process.env.OVERSEAS_TEST_WAREHOUSE_ID, warehouseCode: process.env.OVERSEAS_TEST_WAREHOUSE_CODE || null }, + } : await findRelabelScope(admin.accessToken, preferredMerchant) + const { merchant, customerId, warehouse: selected } = scope + // Do not disable accounts from another acceptance run; cleanup owns only this run's account. pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created }) const relabelReport = join(outputDir, 'relabel.json') @@ -156,7 +186,10 @@ try { PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId, OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport, OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport } - for (const script of ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']) { + const scripts = process.env.OVERSEAS_CLOSURE_EXTRA === 'true' ? ['relabel-closure-local-acceptance.mjs'] : process.env.OVERSEAS_RESUME_FULFILLMENT === 'true' + ? ['overseas-relabel-fulfillment-acceptance.mjs'] + : ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs'] + for (const script of scripts) { const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' }) if (result.error) throw result.error if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`) diff --git a/scripts/db/dev-lastleg-relabel-return-incremental.sql b/scripts/db/dev-lastleg-relabel-return-incremental.sql index fb1e62737..497d468c1 100644 --- a/scripts/db/dev-lastleg-relabel-return-incremental.sql +++ b/scripts/db/dev-lastleg-relabel-return-incremental.sql @@ -17,6 +17,33 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少基础表 ll_relabel_item,请先执行海外尾程基线'; END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order') THEN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人' AFTER delivery_address; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone') THEN + ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话' AFTER pickup_contact_name; + END IF; + END IF; + + CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) + ) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at') THEN + ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间' AFTER uploaded_at; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order') + AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash') THEN + ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空' AFTER recipient_info; + END IF; + -- 贴标申请:FBA目的仓、换标模式、SKU组合明细及原库存快照。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code') THEN ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)'; diff --git a/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql b/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql new file mode 100644 index 000000000..e094c5edc --- /dev/null +++ b/scripts/db/dev-warehouse-transfer-pallet-lifecycle-incremental.sql @@ -0,0 +1,25 @@ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_warehouse_transfer_pallet' + AND column_name = 'draft_idempotency_key' + ), + 'SELECT 1', + 'ALTER TABLE ll_warehouse_transfer_pallet + MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', + MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', + MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', + MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', + MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', + ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, + ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, + ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, + ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, + ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key' +)); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; +EXECUTE transfer_pallet_lifecycle_stmt; +DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index f705f966d..21ef5a52f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -630,9 +630,10 @@ WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', - tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + expected_loading_at DATETIME(3) NOT NULL COMMENT '预计装车时间', appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', @@ -641,8 +642,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( 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_fba_appointment_no (tenant_id, appointment_no), - KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at) + UNIQUE KEY uk_ll_fba_appointment_no (customer_id, appointment_no), + KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at) ) ENGINE=InnoDB COMMENT='FBA入仓预约单'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( @@ -1173,7 +1174,7 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码', - delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', + delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式', estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号', tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', @@ -3963,6 +3964,8 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''一件代发创建请求摘要,其他出库类型为空'' AFTER recipient_info')); +PREPARE ll_outbound_request_hash_stmt FROM @ddl; EXECUTE ll_outbound_request_hash_stmt; DEALLOCATE PREPARE ll_outbound_request_hash_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='lx_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN lx_code VARCHAR(128) NULL COMMENT ''一件代发领星入库单代码''')); PREPARE ll_inbound_lx_code_stmt FROM @ddl; EXECUTE ll_inbound_lx_code_stmt; DEALLOCATE PREPARE ll_inbound_lx_code_stmt; @@ -4201,14 +4204,16 @@ PREPARE return_order_lastleg_no_stmt FROM @ddl; EXECUTE return_order_lastleg_no_ USE baiyue_lastleg; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet ( id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码', - warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', + sequence_no INT NOT NULL COMMENT '托序号', warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码', warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数', length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', - status VARCHAR(32) NOT NULL COMMENT '打托状态', completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要', generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键', bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no), UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id), KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status) ) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key')); +PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt; CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID', transfer_pallet_id BIGINT NOT NULL COMMENT '转运托盘事实ID', transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', package_count INT NOT NULL COMMENT '本托该SKU实收箱数', @@ -4220,3 +4225,80 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding ( fba_carton_mark VARCHAR(255) NULL COMMENT '新FBA箱唛', destination_warehouse_code VARCHAR(128) NULL COMMENT 'FBA目的仓', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_relabel_mark(transfer_order_id,customer_carton_mark), KEY idx_ll_transfer_relabel_order(customer_id,transfer_order_id) ) ENGINE=InnoDB COMMENT='仓储转运换标预绑定明细'; + +-- 2026-09-16 / 039 仓储转运提货联系人独立于换标联系人。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address')); +PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name')); +PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt; + +-- 2026-09-16 / 040 仓储转运PDA测量现场照片引用。 +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo ( + id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID', + file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照', + size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id), + KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at) +) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at')); +PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt; + +-- 2026-09-16 / 041 仓储转运PDA实收箱数及历史完成状态回填。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='received_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN received_package_count INT NULL COMMENT ''PDA实际入库箱数'' AFTER package_count')); +PREPARE transfer_received_package_count_stmt FROM @ddl; EXECUTE transfer_received_package_count_stmt; DEALLOCATE PREPARE transfer_received_package_count_stmt; +UPDATE ll_order o +JOIN ll_warehouse_transfer_order t + ON t.customer_id=o.customer_id AND t.lastleg_order_id=o.id AND t.transfer_no=o.source_order_no +JOIN ( + SELECT customer_id,transfer_order_id,SUM(carton_count) AS received_package_count + FROM ll_warehouse_transfer_pallet + WHERE status='COMPLETED' + GROUP BY customer_id,transfer_order_id +) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id +SET o.received_package_count=p.received_package_count, + o.current_status='INBOUND_COMPLETED', + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='WAREHOUSE_TRANSFER' + AND t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED'); +UPDATE ll_inbound_order i +JOIN ll_warehouse_transfer_order t + ON t.customer_id=i.customer_id AND t.inbound_order_id=i.id AND t.inbound_code=t.transfer_no +JOIN ( + SELECT customer_id,transfer_order_id,COUNT(*) AS pallet_count,SUM(carton_count) AS package_count, + SUM(gross_weight_kg) AS gross_weight_kg,SUM(volume_cbm) AS volume_cbm + FROM ll_warehouse_transfer_pallet + WHERE status='COMPLETED' + GROUP BY customer_id,transfer_order_id +) p ON p.customer_id=t.customer_id AND p.transfer_order_id=t.id +SET i.status='RECEIVED',i.package_count=p.package_count,i.pallet_count=p.pallet_count, + i.gross_weight_kg=p.gross_weight_kg,i.volume_cbm=p.volume_cbm,i.updated_at=CURRENT_TIMESTAMP(3) +WHERE t.status IN ('WAIT_CUSTOMER_CONFIRMATION','WAIT_FBA_DESTINATION','WAIT_RELABEL','READY_FOR_DISPATCH','ALLOCATED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED'); + +-- 2026-09-16 / 042 仓储转运直接出库的FBA/私人仓目的地快照。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name')); +PREPARE transfer_outbound_destination_stmt FROM @ddl; EXECUTE transfer_outbound_destination_stmt; DEALLOCATE PREPARE transfer_outbound_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空''')); +PREPARE transfer_outbound_nullable_stmt FROM @ddl; EXECUTE transfer_outbound_nullable_stmt; DEALLOCATE PREPARE transfer_outbound_nullable_stmt; + +-- 2026-09-17 / 043 仓储转运目的地绑定及私人仓邮编快照。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希''')); +PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address')); +PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_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='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照''')); +PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_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='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); +PREPARE dispatch_destination_stmt FROM @ddl; EXECUTE dispatch_destination_stmt; DEALLOCATE PREPARE dispatch_destination_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address')); +PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address')); +PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt; diff --git a/scripts/db/relabel-file-scope-incremental.sql b/scripts/db/relabel-file-scope-incremental.sql index e458716eb..cad82579b 100644 --- a/scripts/db/relabel-file-scope-incremental.sql +++ b/scripts/db/relabel-file-scope-incremental.sql @@ -1,5 +1,8 @@ USE baiyue_lastleg; +-- 当前文件服务数据库;不删除或重建文件元数据。 +ALTER TABLE baiyue_file.file_object MODIFY COLUMN business_type VARCHAR(64) NULL COMMENT '关联业务类型'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='carton_total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_id VARCHAR(128) NULL COMMENT ''箱唛总文件ID'' AFTER total_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_application' AND column_name='carton_total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN carton_total_file_name VARCHAR(255) NULL COMMENT ''箱唛总文件名'' AFTER carton_total_file_id')); diff --git a/scripts/db/relabel-wait-status-incremental.sql b/scripts/db/relabel-wait-status-incremental.sql new file mode 100644 index 000000000..d14947c6e --- /dev/null +++ b/scripts/db/relabel-wait-status-incremental.sql @@ -0,0 +1,16 @@ +-- 存量换标尾程主单状态修正:不创建入库单,不增减库存;可重复执行。 +USE baiyue_lastleg; +START TRANSACTION; +UPDATE ll_order o +JOIN ll_relabel_application a ON a.relabel_no=o.relabel_order_no AND a.customer_id=o.customer_id +SET o.current_status=CASE + WHEN a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') THEN 'WAIT_RELABEL' + WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' THEN 'WAIT_OUTBOUND' + WHEN a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED') THEN 'WAIT_OUTBOUND' + ELSE o.current_status END, + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMENT' + AND o.inbound_order_no IS NULL + AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') + OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED'))); +COMMIT; diff --git a/scripts/db/seed-local-relabel-ready-orders.sql b/scripts/db/seed-local-relabel-ready-orders.sql new file mode 100644 index 000000000..095f1d924 --- /dev/null +++ b/scripts/db/seed-local-relabel-ready-orders.sql @@ -0,0 +1,114 @@ +-- LOCAL ONLY: explicit test fixtures, NOT evidence of real PDA receiving. +-- Caller must set @fixture_customer_id, @fixture_warehouse_id, @fixture_location_id, +-- @fixture_destination_id, @fixture_base and @fixture_batch in the same connection. +-- Insert-only: duplicate IDs/numbers fail; never restore or overwrite consumed stock. +SET NAMES utf8mb4; +USE baiyue_lastleg; +CREATE TEMPORARY TABLE fixture_guard (ok INT NOT NULL CHECK (ok=1)); +INSERT INTO fixture_guard SELECT IF(@fixture_customer_id>0 AND @fixture_base>0 + AND @fixture_batch REGEXP '^[A-Z0-9-]{1,16}$',1,0); +SET @fixture_customer_name=(SELECT tenant_name FROM baiyue_iam.iam_tenant + WHERE id=@fixture_customer_id AND deleted=0 AND status='ACTIVE'); +SET @fixture_warehouse_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse + WHERE id=@fixture_warehouse_id AND warehouse_type='OVERSEAS' AND status='ACTIVE' AND deleted=0); +SET @fixture_warehouse_name=(SELECT warehouse_name FROM baiyue_warehouse.wh_warehouse WHERE id=@fixture_warehouse_id); +SET @fixture_location_code=(SELECT location_code FROM baiyue_lastlag_warehouse.wh_location + WHERE id=@fixture_location_id AND warehouse_id=@fixture_warehouse_id AND location_type='VIRTUAL' AND status='ACTIVE'); +SET @fixture_destination_code=(SELECT warehouse_code FROM baiyue_warehouse.wh_warehouse + WHERE id=@fixture_destination_id AND warehouse_type='FBA' AND status='ACTIVE' AND deleted=0); +INSERT INTO fixture_guard SELECT IF(@fixture_customer_name IS NOT NULL AND @fixture_warehouse_code IS NOT NULL + AND @fixture_location_code IS NOT NULL AND @fixture_destination_code IS NOT NULL,1,0); +CREATE TEMPORARY TABLE fixture_orders (seq INT PRIMARY KEY, boxes INT, order_id BIGINT, plan_id BIGINT, + balance_id BIGINT, inbound_id BIGINT, order_no VARCHAR(32), plan_no VARCHAR(32), inbound_code VARCHAR(128), sku VARCHAR(128)); +INSERT INTO fixture_orders +SELECT n,6+n*6,@fixture_base+n,@fixture_base+100+n,@fixture_base+200+n,@fixture_base+300+n, + CONCAT('TL-',@fixture_batch,'-',n),CONCAT('IP-',@fixture_batch,'-',n), + CONCAT('IN-',@fixture_batch,'-',n),CONCAT('SKU-',@fixture_batch,'-',n) +FROM (SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3) numbers; +CREATE TEMPORARY TABLE fixture_pallets (seq INT, part INT, pallet_id BIGINT, position_id BIGINT, + pallet_code VARCHAR(64), boxes INT, PRIMARY KEY(seq,part)); +INSERT INTO fixture_pallets SELECT f.seq,p.n,@fixture_base+400+f.seq*10+p.n, + @fixture_base+500+f.seq*10+p.n,CONCAT('PAL-',@fixture_batch,'-',f.seq,'-',p.n),f.boxes/2 +FROM fixture_orders f CROSS JOIN (SELECT 1 n UNION ALL SELECT 2) p; +CREATE TEMPORARY TABLE fixture_numbers (n INT PRIMARY KEY); +INSERT INTO fixture_numbers SELECT a.n+b.n*10+1 FROM + (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a + CROSS JOIN (SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2) b; +START TRANSACTION; +INSERT INTO baiyue_lastlag_warehouse.wh_pallet + (id,customer_id,warehouse_id,location_id,pallet_code,pallet_type,capacity_cbm,status,remark,fba_warehouse_code,version) +SELECT pallet_id,0,@fixture_warehouse_id,@fixture_location_id,pallet_code,'STANDARD',2,'OCCUPIED', + 'LOCAL_TEST_FIXTURE: relabel source inventory; not real PDA acceptance',@fixture_destination_code,0 FROM fixture_pallets; +INSERT INTO ll_inbound_order + (id,customer_id,source_region,source_inbound_id,source_order_id,source_order_no,inbound_code, + destination_warehouse_id,destination_warehouse_code,destination_type,package_count,product_quantity,pallet_count, + source_event_id,source_loading_plan_id,source_loading_plan_version,handover_version,payload_hash,status) +SELECT inbound_id,@fixture_customer_id,'FIRST_LEG',CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,inbound_code, + @fixture_destination_id,@fixture_destination_code,'FBA',boxes,boxes*10,2, + CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),1,1,SHA2(order_no,256),'RECEIVED' FROM fixture_orders; +INSERT INTO ll_order + (id,order_no,customer_id,order_type,lx_code,destination_type,inbound_code,source_region,source_inbound_id, + source_order_id,source_order_no,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + fba_warehouse_code,container_no,container_status,pallet_count,package_count,product_quantity, + inbound_order_no,current_status,source_event_id,source_loading_plan_id,source_payload_hash) +SELECT order_id,order_no,@fixture_customer_id,'FIRST_LEG',CONCAT('LX-',order_no),'FBA',inbound_code,'FIRST_LEG', + CONCAT('FIXTURE-',inbound_code),CAST(order_id AS CHAR),order_no,@fixture_customer_name, + @fixture_warehouse_id,@fixture_warehouse_code,@fixture_warehouse_name,@fixture_destination_code, + CONCAT('FIXTURE-',@fixture_batch),'LOCAL_TEST_FIXTURE',2,boxes,boxes*10,plan_no,'INBOUND_COMPLETED', + CONCAT('FIXTURE-',order_no),CONCAT('FIXTURE-LOAD-',order_no),SHA2(order_no,256) FROM fixture_orders; +INSERT INTO ll_order_detail + (id,customer_id,order_id,source_inbound_detail_id,sku_code,product_name_zh,package_count,product_quantity,pallet_count, + destination_warehouse_id,destination_warehouse_code,shipping_mark) +SELECT @fixture_base+10000+f.seq*100+n.n,@fixture_customer_id,f.order_id, + CONCAT('FIXTURE-',f.inbound_code,'-',n.n),f.sku,CONCAT('贴标测试商品-',f.seq),1,10,0, + @fixture_destination_id,@fixture_destination_code,CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(n.n,3,'0')) +FROM fixture_orders f JOIN fixture_numbers n ON n.n<=f.boxes; +INSERT INTO ll_inbound_plan + (id,plan_no,customer_id,source_order_id,container_no,overseas_warehouse_id,planned_arrival_date, + pallet_type,assigned_flag,creation_mode,plan_remark,status,line_count,pallet_count,total_volume_cbm,version) +SELECT plan_id,plan_no,@fixture_customer_id,order_id,CONCAT('FIXTURE-',@fixture_batch),@fixture_warehouse_id, + CURRENT_DATE,'STANDARD',1,'MANUAL','LOCAL_TEST_FIXTURE: 已入库测试准备,非真实PDA签收验收','COMPLETED',1,2,2,0 FROM fixture_orders; +INSERT INTO ll_inbound_plan_line (id,customer_id,plan_id,order_id,inbound_code,required_package_count, + planned_pallet_count,destination_type,destination_warehouse_code,fba_warehouse_code) +SELECT @fixture_base+700+seq,@fixture_customer_id,plan_id,order_id,inbound_code,boxes,2,'FBA', + @fixture_destination_code,@fixture_destination_code FROM fixture_orders; +INSERT INTO ll_inbound_plan_pallet + (id,customer_id,plan_id,pallet_no,pallet_type,destination_warehouse_code,capacity_cbm,allocated_volume_cbm, + sequence_no,warehouse_pallet_id,assignment_status,actual_volume_cbm,actual_package_count) +SELECT p.pallet_id+1000,@fixture_customer_id,f.plan_id,p.pallet_code,'STANDARD',@fixture_destination_code, + 2,1,p.part,p.pallet_id,'ASSIGNED',1,p.boxes FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inventory_balance + (id,customer_id,customer_name,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name, + destination_warehouse_code,inbound_order_id,inbound_order_no,inbound_code,sku_code,product_name_zh, + available_packages,available_quantity,version) +SELECT balance_id,@fixture_customer_id,@fixture_customer_name,@fixture_warehouse_id,@fixture_warehouse_code, + @fixture_warehouse_name,@fixture_destination_code,order_id,plan_no,inbound_code,sku,CONCAT('贴标测试商品-',seq),boxes,boxes*10,0 FROM fixture_orders; +INSERT INTO ll_inventory_position + (id,customer_id,balance_id,overseas_warehouse_id,destination_warehouse_code,sku_code,pallet_id,pallet_no,batch_no, + warehouse_location_id,warehouse_location_code,available_packages,product_quantity,status,version) +SELECT p.position_id,@fixture_customer_id,f.balance_id,@fixture_warehouse_id,@fixture_destination_code,f.sku,p.pallet_id, + p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_id,@fixture_location_code,p.boxes,p.boxes*10,'OCCUPIED',0 +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inbound_plan_pallet_allocation + (id,customer_id,plan_id,line_id,pallet_id,allocated_volume_cbm,planned_package_count, + counted_package_count,picked_package_count,actual_volume_cbm,status,version) +SELECT p.pallet_id+3000,@fixture_customer_id,f.plan_id,@fixture_base+700+f.seq,p.pallet_id+1000, + 1,p.boxes,p.boxes,p.boxes,1,'PICKED',0 +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO ll_inventory_ledger + (id,customer_id,customer_name,balance_id,position_id,overseas_warehouse_id,destination_warehouse_code, + inbound_order_id,sku_code,pallet_id,pallet_no,batch_no,warehouse_location_code,business_type,business_order_no, + inbound_code,inbound_order_no,idempotency_key,status_from,status_to,before_packages,delta_packages,after_packages,remark,occurred_at) +SELECT p.position_id+2000,@fixture_customer_id,@fixture_customer_name,f.balance_id,p.position_id,@fixture_warehouse_id, + @fixture_destination_code,f.order_id,f.sku,p.pallet_id,p.pallet_code,CONCAT('FIXTURE-',@fixture_batch),@fixture_location_code, + 'LOCAL_TEST_FIXTURE',f.order_no,f.inbound_code,f.plan_no,CONCAT('LOCAL_TEST_FIXTURE:',p.pallet_code),'NONE','AVAILABLE', + 0,p.boxes,p.boxes,'Explicit local test fixture; not a real PDA stock-in event',NOW(3) +FROM fixture_pallets p JOIN fixture_orders f ON f.seq=p.seq; +INSERT INTO fixture_guard SELECT IF(COUNT(*)=6 AND SUM(p.available_packages)=54,1,0) +FROM ll_inventory_position p JOIN fixture_pallets f ON f.position_id=p.id; +COMMIT; +SELECT f.order_no,f.plan_no,f.inbound_code,f.sku,p.pallet_code,p.boxes, + CONCAT('CM-',@fixture_batch,'-',f.seq,'-001') first_carton_mark, + CONCAT('CM-',@fixture_batch,'-',f.seq,'-',LPAD(f.boxes,3,'0')) last_carton_mark +FROM fixture_orders f JOIN fixture_pallets p ON p.seq=f.seq ORDER BY f.seq,p.part; diff --git a/scripts/db/warehouse-transfer-outbound-incremental.sql b/scripts/db/warehouse-transfer-outbound-incremental.sql new file mode 100644 index 000000000..aff2665d3 --- /dev/null +++ b/scripts/db/warehouse-transfer-outbound-incremental.sql @@ -0,0 +1,326 @@ +-- 仓储转运审核、PDA测量打托、换标、目的地绑定、智能排车与派送字段。 +-- 目标数据库:baiyue_lastleg +-- 可重复执行:字段和索引独立检查,补充缺失结构并修正必要的可空性;不更新业务数据。 + +USE `baiyue_lastleg`; + +DROP PROCEDURE IF EXISTS `ensure_transfer_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_index`; +DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`; + +DELIMITER $$ + +CREATE PROCEDURE `ensure_transfer_column`( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_nullable_column`( + IN p_table_name VARCHAR(64), + IN p_column_name VARCHAR(64), + IN p_column_definition TEXT +) +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND column_name = p_column_name + AND is_nullable = 'NO' + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` MODIFY COLUMN `', REPLACE(p_column_name, '`', '``'), + '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_index`( + IN p_table_name VARCHAR(64), + IN p_index_name VARCHAR(64), + IN p_index_definition TEXT +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table_name + AND index_name = p_index_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', REPLACE(p_table_name, '`', '``'), + '` ADD ', p_index_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ + +CREATE PROCEDURE `ensure_transfer_pallet_unique_index`() +BEGIN + DECLARE v_duplicate_groups BIGINT DEFAULT 0; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ) THEN + SELECT COUNT(*) + INTO v_duplicate_groups + FROM ( + SELECT 1 + FROM `ll_inbound_plan_pallet` + GROUP BY `customer_id`, `pallet_no` + HAVING COUNT(*) > 1 + ) duplicate_groups; + + IF v_duplicate_groups = 0 THEN + ALTER TABLE `ll_inbound_plan_pallet` + ADD UNIQUE KEY `uk_ll_inbound_pallet_no` (`customer_id`,`pallet_no`); + END IF; + END IF; +END$$ + +DELIMITER ; + +-- 相关业务表统一补齐 SaaS 租户字段。默认值只用于兼容历史数据和单租户海外环境。 +CALL ensure_transfer_column('ll_delivery_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_dispatch_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_application', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_relabel_status_history', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_inventory_allocation', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_item', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_receipt', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_source_order', 'tenant_id', 'VARCHAR(128) NOT NULL DEFAULT ''BAIYUE'' COMMENT ''SaaS租户标识,当前海外租户固定为BAIYUE'''); + +-- 退货换标允许只更换箱唛,不强制生成新 SKU。 +CALL ensure_transfer_nullable_column('ll_relabel_item', 'new_sku_code', 'VARCHAR(128) NULL COMMENT ''换标后SKU编码'''); + +-- 主单审核、提货、发货、换标和客户确认事实。 +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_name', 'VARCHAR(128) NULL COMMENT ''提货联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'pickup_contact_phone', 'VARCHAR(64) NULL COMMENT ''提货联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'estimated_ship_at', 'DATETIME(3) NULL COMMENT ''预计发货时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_decision', 'VARCHAR(32) NULL COMMENT ''审核决定'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'review_remark', 'VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_by', 'VARCHAR(128) NULL COMMENT ''审核人ID'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'reviewed_at', 'DATETIME(3) NULL COMMENT ''审核时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'label_deadline', 'DATETIME(3) NULL COMMENT ''换标截止时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_name', 'VARCHAR(128) NULL COMMENT ''换标联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_contact_phone', 'VARCHAR(64) NULL COMMENT ''换标联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_charge_amount', 'DECIMAL(20,2) NULL COMMENT ''换标费用CNY'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_delivery_method', 'VARCHAR(32) NULL COMMENT ''换标后派送方式'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'relabel_completed_at', 'DATETIME(3) NULL COMMENT ''关联贴标完成时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_idempotency_key', 'VARCHAR(255) NULL COMMENT ''PDA打托幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'palletize_request_hash', 'CHAR(64) NULL COMMENT ''PDA打托请求哈希'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_idempotency_key', 'VARCHAR(255) NULL COMMENT ''客户测量确认幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'confirmation_request_hash', 'CHAR(64) NULL COMMENT ''客户测量确认请求哈希'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'customer_confirmed_at', 'DATETIME(3) NULL COMMENT ''客户确认测量时间'''); + +-- 仓储转运主单目的地。 +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_id', 'VARCHAR(128) NULL COMMENT ''目的仓ID快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''目的仓编码快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_warehouse_name', 'VARCHAR(255) NULL COMMENT ''目的仓名称快照'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_address', 'VARCHAR(1000) NULL COMMENT ''私人仓完整地址'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'private_destination_contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_idempotency_key', 'VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_order', 'destination_binding_request_hash', 'CHAR(64) NULL COMMENT ''目的地绑定请求哈希'''); + +-- 转仓尾程主单记录PDA实收箱数。 +CALL ensure_transfer_column('ll_order', 'received_package_count', 'INT NULL COMMENT ''PDA实际入库箱数'''); + +-- PDA逐托草稿、生成、扫码绑定和整单完成事实。 +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet` ( + `id` BIGINT NOT NULL COMMENT '打托事实ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `sequence_no` INT NOT NULL COMMENT '托序号', + `warehouse_pallet_id` BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', + `pallet_no` VARCHAR(128) NULL COMMENT '生成后的真实托盘码', + `warehouse_location_id` BIGINT NULL COMMENT '生成后的真实库位ID', + `warehouse_location_code` VARCHAR(128) NULL COMMENT '真实库位码', + `warehouse_resource_lock_id` BIGINT NULL COMMENT '生成后的仓储资源锁ID', + `carton_count` INT NOT NULL COMMENT '本托实收箱数', + `length_cm` DECIMAL(12,2) NOT NULL COMMENT '实测长cm', + `width_cm` DECIMAL(12,2) NOT NULL COMMENT '实测宽cm', + `height_cm` DECIMAL(12,2) NOT NULL COMMENT '实测高cm', + `gross_weight_kg` DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg', + `volume_cbm` DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', + `status` VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED', + `draft_idempotency_key` VARCHAR(255) NULL COMMENT '草稿保存幂等键', + `draft_request_hash` CHAR(64) NULL COMMENT '草稿请求摘要', + `generate_idempotency_key` VARCHAR(255) NULL COMMENT '新托盘生成幂等键', + `bind_idempotency_key` VARCHAR(255) NULL COMMENT '扫码绑定幂等键', + `bound_at` DATETIME(3) NULL COMMENT '扫码确认绑定时间', + `completed_at` DATETIME(3) NULL COMMENT '整单打托完成时间', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_seq` (`transfer_order_id`,`sequence_no`), + UNIQUE KEY `uk_ll_transfer_pallet_physical` (`customer_id`,`warehouse_pallet_id`), + KEY `idx_ll_transfer_pallet_order` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA实测打托事实'; + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_pallet_item` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `transfer_pallet_id` BIGINT NOT NULL COMMENT '转运托盘事实ID', + `transfer_item_id` BIGINT NOT NULL COMMENT '转运申报明细ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `package_count` INT NOT NULL COMMENT '本托该SKU实收箱数', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_item` (`transfer_pallet_id`,`transfer_item_id`), + KEY `idx_ll_transfer_pallet_item_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运每托SKU箱数回填'; + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_relabel_binding` ( + `id` BIGINT NOT NULL COMMENT '主键ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `operation_type` VARCHAR(32) NOT NULL COMMENT '换标类型', + `customer_carton_mark` VARCHAR(255) NOT NULL COMMENT '客户原箱唛', + `original_sku` VARCHAR(128) NOT NULL COMMENT '旧SKU', + `new_sku` VARCHAR(128) NULL COMMENT '新SKU', + `quantity` INT NOT NULL COMMENT '换标件数', + `fba_carton_mark` VARCHAR(255) NULL COMMENT '新FBA箱唛', + `destination_warehouse_code` VARCHAR(128) NULL COMMENT 'FBA目的仓', + `remark` VARCHAR(1000) NULL COMMENT '备注', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_relabel_mark` (`transfer_order_id`,`customer_carton_mark`), + KEY `idx_ll_transfer_relabel_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运换标预绑定明细'; + +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_pallet_id', 'BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'pallet_no', 'VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_location_id', 'BIGINT NULL COMMENT ''生成后的真实库位ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'warehouse_resource_lock_id', 'BIGINT NULL COMMENT ''生成后的仓储资源锁ID'''); +CALL ensure_transfer_nullable_column('ll_warehouse_transfer_pallet', 'completed_at', 'DATETIME(3) NULL COMMENT ''整单打托完成时间'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_idempotency_key', 'VARCHAR(255) NULL COMMENT ''草稿保存幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'draft_request_hash', 'CHAR(64) NULL COMMENT ''草稿请求摘要'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'generate_idempotency_key', 'VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bind_idempotency_key', 'VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'''); +CALL ensure_transfer_column('ll_warehouse_transfer_pallet', 'bound_at', 'DATETIME(3) NULL COMMENT ''扫码确认绑定时间'''); + +CREATE TABLE IF NOT EXISTS `ll_warehouse_transfer_measurement_photo` ( + `id` BIGINT NOT NULL COMMENT '测量照片引用ID', + `transfer_order_id` BIGINT NOT NULL COMMENT '仓储转运单ID', + `customer_id` VARCHAR(128) NOT NULL COMMENT '客户租户ID', + `file_id` BIGINT NOT NULL COMMENT '文件服务文件ID', + `file_name` VARCHAR(255) NULL COMMENT '文件名快照', + `content_type` VARCHAR(128) NULL COMMENT '文件媒体类型快照', + `size_bytes` BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', + `uploaded_by` VARCHAR(128) NULL COMMENT 'PDA上传操作人', + `uploaded_at` DATETIME(3) NOT NULL COMMENT '上传时间', + `submitted_at` DATETIME(3) NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`), + KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运PDA测量现场照片引用'; +CALL ensure_transfer_column('ll_warehouse_transfer_measurement_photo', 'submitted_at', 'DATETIME(3) NULL COMMENT ''随测量打托提交时间'''); +CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'uk_ll_transfer_measurement_photo_file', + 'UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`)'); +CALL ensure_transfer_index('ll_warehouse_transfer_measurement_photo', 'idx_ll_transfer_measurement_photo_order', + 'KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`)'); + +-- 入库计划行和托盘保存私人仓目的地快照。 +CALL ensure_transfer_column('ll_inbound_plan_line', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_inbound_plan_line', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); + +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_inbound_plan_pallet', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); +CALL ensure_transfer_index('ll_inbound_plan_line', 'idx_ll_inbound_line_tenant_plan', + 'KEY `idx_ll_inbound_line_tenant_plan` (`customer_id`,`plan_id`)'); +CALL ensure_transfer_index('ll_inbound_plan_pallet', 'idx_ll_inbound_pallet_tenant_plan', + 'KEY `idx_ll_inbound_pallet_tenant_plan` (`customer_id`,`plan_id`)'); +CALL ensure_transfer_pallet_unique_index(); + +-- 智能排车候选和排车明细快照。 +CALL ensure_transfer_column('ll_dispatch_item', 'destination_type', 'VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_dispatch_item', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人快照'''); +CALL ensure_transfer_column('ll_dispatch_item', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''); + +-- 派送单允许私人仓目的地,并保留地址、邮编及联系人快照。 +CALL ensure_transfer_column('ll_delivery_order', 'destination_type', 'VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'''); +CALL ensure_transfer_column('ll_delivery_order', 'delivery_address', 'VARCHAR(1000) NULL COMMENT ''私人仓派送地址'''); +CALL ensure_transfer_column('ll_delivery_order', 'postal_code', 'VARCHAR(32) NULL COMMENT ''私人仓邮编'''); +CALL ensure_transfer_column('ll_delivery_order', 'contact_name', 'VARCHAR(128) NULL COMMENT ''私人仓联系人'''); +CALL ensure_transfer_column('ll_delivery_order', 'contact_phone', 'VARCHAR(64) NULL COMMENT ''私人仓联系电话'''); + +-- 仓储转运直接出库历史兼容:这三列允许为空。 +CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_order_id', 'BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'''); +CALL ensure_transfer_nullable_column('ll_delivery_order', 'dispatch_no', 'VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'''); +CALL ensure_transfer_nullable_column('ll_delivery_order', 'destination_warehouse_code', 'VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空'''); + +DROP PROCEDURE IF EXISTS `ensure_transfer_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_nullable_column`; +DROP PROCEDURE IF EXISTS `ensure_transfer_index`; +DROP PROCEDURE IF EXISTS `ensure_transfer_pallet_unique_index`; + +SELECT DATABASE() AS target_database, + IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ), + 'WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY', + 'WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER' + ) AS migration_result, + IF( + EXISTS ( + SELECT 1 + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_plan_pallet' + AND index_name = 'uk_ll_inbound_pallet_no' + ), + 'READY', + 'BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO' + ) AS inbound_pallet_unique_index; diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index d2117ed5b..aa0912c22 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -22,8 +22,8 @@ function assert(value, message) { if (!value) throw new Error(message) } async function api(base, token, method, path, body) { const response = await fetch(`${base}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, - body: body === undefined ? undefined : JSON.stringify(body) }) + ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) }) const text = await response.text(); let value try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } if (!response.ok || String(value?.code) !== '200') { @@ -48,12 +48,32 @@ const platformToken = directPlatformToken || platformLogin.overseasAccessToken const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken const pdaToken = directPdaToken || pdaLogin.overseasAccessToken -await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', route: `${marker}-ROUTE`, - estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), -}) -const transfer = await api(overseas, merchantToken, 'POST', +let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +let alreadyDeparted = task.status === 'DEPARTED' + || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) +if (!alreadyDeparted && task.status === 'PENDING') { + await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', route: `${marker}-ROUTE`, + estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), + }) +} +const existingPage = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) +const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) +const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean)) +assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders') +const transfer = existingDeliveries.length > 0 ? { + outboundNo: [...existingOutboundNos][0], + deliveries: existingDeliveries.map((value) => ({ + deliveryNo: value.deliveryNo, deliveryStatus: value.status, + destinationWarehouseId: value.destinationWarehouseId, + destinationWarehouseCode: value.destinationWarehouseCode, + destinationWarehouseName: value.destinationWarehouseName, + palletCount: value.palletCount, packageCount: value.packageCount, + })), + replayed: true, +} : await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', @@ -62,29 +82,53 @@ const transfer = await api(overseas, merchantToken, 'POST', appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], }) -assert(transfer.outboundNo && transfer.deliveryNo, 'Transfer did not create outbound and delivery') +const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] +assert(transfer.outboundNo && transferDeliveries.length > 0, + 'Transfer did not create outbound and delivery') +assert(transferDeliveries.length === 1, + `Expected one destination delivery, got ${transferDeliveries.length}`) +const deliveryNo = transferDeliveries[0].deliveryNo -let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) assert(Array.isArray(task.items) && task.items.length === 2, 'Expected two PDA pallet items') -for (const [itemIndex, item] of task.items.entries()) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const [positionIndex, position] of item.positions.entries()) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (packages <= 0) continue - await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, - }) - remaining -= packages +if (!alreadyDeparted) { + if (task.items.every(item => Number(item.pickedPackages) === 0)) { + await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { + vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', + carrierName: '尾程验收车队', fbaAppointmentNo: `UAT-${marker.slice(-8)}`, + fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'CONFIRMED', + estimatedArrivalAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), + }) } - assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + for (const [itemIndex, item] of task.items.entries()) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const [positionIndex, position] of item.positions.entries()) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (packages <= 0) continue + await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, + }) + remaining -= packages + } + assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + } + task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) + alreadyDeparted = task.status === 'DEPARTED' + || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) } -const completed = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) -assert(completed.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') -const departed = await api(overseas, pdaToken, 'POST', +if (!alreadyDeparted && task.status !== 'COMPLETED') { + const pickingCompleted = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) + assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') + task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) +} +assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') +const departed = alreadyDeparted ? { + status: 'DEPARTED', + shippedPackages: task.items.reduce((total, item) => total + Number(item.pickedPackages), 0), +} : await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') @@ -92,24 +136,47 @@ const outbound = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') let delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) -const delivering = await api(overseas, platformToken, 'PUT', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/status?customerId=${customerId}`, { - status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +if (delivery.order.status === 'WAIT_DELIVERY') { + const delivering = await api(overseas, platformToken, 'PUT', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, { + status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, + }) + assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +} +if (delivery.order.status !== 'DELIVERED') { + assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`) + const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}` + const documents = await api(overseas, platformToken, 'GET', documentsPath) + const form = new FormData() + form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }), + `${marker}-${deliveryNo}-receipt.txt`) + const receipt = await api(overseas, platformToken, 'POST', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form) + const existingBindings = (documents.categories || []).map((category) => ({ + category: category.category, + fileIds: (category.files || []).map((file) => id(file.fileId)), + })).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0) + await api(overseas, platformToken, 'PUT', documentsPath, { + expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'), + bolNo: documents.bolNo || null, + documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }], }) -assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') -delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}?customerId=${customerId}`) -const delivered = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/delivery-orders/${transfer.deliveryNo}/complete?customerId=${customerId}`, { - status: 'DELIVERED', expectedVersion: Number(delivery.order.version), remark: marker, - }) -assert(delivered.deliveryStatus === 'DELIVERED', 'Delivery did not reach DELIVERED') + delivery = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) +} +assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery') +const visits = await api(overseas, platformToken, 'GET', + `/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`) +const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo) +assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, - outboundStatus: outbound.outbound.status, deliveryNo: transfer.deliveryNo, - deliveryStatus: delivered.deliveryStatus, visitId: id(delivered.visitId), shippedPackages: departed.shippedPackages, + outboundStatus: outbound.outbound.status, deliveryNo, + deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departed.shippedPackages, passed: true, finishedAt: new Date().toISOString() } await writeFile(output, JSON.stringify(report, null, 2), 'utf8') console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs index f6b5f1879..329754dc4 100644 --- a/scripts/overseas-relabel-real-acceptance.mjs +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -13,6 +13,8 @@ const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` +const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') +if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount') for (const [name, value] of Object.entries({ customerId, warehouseId })) { if (!value) throw new Error(`Missing environment variable: ${name}`) @@ -21,7 +23,7 @@ if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Erro if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials') if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token') -const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', scenarios: [], passed: false } +const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false } function key(value) { return `${marker}-${value}`.slice(0, 200) } function id(value) { return value == null ? '' : String(value) } async function api(base, token, method, path, body, headers = {}) { @@ -70,7 +72,7 @@ async function resolvePallet(palletCode) { return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) } -async function createAndApprove(name, cartonCount) { +async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { const suffix = `${name}-${Date.now()}` const items = Array.from({ length: cartonCount }, (_, index) => ({ sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), @@ -78,49 +80,68 @@ async function createAndApprove(name, cartonCount) { productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, - fbaCartonMark: `FBA-${suffix}-${index + 1}`, destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, - operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: source.skuCode, + fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, + destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType, skuItems: [{ skuCode: source.skuCode, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], })) const created = await api(overseas, platformToken, 'POST', `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: '尾程验收', contactPhone: '13800000006', chargeAmount: 0.01, - remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), deliveryMethod: 'TRUCK', + contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), + deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', printShippingLabel: false, items, }) const relabelNo = created.application.relabelNo - await poll(() => api(overseas, platformToken, 'GET', + if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), (detail) => detail.application.walletStatus === 'FROZEN') let detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) for (const [index, item] of detail.items.entries()) { + if (operationType === 'CARTON_AND_SKU') { + detail = await api(overseas, pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { + cartonMark: item.cartonMark, expectedVersion: Number(detail.version), + idempotencyKey: key(`${name}-CARTON-${index}`), + }) + const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) + assert(Number(stageItem?.cartonProcessedPackages) === 1 + && Number(stageItem?.skuProcessedPackages) === 0, + `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + } detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { - itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark, + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, + skuVerifications: (item.skuItems || []).map((sku) => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), }) } + const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore } + if (prepare) await prepare(scenario) const form = new FormData() - form.append('file', new Blob([Buffer.from('89504e470d0a1a0a', 'hex')], { type: 'image/png' }), `${marker}.png`) + // Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work. + form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`) const photo = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-COMPLETE`), remark: marker, }) - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/review`, { - decision: 'APPROVE', expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-APPROVE`), remark: marker, - }) assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) const confirmed = await api(overseas, merchantToken, 'POST', `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { idempotencyKey: key(`${name}-CONFIRM`), remark: marker, }) - assert(confirmed.application.status === 'READY_FOR_OUTBOUND', `${relabelNo} did not reach READY_FOR_OUTBOUND`) - return { name, relabelNo, items: confirmed.items, sourceBefore: Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) } + const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' + assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) + assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), + `${relabelNo} did not preserve the destination warehouse`) + return { ...scenario, items: confirmed.items } } async function printPallet(relabelNo, suffix) { @@ -141,26 +162,38 @@ async function moveCarton(scenario, item, target, index) { return result } -const fresh = await createAndApprove('NEW-PALLET', 1) -const sharedTarget = await printPallet(fresh.relabelNo, 'SHARED') -const printReplay = await printPallet(fresh.relabelNo, 'SHARED') -assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') -await moveCarton(fresh, fresh.items[0], sharedTarget, 0) -fresh.targets = [sharedTarget] +const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') +skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) +assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') +skuOnly.targetPallets = [] +skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode +delete skuOnly.items + +let sharedTarget +const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { + sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') + const printReplay = await printPallet(scenario.relabelNo, 'SHARED') + assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] +}) -const merged = await createAndApprove('MERGE-EXISTING', 1) const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -await moveCarton(merged, merged.items[0], sharedTarget, 0) +const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] +}) const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') merged.targets = [sharedTarget] -const split = await createAndApprove('SPLIT-TWO-TARGETS', 2) -const splitA = await printPallet(split.relabelNo, 'SPLIT-A') -const splitB = await printPallet(split.relabelNo, 'SPLIT-B') -await moveCarton(split, split.items[0], splitA, 0) -await moveCarton(split, split.items[1], splitB, 1) -split.targets = [splitA, splitB] +const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { + const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') + const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') + await moveCarton(scenario, scenario.items[0], splitA, 0) + await moveCarton(scenario, scenario.items[1], splitB, 1) + scenario.targets = [splitA, splitB] +}) for (const scenario of [fresh, merged, split]) { const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) @@ -191,7 +224,7 @@ const ledgerRows = ledger.records.filter((row) => String(row.remark || '').inclu assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') -report.scenarios = [fresh, merged, split] +report.scenarios = [skuOnly, fresh, merged, split] report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } report.passed = true report.finishedAt = new Date().toISOString() diff --git a/scripts/relabel-closure-local-acceptance.mjs b/scripts/relabel-closure-local-acceptance.mjs new file mode 100644 index 000000000..b20454678 --- /dev/null +++ b/scripts/relabel-closure-local-acceptance.mjs @@ -0,0 +1,116 @@ +import { mkdir, writeFile, readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +const base = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' +if (!['localhost', '127.0.0.1'].includes(new URL(base).hostname)) throw new Error('Local acceptance only') +const merchant = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN, pda = process.env.PDA_OVERSEAS_ACCESS_TOKEN, admin = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN +const warehouse = process.env.OVERSEAS_TEST_WAREHOUSE_ID +if (!merchant || !pda || !admin || !warehouse) throw new Error('Explicit session and warehouse required') +const marker = `CLOSE-${Date.now()}` +const report = { marker, passed: false, scope: 'REAL_HTTP_SYNTHETIC_WAREHOUSE_EVIDENCE_NO_FINANCE', scenarios: [] } +const key = () => `${marker}-${crypto.randomUUID()}` +const root = '/api/merchant/v1/last-leg/relabel-applications' +const work = '/api/pda/v1/last-leg/relabel-tasks' +const pallets = '/api/pda/v1/last-leg/pallet-operations' +const assert = (condition, message) => { if (!condition) throw new Error(message) } +async function raw(token, method, path, body) { + const response = await fetch(base + path, { method, headers: { Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined }) + const result = await response.json() + return { ...result, http: response.status } +} +async function api(token, method, path, body) { + const result = await raw(token, method, path, body) + assert(result.http === 200 && String(result.code) === '200', `${path}: ${result.code} ${result.message}`) + return result.data +} +async function reject(token, path, body, message) { + const result = await raw(token, 'POST', path, body) + assert(String(result.code) !== '200' && message.test(result.message || ''), `Expected rejection at ${path}, got ${result.code} ${result.message}`) +} +const resolve = code => api(pda, 'POST', `${pallets}/pallets/resolve`, { code }) +async function move(no, mark, from, to) { + const source = await resolve(from), target = await resolve(to) + const request = { relabelNo: no, source: { palletCode: from, expectedVersion: source.palletVersion }, target: { palletCode: to, expectedVersion: target.palletVersion }, cartonMarks: [mark], reason: marker, idempotencyKey: key() } + const result = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request) + const replay = await api(pda, 'POST', `${pallets}/relabel-disassemble`, request) + assert(replay.replayed === true, 'Move replay was not idempotent') + const after = await resolve(to) + assert(Number(after.totalPackages) === Number(target.totalPackages) + 1 && after.status === 'OCCUPIED', 'Target stock/status did not update exactly once') + assert(Number((await resolve(from)).totalPackages) === Number(source.totalPackages) - 1, 'Source stock was not reduced once') + return result +} +try { + for (const deliveryMethod of ['TRUCK_PICKUP', 'EXPRESS', 'EXPRESS_PICKUP']) { + const candidates = await api(merchant, 'GET', `${root}/inventory-candidates?operationWarehouseId=${warehouse}`) + const candidate = candidates.find(value => value.destinationFbaWarehouseCode && value.positions?.some(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND')) + assert(candidate, 'No available inventory for another scenario') + const position = candidate.positions.find(pos => Number(pos.availablePackages) > 0 && pos.palletNo !== 'UNBOUND') + const mark = `${marker}-${deliveryMethod}` + const request = { customerOrderNo: mark, operationWarehouseId: warehouse, labelDeadline: new Date(Date.now() + 86400000).toISOString().slice(0, 19), contactName: '流程验收', contactPhone: '13800000000', deliveryMethod, printShippingLabel: false, idempotencyKey: key(), items: [{ sourceInventoryBalanceId: candidate.inventoryBalanceId, sourceInventoryPositionId: position.positionId, sourceInboundOrderId: candidate.inboundOrderId, skuCode: candidate.skuCode || 'TEST', productNameZh: '验收测试货物', totalPackageCount: 1, notifiedPackageCount: 1, cartonMark: mark, fbaCartonMark: `FBA-${mark}`, destinationFbaWarehouseCode: candidate.destinationFbaWarehouseCode, operationType: 'CARTON_LABEL', skuItems: [] }] } + request.items[0].shipmentNo = `SHIP-${mark}` + const created = await api(merchant, 'POST', root, request) + const no = created.application.relabelNo + const scenario = { deliveryMethod, relabelNo: no, originalPallet: position.palletNo, checks: [] } + report.scenarios.push(scenario) + await reject(merchant, root, { ...request, idempotencyKey: key() }, /已用于换标单/) + scenario.checks.push('DUPLICATE_APPLICATION_REJECTED') + // Read an actual Print-generated test document from File, then upload it through the merchant API. + const pdfReport = JSON.parse(await readFile('reports/relabel-closure-20260916/pdf.json', 'utf8')) + const doc = pdfReport.files[0] + const pdfResponse = await fetch(`http://127.0.0.1:9206/internal/file/v1/business-files/${process.env.OVERSEAS_TEST_CUSTOMER_ID}/${doc.category}/${pdfReport.businessId}/${doc.fileId}/content`, { headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN } }) + const pdf = Buffer.from(await pdfResponse.arrayBuffer()) + assert(pdfResponse.ok && pdf.subarray(0, 5).toString() === '%PDF-', 'Test PDF not available') + for (const scope of ['CARTON_TOTAL', 'SKU_TOTAL', 'ITEM']) { + const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), `${marker}.pdf`) + await api(merchant, 'POST', `${root}/${no}/files?scope=${scope}${scope === 'ITEM' ? `&itemId=${created.items[0].id}` : ''}`, form) + } + let task = await api(pda, 'POST', `${work}/${no}/claim`, { idempotencyKey: key() }) + const documents = await api(pda, 'GET', `${work}/${no}/documents`) + assert(documents.length >= 3, 'PDA is missing uploaded label documents') + for (const file of documents) { + const response = await fetch(`${base}${work}/${no}/documents/${file.fileId}/download?scope=${file.scope}${file.itemId ? `&itemId=${file.itemId}` : ''}`, { headers: { Authorization: `Bearer ${pda}` } }) + const downloaded = Buffer.from(await response.arrayBuffer()) + assert(response.ok && createHash('sha256').update(downloaded).digest('hex') === createHash('sha256').update(pdf).digest('hex'), 'PDA document mismatch') + } + scenario.checks.push('MERCHANT_UPLOAD_PDA_DOWNLOAD_THREE_SCOPES') + task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() }) + const photoForm = new FormData(); photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic.png') + const photo = await api(pda, 'POST', `${work}/${no}/photos/files`, photoForm) + const complete = () => ({ photoFileIds: [photo.fileId], expectedVersion: task.version, remark: marker, idempotencyKey: key() }) + await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/) + scenario.checks.push('UNBOUND_COMPLETION_REJECTED') + const first = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() }) + await move(no, mark, position.palletNo, first.palletCode) + const second = await api(pda, 'POST', `${pallets}/relabel-pallet-label`, { relabelNo: no, idempotencyKey: key() }) + await move(no, mark, first.palletCode, second.palletCode) + scenario.targetPallet = second.palletCode + scenario.checks.push('REBIND_STOCK_CONSERVED_AND_REPLAY_SAFE') + task = await api(pda, 'POST', `${work}/${no}/complete`, complete()) + if (deliveryMethod === 'TRUCK_PICKUP') { + task = await api(admin, 'POST', `/api/platform/v1/last-leg/relabel-applications/${no}/rework`, { decision: 'REWORK', expectedVersion: task.version, idempotencyKey: key(), remark: '验收退回:请核对并重新贴标' }) + assert(task.reviewRemark?.includes('验收退回'), 'Rework remark missing on PDA') + task = await api(pda, 'POST', `${work}/${no}/cartons/scan`, { cartonMark: mark, expectedVersion: task.version, idempotencyKey: key() }) + await reject(pda, `${work}/${no}/complete`, complete(), /托盘绑定/) + await move(no, mark, second.palletCode, first.palletCode) + scenario.targetPallet = first.palletCode + task = await api(pda, 'POST', `${work}/${no}/complete`, complete()) + scenario.checks.push('REWORK_RESCAN_REBIND_AND_RESUBMIT') + } + const confirmed = await api(merchant, 'POST', `${root}/${no}/confirm-completion`, { idempotencyKey: key(), remark: marker }) + scenario.outboundNo = confirmed.application.outboundOrderNo + assert(scenario.outboundNo, 'Customer confirmation did not create direct outbound') + await reject(pda, `${pallets}/relabel-disassemble`, { relabelNo: no, source: { palletCode: scenario.targetPallet, expectedVersion: 0 }, target: { palletCode: position.palletNo, expectedVersion: 0 }, cartonMarks: [mark], idempotencyKey: key() }, /排车或出库/) + const resolved = await api(pda, 'GET', `/api/pda/v1/last-leg/outbound-tasks/resolve/${scenario.outboundNo}`) + const picked = await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/pallets/${scenario.targetPallet}/pick`, { idempotencyKey: key() }) + await api(pda, 'POST', `/api/pda/v1/last-leg/outbound-tasks/${resolved.taskNo}/complete`, { idempotencyKey: key() }) + const outbound = await api(admin, 'GET', `/api/platform/v1/last-leg/outbound-orders/${scenario.outboundNo}?page=1&size=20`) + assert(outbound.outbound.status === 'OUTBOUNDED', 'Direct outbound not completed') + scenario.outboundStatus = outbound.outbound.status + scenario.checks.push('DIRECT_OUTBOUND_COMPLETED', 'OUTBOUND_REBIND_REJECTED') + } + report.passed = true +} catch (error) { report.error = error.message; process.exitCode = 1 } +finally { + await mkdir('reports/relabel-closure-20260916', { recursive: true }) + await writeFile('reports/relabel-closure-20260916/extra.json', JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) +} diff --git a/scripts/relabel-pdf-local-acceptance.mjs b/scripts/relabel-pdf-local-acceptance.mjs new file mode 100644 index 000000000..37031a4ab --- /dev/null +++ b/scripts/relabel-pdf-local-acceptance.mjs @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' + +const token = process.env.INTERNAL_SERVICE_TOKEN +if (!token) throw new Error('INTERNAL_SERVICE_TOKEN is required') +const printBase = process.env.PRINT_SERVICE_URL || 'http://127.0.0.1:9207' +const fileBase = process.env.FILE_SERVICE_URL || 'http://127.0.0.1:9206' +for (const url of [printBase, fileBase]) if (!['localhost', '127.0.0.1'].includes(new URL(url).hostname)) throw new Error('Local services only') +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +if (!customerId) throw new Error('OVERSEAS_TEST_CUSTOMER_ID is required') +const businessId = String(Date.now()) +const headers = { 'X-Internal-Service-Token': token, 'X-Operator-Id': '1' } +const report = { scope: 'LOCAL_PRINT_AND_FILE_HTTP_ONLY', businessId, passed: false, files: [] } +const ensure = (ok, message) => { if (!ok) throw new Error(message) } +try { + const rendered = await fetch(`${printBase}/internal/print/v1/pdfs/inbound-marks`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'relabel-acceptance.pdf', labels: [{ topCode: 'RELABEL-ACCEPTANCE', warehouseText: 'ONT8', cartonText: 'CTNS:1-1', routeText: '验收测试标签', qrContent: `ACCEPTANCE-${businessId}` }] }) }) + const pdf = Buffer.from(await rendered.arrayBuffer()) + ensure(rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-', `Print failed: HTTP ${rendered.status}`) + report.pdfBytes = pdf.length + const hash = value => createHash('sha256').update(value).digest('hex') + for (const category of ['OVERSEAS_RELABEL_CARTON_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_SKU_TOTAL_DOCUMENT', 'OVERSEAS_RELABEL_ITEM_DOCUMENT']) { + const form = new FormData(); form.append('file', new Blob([pdf], { type: 'application/pdf' }), 'relabel-acceptance.pdf') + const path = `/internal/file/v1/business-files/${customerId}/${category}/${businessId}` + const upload = await fetch(fileBase + path, { method: 'POST', headers, body: form }) + const body = await upload.json() + ensure(upload.ok && String(body.code) === '200', `${category}: ${body.code} ${body.message}`) + const fileId = body.data.fileId + const downloaded = await fetch(`${fileBase}${path}/${fileId}/content`, { headers }) + const bytes = Buffer.from(await downloaded.arrayBuffer()) + ensure(downloaded.ok && hash(bytes) === hash(pdf), 'Downloaded PDF does not match uploaded bytes') + const wrongOwner = await fetch(`${fileBase}/internal/file/v1/business-files/1/${category}/${businessId}/${fileId}`, { headers }) + const denied = await wrongOwner.json() + ensure(!wrongOwner.ok || String(denied.code) !== '200', 'File was visible across customers') + report.files.push({ category, fileId, sha256: hash(bytes), ownershipCheck: 'PASS' }) + } + report.passed = true +} catch (error) { report.error = error.message; process.exitCode = 1 } +finally { + await mkdir('reports/relabel-closure-20260916', { recursive: true }) + await writeFile('reports/relabel-closure-20260916/pdf.json', JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) +} diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 95a4c29b2..7bfb080eb 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -11,7 +11,7 @@ | 功能 | 入口 | |---|---| | 国内仓库只读目录 | `client/DomesticWarehouseDirectoryClient.java`、`service/DomesticWarehouseDirectory.java`;正结果缓存 30 分钟,空值和不存在不得长缓存 | -| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | +| 平台库位与托盘 | `controller/PlatformWarehouseResourceController.java`;企业端不得直接维护物理库位与托盘 | | 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | | PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 66c6033af..736dce6a7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -7,14 +7,17 @@ import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.PalletLabelPrintService; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/internal/v1/warehouse-pallets") public class InternalWarehousePalletController { @Resource private WarehouseResourceService service; + @Resource private PalletLabelPrintService palletLabels; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/ensure-dropship") @@ -49,4 +52,14 @@ public class InternalWarehousePalletController { return ApiResponse.success(service.resolvePublicPallet(palletId)); } + @GetMapping("/{warehouseId}/{palletId}/label") + public ResponseEntity label( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Request-Id", required = false) String requestId, + @PathVariable long warehouseId, @PathVariable long palletId, + @RequestParam String transferNo) { + tokenGuard.verify(token); + return palletLabels.printTransfer(warehouseId, palletId, transferNo, requestId); + } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java deleted file mode 100644 index 0ba243967..000000000 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.warehouse.dto.LocationRequests; -import com.baiyue.warehouse.dto.PalletRequests; -import com.baiyue.warehouse.entity.WarehouseLocationEntity; -import com.baiyue.warehouse.entity.WarehousePalletEntity; -import com.baiyue.warehouse.service.WarehouseResourceService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -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.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}") -public class MerchantWarehouseResourceController { - @Resource private WarehouseResourceService service; - - @PostMapping("/locations") - public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, - @Valid @RequestBody LocationRequests.SaveRequest request) { - return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request)); - } - - @PutMapping("/locations/{id}") - public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id, - @Valid @RequestBody LocationRequests.SaveRequest request) { - service.updateLocation(tenant(user), warehouseId, id, request); - return ApiResponse.success(null); - } - - @GetMapping("/locations") - public ApiResponse> locations( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, - @Valid LocationRequests.QueryRequest query) { - return ApiResponse.success(service.locations(tenant(user), warehouseId, query)); - } - - @GetMapping("/locations/{id}") - public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, - @PathVariable long id) { - return ApiResponse.success(service.location(tenant(user), warehouseId, id)); - } - - @DeleteMapping("/locations/{id}") - public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id) { - service.deleteLocation(tenant(user), warehouseId, id); - return ApiResponse.success(null); - } - - @PostMapping("/pallets") - public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, - @Valid @RequestBody PalletRequests.SaveRequest request) { - return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request)); - } - - @PutMapping("/pallets/{id}") - public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id, - @Valid @RequestBody PalletRequests.SaveRequest request) { - service.updatePallet(tenant(user), warehouseId, id, request); - return ApiResponse.success(null); - } - - @GetMapping("/pallets") - public ApiResponse> pallets( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, - @Valid PalletRequests.QueryRequest query) { - return ApiResponse.success(service.pallets(tenant(user), warehouseId, query)); - } - - @GetMapping("/pallets/{id}") - public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id) { - return ApiResponse.success(service.pallet(tenant(user), warehouseId, id)); - } - - @DeleteMapping("/pallets/{id}") - public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable long warehouseId, @PathVariable long id) { - service.deletePallet(tenant(user), warehouseId, id); - return ApiResponse.success(null); - } - - private static long tenant(AuthenticatedUser user) { - if (user == null || user.tenantId() == null || user.tenantId() <= 0) { - throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失"); - } - return user.tenantId(); - } -} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java index 2da38bfbe..08bf14e64 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/PalletLabelPrintService.java @@ -25,6 +25,16 @@ public class PalletLabelPrintService { private String internalToken; public ResponseEntity print(long warehouseId, long palletId, String requestId) { + return print(warehouseId, palletId, requestId, "PALLET"); + } + + public ResponseEntity printTransfer(long warehouseId, long palletId, String transferNo, + String requestId) { + String reference = transferNo == null || transferNo.isBlank() ? "转运单" : "转运单 " + transferNo.trim(); + return print(warehouseId, palletId, requestId, reference); + } + + private ResponseEntity print(long warehouseId, long palletId, String requestId, String reference) { var pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, palletId); String code = pallet.getPalletCode(); String fbaCode = pallet.getFbaWarehouseCode(); @@ -37,8 +47,9 @@ public class PalletLabelPrintService { } var warehouse = warehouses.required(warehouseId); String fileName = code.replaceAll("[\\\\/:*?\"<>|]", "-") + "-托盘码.pdf"; - var label = new PrintPdfClient.Label(code, fbaCode == null || fbaCode.isBlank() ? "—" : fbaCode, - "PALLET", warehouse.warehouseName(), code); + var label = new PrintPdfClient.Label(code, + fbaCode == null || fbaCode.isBlank() ? "目的仓待定" : fbaCode, + reference, warehouse.warehouseName(), code); var request = new PrintPdfClient.Request("INBOUND_MARK_V1", new PrintPdfClient.PageSpec(new BigDecimal("100"), new BigDecimal("100")), fileName, List.of(label)); try { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 407991006..afebff049 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -183,7 +183,7 @@ public class WarehouseResourceService { throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请刷新后重试"); } if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { - throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); + throw new BizException(ResultCode.CONFLICT, "已报废的托盘不允许修改为其他状态"); } String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() : existing.getPalletCode(); @@ -386,9 +386,11 @@ public class WarehouseResourceService { } private static String generatedPalletCode(String warehouseCode, long palletId) { - String boundedWarehouseCode = warehouseCode.length() <= 30 ? warehouseCode : warehouseCode.substring(0, 30); - return "PLT-" + boundedWarehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) - + "-" + Long.toUnsignedString(palletId); + String compactWarehouseCode = warehouseCode.replaceAll("[^A-Za-z0-9]", "").toUpperCase(java.util.Locale.ROOT); + if (compactWarehouseCode.length() > 9) compactWarehouseCode = compactWarehouseCode.substring(0, 9); + return "PLT-" + compactWarehouseCode + "-" + + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")) + "-" + + Long.toUnsignedString(palletId, 36).toUpperCase(java.util.Locale.ROOT); } private static String defaultLocationCode(String warehouseCode) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java new file mode 100644 index 000000000..97fa17267 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/InternalWarehousePalletLabelContractTest.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import static org.assertj.core.api.Assertions.assertThat; + +class InternalWarehousePalletLabelContractTest { + @Test + void labelEndpointIsInternalAndRequiresTheServiceToken() throws Exception { + var method = InternalWarehousePalletController.class.getMethod("label", + String.class, String.class, long.class, long.class, String.class); + + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/{warehouseId}/{palletId}/label"); + assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java index 3fb778ee7..728fd6a5c 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -32,18 +32,6 @@ class WarehouseResourceControllerTest { verify(labels).print(200L, 9L, "r1"); } - @Test - void merchantTenantAlwaysComesFromAuthenticatedUser() { - MerchantWarehouseResourceController controller = merchantController(); - AuthenticatedUser merchant = user(10001L, "MERCHANT"); - WarehouseLocationEntity expected = new WarehouseLocationEntity(); - when(service.location(10001L, 20001L, 30001L)).thenReturn(expected); - - controller.location(merchant, 20001L, 30001L); - - verify(service).location(10001L, 20001L, 30001L); - } - @Test void platformApiRejectsNonPlatformIdentity() { PlatformWarehouseResourceController controller = platformController(); @@ -79,12 +67,6 @@ class WarehouseResourceControllerTest { "tester", appCode, "WEB", Set.of()); } - private MerchantWarehouseResourceController merchantController() { - MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController(); - ReflectionTestUtils.setField(controller, "service", service); - return controller; - } - private PlatformWarehouseResourceController platformController() { PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController(); ReflectionTestUtils.setField(controller, "service", service); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java index 5035541d1..ee805f0ec 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/PalletLabelPrintServiceTest.java @@ -59,6 +59,26 @@ class PalletLabelPrintServiceTest { verify(print, never()).generateInboundMarks(any(), any(), any()); } + @Test + void transferLabelIncludesTransferNumberAndMakesAnUnknownDestinationExplicit() { + pallet.setPalletCode("ABCD1234567-ABE3-003"); + pallet.setFbaWarehouseCode(null); + byte[] pdf = "%PDF-transfer-label".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + when(print.generateInboundMarks(anyString(), anyString(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(pdf)); + + service.printTransfer(200L, 9L, "WTR-20260916-1", "request-transfer"); + + var request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateInboundMarks(eq("test-token"), eq("request-transfer"), request.capture()); + assertThat(request.getValue().labels()).singleElement().satisfies(label -> { + assertThat(label.topCode()).isEqualTo("ABCD1234567-ABE3-003"); + assertThat(label.cartonText()).isEqualTo("转运单 WTR-20260916-1"); + assertThat(label.warehouseText()).isEqualTo("目的仓待定"); + assertThat(label.routeText()).isEqualTo("洛杉矶仓"); + }); + } + @Test void refusesWrongWarehouseBeforeCallingPrint() { when(resources.pallet(0L, 201L, 9L)).thenThrow(new BizException(ResultCode.NOT_FOUND, "托盘不存在")); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 888a44884..d89161f7c 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -262,7 +262,8 @@ class WarehouseResourceServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).insertPallet(captor.capture()); - assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getPalletCode()).matches("PLT-USLAX01-[0-9]{6}-[0-9A-Z]+"); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); assertThat(captor.getValue().getId()).isNotNull(); } @@ -283,7 +284,7 @@ class WarehouseResourceServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); verify(repository).insertPallet(captor.capture()); - assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(64); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(34); } @Test @@ -331,7 +332,7 @@ class WarehouseResourceServiceTest { assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null, null, 0))) .isInstanceOf(BizException.class) - .hasMessageContaining("cannot be reused"); + .hasMessageContaining("已报废的托盘不允许修改为其他状态"); } @Test