Merge pull request 'Test' (#5) from test into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -14,6 +14,7 @@ logs/
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
.env.development
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
application-local.yml
|
||||
|
||||
@@ -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/`;服务专属脚本留在 `<service>/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/`;服务专属脚本留在 `<service>/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/` |
|
||||
|
||||
## 公共模块快速索引
|
||||
|
||||
|
||||
Vendored
+99
@@ -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}
|
||||
> 状态:<font color="${color}">${status}</font>
|
||||
> 详情:${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<String> 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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/`;服务专属脚本留在 `<service>/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` 消费。标签与资源预留的细节继续读对应服务指引。
|
||||
@@ -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。
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 '对象存储键',
|
||||
|
||||
@@ -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: 船名}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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")))
|
||||
|
||||
@@ -157,6 +157,9 @@ class GenericXlsxExportRunnerTest {
|
||||
Map<String, Object> 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<ExportBatchRequest> request = ArgumentCaptor.forClass(ExportBatchRequest.class);
|
||||
verify(provider).batch(anyString(), eq(ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST),
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||
|
||||
@@ -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<RemarksUpdateResponse> 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,
|
||||
|
||||
+12
@@ -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<Integer> 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<PageResult<UnifiedInboundSummary>> unifiedPage(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -168,9 +168,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Param("version") int version, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
|
||||
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET unloading_completed_at=#{unloadingCompletedAt},
|
||||
unloading_photo_uploaded_at=#{photoUploadedAt}, estimated_empty_reported_at=#{estimatedEmptyReportedAt},
|
||||
|
||||
@@ -12,7 +12,7 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface LoadingPalletLabelMapper {
|
||||
@Select("""
|
||||
SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.warehouse_id,
|
||||
SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id,
|
||||
i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id,
|
||||
i.destination_warehouse_code,i.destination_warehouse_name,
|
||||
COALESCE(NULLIF(i.pallet_count,0),
|
||||
@@ -55,6 +55,7 @@ public interface LoadingPalletLabelMapper {
|
||||
@Getter @Setter
|
||||
class SourceRow {
|
||||
private Long planId; private String planNo; private String planStatus; private String containerNo;
|
||||
private String cabinetNo;
|
||||
private Long warehouseId; private Long itemId; private Long accountId; private Long inboundId;
|
||||
private Long destinationWarehouseId;
|
||||
private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import org.apache.ibatis.annotations.*; import java.util.List;
|
||||
@Mapper public interface LoadingPlanItemMapper extends BaseMapper<LoadingPlanItemEntity> {
|
||||
@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<Long> 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<Long> 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);
|
||||
}
|
||||
|
||||
@@ -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<LoadingPlanEntity> {
|
||||
@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("""
|
||||
<script>
|
||||
UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE plan_status IN ('EDITING','LOADING') AND id IN
|
||||
<foreach collection="planIds" item="planId" open="(" separator="," close=")">#{planId}</foreach>
|
||||
</script>
|
||||
""")
|
||||
int touchForOrderRemarks(@Param("planIds") List<Long> planIds,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
|
||||
SELECT booking.id ocean_booking_id,booking.booking_no,booking.bill_of_lading_no,requirement.id requirement_id,
|
||||
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
|
||||
requirement.quantity required_quantity,
|
||||
COALESCE(allocated.allocated_quantity,0) allocated_quantity,
|
||||
|
||||
+6
-3
@@ -38,6 +38,12 @@ public class OceanBookingRepository {
|
||||
}
|
||||
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
|
||||
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
|
||||
public boolean billOfLadingNoExists(String billOfLadingNo, Long excludedId) {
|
||||
if (billOfLadingNo == null) return false;
|
||||
return bookings.selectCount(new LambdaQueryWrapper<OceanBookingEntity>()
|
||||
.eq(OceanBookingEntity::getBillOfLadingNo, billOfLadingNo)
|
||||
.ne(excludedId != null, OceanBookingEntity::getId, excludedId)) > 0;
|
||||
}
|
||||
public void insert(OceanBookingEntity value) { bookings.insert(value); }
|
||||
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
|
||||
return bookings.transition(id, from, to, version, operatorId, operatorName);
|
||||
@@ -102,9 +108,6 @@ public class OceanBookingRepository {
|
||||
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
|
||||
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
|
||||
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
|
||||
public boolean containerNoExists(String containerNo) {
|
||||
return containerBookings.containerNoExists(containerNo, null);
|
||||
}
|
||||
public void insertContainer(ContainerBookingEntity value) { containerBookings.insert(value); }
|
||||
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
|
||||
public int reassignAllocation(long sourceId, long targetId) {
|
||||
|
||||
+16
-2
@@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import feign.FeignException;
|
||||
@@ -110,9 +111,11 @@ public class OceanBookingService {
|
||||
value.setBookingNo(documentNumbers.next("OB"));
|
||||
value.setStatus(OceanBookingStatus.DRAFT.name());
|
||||
copy(value, request.data(), resolved);
|
||||
requireUniqueBillOfLadingNo(value.getBillOfLadingNo(), null);
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
repository.insert(value);
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) { throw duplicateBillOfLadingNo(); }
|
||||
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
|
||||
List<OceanBookingContainerRequirementEntity> requirements = requirements(
|
||||
value.getId(), request.data().containerRequirements());
|
||||
@@ -132,7 +135,10 @@ public class OceanBookingService {
|
||||
validateDraftStructure(request.data());
|
||||
ResolvedDraft resolved = resolveDraft(request.data());
|
||||
copy(before, request.data(), resolved);
|
||||
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
|
||||
requireUniqueBillOfLadingNo(before.getBillOfLadingNo(), id);
|
||||
try {
|
||||
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
|
||||
} catch (DuplicateKeyException exception) { throw duplicateBillOfLadingNo(); }
|
||||
repository.replaceParties(id, parties(id, request.data().parties()));
|
||||
repository.replaceRequirements(id, requirements(id, request.data().containerRequirements()));
|
||||
OceanBookingEntity after = required(id);
|
||||
@@ -294,6 +300,14 @@ public class OceanBookingService {
|
||||
v.setRemark(trim(d.remark()));
|
||||
}
|
||||
|
||||
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
|
||||
if (repository.billOfLadingNoExists(billOfLadingNo, excludedId)) throw duplicateBillOfLadingNo();
|
||||
}
|
||||
|
||||
private static BizException duplicateBillOfLadingNo() {
|
||||
return conflict("提单号已存在,请检查是否重复创建订舱");
|
||||
}
|
||||
|
||||
private void validateMasterData(OceanBookingEntity booking) {
|
||||
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
|
||||
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
|
||||
|
||||
@@ -22,9 +22,10 @@ public class ContainerRepository {
|
||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
||||
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
|
||||
public boolean containerNoExists(String no, Long excludedId) {
|
||||
public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) {
|
||||
if (no == null) return false;
|
||||
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
||||
.eq(ContainerBookingEntity::getOceanBookingId, oceanBookingId)
|
||||
.eq(ContainerBookingEntity::getContainerNo, no)
|
||||
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ public class ContainerService {
|
||||
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
|
||||
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
|
||||
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
|
||||
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
|
||||
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.nextCabinetNo());
|
||||
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
|
||||
value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
|
||||
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
|
||||
@@ -93,7 +93,9 @@ public class ContainerService {
|
||||
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
|
||||
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
|
||||
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
|
||||
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
if (containerNo != null && repository.containerNoExists(request.oceanBookingId(), containerNo, id)) {
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|
||||
|| !java.util.Objects.equals(container.getChannelGroupCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
|
||||
@@ -105,7 +107,7 @@ public class ContainerService {
|
||||
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
|
||||
}
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("集装箱号已存在");
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
|
||||
return required(id);
|
||||
@@ -126,13 +128,15 @@ public class ContainerService {
|
||||
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
|
||||
}
|
||||
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
try {
|
||||
if (repository.assignRealContainerNo(id, containerNo, expectedVersion, operatorId, operatorName) != 1) {
|
||||
throw conflict("真实柜号已被补录或货柜版本发生变化,请刷新后重试");
|
||||
}
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("集装箱号已存在");
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
return required(id);
|
||||
}
|
||||
|
||||
+19
-5
@@ -19,7 +19,7 @@ import java.util.Set;
|
||||
public class FirstlegDocumentNumberGenerator {
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP", "DP");
|
||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "LP", "DP");
|
||||
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
@@ -42,16 +42,30 @@ public class FirstlegDocumentNumberGenerator {
|
||||
}
|
||||
LocalDate sequenceDate = LocalDate.now(clock);
|
||||
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
||||
long sequence = nextSequence(sequenceKey);
|
||||
if (sequence > MAX_DAILY_SEQUENCE) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
|
||||
}
|
||||
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||
String.format(Locale.ROOT, "%04d", sequence);
|
||||
}
|
||||
|
||||
/** 平台柜次号是长期业务身份,使用不按日期重置的 D 序号。 */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public String nextCabinetNo() {
|
||||
return "D" + String.format(Locale.ROOT, "%04d", nextSequence("CABINET"));
|
||||
}
|
||||
|
||||
private long nextSequence(String sequenceKey) {
|
||||
jdbc.update(
|
||||
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
|
||||
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
||||
"current_value=LAST_INSERT_ID(current_value+1)",
|
||||
sequenceKey);
|
||||
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
|
||||
if (sequence == null || sequence <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "业务编号生成失败");
|
||||
}
|
||||
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||
String.format(Locale.ROOT, "%04d", sequence);
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ public class InboundMeasurementService {
|
||||
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
|
||||
LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound);
|
||||
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
|
||||
review, latestDeclaration);
|
||||
review, latestDeclaration, customerFeeConfirmed(tenantId, inbound.getId()));
|
||||
}
|
||||
|
||||
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
|
||||
@@ -236,10 +236,9 @@ public class InboundMeasurementService {
|
||||
InboundOrderEntity inbound, String subject) {
|
||||
var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId);
|
||||
boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null;
|
||||
boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
|
||||
if (customerConfirmed && loadingCompleted) {
|
||||
if (customerConfirmed) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"客户已确认且该票已装柜完成,不能修改" + subject);
|
||||
"客户已确认费用,不能修改" + subject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,12 +568,14 @@ public class InboundMeasurementService {
|
||||
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
|
||||
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
|
||||
MeasureReviewRecordEntity review,
|
||||
LatestDeclaration latestDeclaration) {
|
||||
LatestDeclaration latestDeclaration,
|
||||
boolean customerFeeConfirmed) {
|
||||
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
|
||||
return new InboundMeasurementResponses.Detail(
|
||||
inbound.getId(), inbound.getInboundCode(), customerDisplayName(inbound), inbound.getChannelCode(),
|
||||
inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(),
|
||||
inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
|
||||
customerFeeConfirmed,
|
||||
RECOMMENDED_PALLET_TYPE,
|
||||
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
|
||||
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
|
||||
@@ -603,6 +604,11 @@ public class InboundMeasurementService {
|
||||
toPalletLocationGuide(allocationSlots));
|
||||
}
|
||||
|
||||
private boolean customerFeeConfirmed(long tenantId, long inboundId) {
|
||||
var latestFee = pricingRepository.findLatestFee(tenantId, inboundId);
|
||||
return latestFee != null && latestFee.getCustomerConfirmedAt() != null;
|
||||
}
|
||||
|
||||
private static InboundMeasurementResponses.PalletLocationGuide toPalletLocationGuide(
|
||||
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> slots) {
|
||||
if (slots.isEmpty()) return null;
|
||||
|
||||
@@ -463,9 +463,10 @@ public class LoadingListService {
|
||||
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
|
||||
return repository.preloadDetail(before);
|
||||
}
|
||||
if (containerNo != null
|
||||
&& containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
|
||||
throw conflict("集装箱号已存在");
|
||||
if (containerNo != null && before.getOceanBookingId() != null
|
||||
&& containerRepository.containerNoExists(before.getOceanBookingId(), containerNo,
|
||||
before.getContainerBookingId())) {
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
try {
|
||||
int updated = initialContainerFill
|
||||
@@ -477,7 +478,7 @@ public class LoadingListService {
|
||||
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
|
||||
}
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("集装箱号已存在");
|
||||
throw conflict("当前提单下集装箱号已存在");
|
||||
}
|
||||
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
|
||||
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 散货托盘码按真实柜号、FBA 地址内部编号和仓内序号组成;一件代发继续使用装箱打托事实。 */
|
||||
/** 散货托盘码按平台柜次号、FBA 地址内部编号和仓内序号组成;一件代发继续使用装箱打托事实。 */
|
||||
@Service
|
||||
public class LoadingPalletLabelService {
|
||||
@Resource private LoadingPalletLabelMapper mapper;
|
||||
@@ -107,7 +107,8 @@ public class LoadingPalletLabelService {
|
||||
row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId());
|
||||
row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo);
|
||||
row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName());
|
||||
row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + internalCode + "-" + String.format("%03d", index));
|
||||
row.setPalletIndex(index); row.setPalletCode(plan.getCabinetNo() + "-" + internalCode + "-"
|
||||
+ String.format("%03d", index));
|
||||
if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制");
|
||||
result.add(row);
|
||||
}
|
||||
@@ -165,10 +166,14 @@ public class LoadingPalletLabelService {
|
||||
}
|
||||
private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), internalCode(row), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); }
|
||||
private String internalCode(LabelRow row) {
|
||||
String prefix = row.getContainerNo() + "-";
|
||||
String suffix = "-" + String.format("%03d", row.getPalletIndex());
|
||||
String palletCode = row.getPalletCode();
|
||||
return palletCode.substring(prefix.length(), palletCode.length() - suffix.length());
|
||||
String body = palletCode.substring(0, palletCode.length() - suffix.length());
|
||||
int separator = body.lastIndexOf('-');
|
||||
if (separator < 0 || separator == body.length() - 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘码缺少柜次号或 FBA 仓内部编号");
|
||||
}
|
||||
return body.substring(separator + 1);
|
||||
}
|
||||
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.Objects;
|
||||
public class OrderDeclarationSyncService {
|
||||
@Resource private OrderDeclarationSyncMapper mapper;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private OrderRemarksSyncService remarksSync;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ChannelChangeValidationResponse validateChannelChange(ChannelChangeValidationRequest request) {
|
||||
@@ -86,6 +87,8 @@ public class OrderDeclarationSyncService {
|
||||
normalizeChannel(event.channelCode()), event.channelName(), event.operatorId()) != 1) {
|
||||
throw conflict("Firstleg 入库申报快照已发生变化");
|
||||
}
|
||||
remarksSync.syncCustomerRemark(event.tenantId(), event.orderId(), event.customerRemark(),
|
||||
event.operatorId(), "order-declaration");
|
||||
}
|
||||
|
||||
private void recordEvent(UpdatedEvent event, String hash) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.OrderRemarksSync;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class OrderRemarksSyncService {
|
||||
@Resource private LoadingPlanItemMapper items;
|
||||
@Resource private LoadingPlanMapper plans;
|
||||
|
||||
@Transactional
|
||||
public int sync(OrderRemarksSync.Command command) {
|
||||
return sync(command, null, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int sync(OrderRemarksSync.Command command, Long alreadyTouchedPlanId) {
|
||||
return sync(command, alreadyTouchedPlanId, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int sync(OrderRemarksSync.Command command, Long alreadyTouchedPlanId, Long excludedItemId) {
|
||||
List<Long> affectedPlanIds = items.findMutablePlanIdsWithDifferentRemarks(
|
||||
command.tenantId(), command.orderId(), command.customerRemark(), command.internalRemark(), command.warehouseRemark(),
|
||||
command.customerServiceRemark(), excludedItemId);
|
||||
if (affectedPlanIds.isEmpty()) return 0;
|
||||
int updated = items.syncMutableOrderRemarks(command.tenantId(), command.orderId(),
|
||||
command.customerRemark(), command.internalRemark(), command.warehouseRemark(), command.customerServiceRemark(),
|
||||
command.operatorId(), command.operatorName(), excludedItemId);
|
||||
if (updated > 0) {
|
||||
List<Long> plansToTouch = affectedPlanIds.stream()
|
||||
.filter(planId -> !planId.equals(alreadyTouchedPlanId)).toList();
|
||||
if (!plansToTouch.isEmpty()) {
|
||||
plans.touchForOrderRemarks(plansToTouch, command.operatorId(), command.operatorName());
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int syncCustomerRemark(long tenantId, long orderId, String customerRemark,
|
||||
long operatorId, String operatorName) {
|
||||
List<Long> affectedPlanIds = items.findMutablePlanIdsWithDifferentCustomerRemark(
|
||||
tenantId, orderId, customerRemark);
|
||||
if (affectedPlanIds.isEmpty()) return 0;
|
||||
int updated = items.syncMutableOrderCustomerRemark(
|
||||
tenantId, orderId, customerRemark, operatorId, operatorName);
|
||||
if (updated > 0) plans.touchForOrderRemarks(affectedPlanIds, operatorId, operatorName);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
|
||||
import com.baiyue.firstleg.dto.OrderRemarksSync;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.client.PendingReviewOrderClient;
|
||||
@@ -56,6 +57,7 @@ public class SmartLoadingService {
|
||||
@Resource private FbaPricingService fbaPricingService;
|
||||
@Resource private PriceBookQueryUseCase priceBooks;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private OrderRemarksSyncService orderRemarksSync;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
public SmartLoadingService() { }
|
||||
@@ -1099,6 +1101,9 @@ public class SmartLoadingService {
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
|
||||
LoadingPlanItemEntity item = repository.item(planId, itemId);
|
||||
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
|
||||
if (item.getVersion() != request.expectedItemVersion()) {
|
||||
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
|
||||
}
|
||||
if (Objects.equals(item.getInternalRemark(), internalRemark)
|
||||
&& Objects.equals(item.getWarehouseRemark(), warehouseRemark)
|
||||
&& Objects.equals(item.getCustomerServiceRemark(), customerServiceRemark)) {
|
||||
@@ -1109,16 +1114,30 @@ public class SmartLoadingService {
|
||||
"version", item.getVersion()));
|
||||
try {
|
||||
touch(plan, operatorId, operatorName);
|
||||
ApiResponse<OrderCargoClient.RemarksUpdateResponse> response = orderCargoClient.updateRemarks(
|
||||
internalToken, item.getAccountId(), item.getOrderId(),
|
||||
new OrderCargoClient.RemarksUpdateRequest(internalRemark, warehouseRemark,
|
||||
customerServiceRemark, operatorId));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
response == null ? "订单备注服务暂不可用" : response.message());
|
||||
}
|
||||
if (repository.updateRemarks(planId, itemId, request.expectedPlanVersion() + 1,
|
||||
request.expectedItemVersion(), internalRemark, warehouseRemark, customerServiceRemark,
|
||||
operatorId, operatorName) != 1) {
|
||||
request.expectedItemVersion(), response.data().internalRemark(), response.data().warehouseRemark(),
|
||||
response.data().customerServiceRemark(), operatorId, operatorName) != 1) {
|
||||
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
|
||||
}
|
||||
orderRemarksSync.sync(new OrderRemarksSync.Command(item.getAccountId(), item.getOrderId(),
|
||||
response.data().customerRemark(),
|
||||
response.data().internalRemark(), response.data().warehouseRemark(),
|
||||
response.data().customerServiceRemark(), operatorId, operatorName), planId, itemId);
|
||||
} catch (DataAccessException exception) {
|
||||
if (hasSqlState(exception, "45000")) {
|
||||
throw conflict("装柜计划或明细状态已变化,备注未保存,请刷新后重试");
|
||||
}
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
history(planId, itemId, "REMARKS", plan.getPlanStatus(), plan.getPlanStatus(), operation,
|
||||
before, json(values("internalRemark", internalRemark, "warehouseRemark", warehouseRemark,
|
||||
|
||||
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS 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_customer CHECK (customer_id > 0),
|
||||
@@ -52,6 +53,18 @@ ALTER TABLE fl_ocean_booking
|
||||
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
|
||||
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
|
||||
|
||||
SET @ocean_add_bill_unique = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE fl_ocean_booking ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)',
|
||||
'SELECT 1')
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
|
||||
AND index_name='uk_ocean_booking_bill_of_lading'
|
||||
);
|
||||
PREPARE ocean_bill_unique_stmt FROM @ocean_add_bill_unique;
|
||||
EXECUTE ocean_bill_unique_stmt;
|
||||
DEALLOCATE PREPARE ocean_bill_unique_stmt;
|
||||
|
||||
-- Recreate triggers and the current two-state constraint on repeatable initialization.
|
||||
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
|
||||
|
||||
@@ -27,7 +27,7 @@ CALL phase3_add_allocation_picked_count();
|
||||
DROP PROCEDURE phase3_add_allocation_picked_count;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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)
|
||||
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
|
||||
CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
|
||||
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
|
||||
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已合并历史',
|
||||
@@ -92,7 +92,8 @@ CREATE TABLE IF NOT EXISTS 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),
|
||||
@@ -203,9 +204,17 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
|
||||
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
|
||||
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
|
||||
ALTER TABLE fl_container_booking DROP INDEX uk_container_booking_container_no;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='uk_container_booking_ocean_container') THEN
|
||||
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='idx_container_booking_container_no') THEN
|
||||
ALTER TABLE fl_container_booking ADD KEY idx_container_booking_container_no (container_no,updated_at,id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='idx_container_booking_merge') THEN
|
||||
@@ -379,7 +388,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
|
||||
destination_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码快照',
|
||||
destination_warehouse_name VARCHAR(128) 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),
|
||||
|
||||
@@ -292,6 +292,32 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/OrderDeclarationUpdatedEvent'}
|
||||
responses:
|
||||
'200': {description: 成功、幂等重放或旧版本已忽略;冲突时响应体 code=A0409}
|
||||
/internal/firstleg/v1/inbounds/order-remarks-updated:
|
||||
post:
|
||||
summary: 同步 Order 权威协作备注到未冻结预装明细
|
||||
description: >-
|
||||
仅接受内部服务令牌。按客户与订单关联全部 EDITING/LOADING 且未冻结的预装明细,
|
||||
同步内部、仓库和客服备注并递增受影响计划与明细版本;已完成装柜的冻结快照不修改。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [tenantId, orderId, operatorId]
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 1}
|
||||
orderId: {type: integer, format: int64, minimum: 1}
|
||||
customerRemark: {type: string, nullable: true, maxLength: 1000}
|
||||
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}
|
||||
operatorName: {type: string, nullable: true, maxLength: 128}
|
||||
responses:
|
||||
'200': {description: data 返回已同步的预装明细数量}
|
||||
/internal/firstleg/v1/inbounds/unified-orders/query:
|
||||
post:
|
||||
summary: 按统一订单条件分页查询履约摘要(Order 内部)
|
||||
@@ -457,7 +483,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到已发布订舱需求
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别、校验位和全局唯一性。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
@@ -1018,8 +1044,7 @@ paths:
|
||||
summary: 更新箱规计算规则
|
||||
description: >-
|
||||
独立更新当前测量批次全部明细的箱规计算规则并重算体积,不覆盖整批测量数据,
|
||||
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
|
||||
仅当客户已确认费用且该票已装柜完成时冻结。
|
||||
不改变测量提交或审核状态。仅当客户已确认费用且该票已装柜完成时冻结,不得再修改。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -1036,8 +1061,7 @@ paths:
|
||||
summary: 更新仓库外箱长宽高
|
||||
description: >-
|
||||
独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积,不覆盖整批测量数据,
|
||||
不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改;
|
||||
仅当客户已确认费用且该票已装柜完成时冻结。
|
||||
不改变测量提交或审核状态。仅当客户已确认费用且该票已装柜完成时冻结,不得再修改。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -1905,7 +1929,7 @@ components:
|
||||
type: object
|
||||
description: >-
|
||||
入库测量权威详情。尺寸证据天然归属 items,称重证据天然归属 weightResults,响应不提供统一 images。
|
||||
required: [inboundId, inboundVersion, items, weightResults]
|
||||
required: [inboundId, inboundVersion, customerFeeConfirmed, items, weightResults]
|
||||
properties:
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段}
|
||||
@@ -1914,6 +1938,7 @@ components:
|
||||
channelName: {type: string, nullable: true}
|
||||
processStatus: {type: string, nullable: true}
|
||||
measureReviewStatus: {type: string, nullable: true}
|
||||
customerFeeConfirmed: {type: boolean, description: 当前最新费用版本已由客户确认,确认后测量数据不可再修改}
|
||||
recommendedPalletType: {type: string, nullable: true}
|
||||
reviewReason: {type: string, nullable: true}
|
||||
reviewerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
@@ -2200,7 +2225,7 @@ components:
|
||||
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
|
||||
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
|
||||
containerNo: {type: string, maxLength: 64, nullable: true}
|
||||
cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 平台稳定柜次号}
|
||||
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
|
||||
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
|
||||
@@ -2765,7 +2790,7 @@ components:
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
|
||||
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和全局唯一性}
|
||||
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
ContainerFeeAdjustRequest:
|
||||
type: object
|
||||
|
||||
@@ -301,7 +301,7 @@ components:
|
||||
containerBookingId: {$ref: '#/components/schemas/LongId'}
|
||||
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号;不得与真实集装箱号混用}
|
||||
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用}
|
||||
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
|
||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||
@@ -359,7 +359,7 @@ components:
|
||||
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
|
||||
warehouseName: {type: string, nullable: true, description: 仓库名称}
|
||||
containerNo: {type: string, nullable: true, description: 集装箱号}
|
||||
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号}
|
||||
cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
|
||||
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
|
||||
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
|
||||
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
|
||||
|
||||
@@ -167,7 +167,7 @@ components:
|
||||
customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
|
||||
channelCode: {type: string, nullable: true, description: 固定计划物流渠道组编码}
|
||||
warehouseId: {type: integer, format: int64, nullable: true}
|
||||
billOfLadingNo: {type: string, nullable: true}
|
||||
billOfLadingNo: {type: string, nullable: true, description: 全局唯一提单号;草稿未取得时可为空}
|
||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||
shippingCompanyName: {type: string, nullable: true}
|
||||
carrierCode:
|
||||
@@ -267,10 +267,11 @@ components:
|
||||
carrierCodes: {type: array, items: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}}
|
||||
LinkCandidate:
|
||||
type: object
|
||||
required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
|
||||
required: [oceanBookingId, bookingNo, billOfLadingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
|
||||
properties:
|
||||
oceanBookingId: {type: string, pattern: '^[0-9]+$'}
|
||||
bookingNo: {type: string}
|
||||
billOfLadingNo: {type: string, description: 已发布订舱的全局唯一提单号}
|
||||
requirementId: {type: string, pattern: '^[0-9]+$'}
|
||||
containerType: {type: string}
|
||||
capacityCbm: {type: number}
|
||||
|
||||
@@ -74,7 +74,7 @@ components:
|
||||
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
|
||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
|
||||
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
||||
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
||||
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ class ContainerControllerContractTest {
|
||||
.contains("未满配 requirementId")
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
|
||||
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
|
||||
+3
@@ -7,6 +7,7 @@ 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.UnifiedInboundQuery;
|
||||
import com.baiyue.firstleg.dto.OrderDeclarationSync;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
@@ -36,6 +37,8 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
private UnifiedInboundQueryService unifiedQueryService;
|
||||
@MockBean
|
||||
private OrderDeclarationSyncService declarationSync;
|
||||
@MockBean
|
||||
private OrderRemarksSyncService remarksSync;
|
||||
@Resource
|
||||
private InternalInboundOrderController controller;
|
||||
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ class OceanBookingMapperContractTest {
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("booking.bill_of_lading_no")
|
||||
.contains("booking.status='RELEASED'")
|
||||
.contains("booking.warehouse_id=#{query.warehouseId}")
|
||||
.contains("booking.channel_code=#{query.channelGroupCode}")
|
||||
|
||||
+12
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -23,6 +25,8 @@ class OceanBookingSqlContractTest {
|
||||
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
|
||||
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
|
||||
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
|
||||
.doesNotContain("service_term")
|
||||
.doesNotContain("freight_term")
|
||||
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
|
||||
@@ -33,6 +37,14 @@ class OceanBookingSqlContractTest {
|
||||
.doesNotContain("ALTER TABLE fl_container_booking");
|
||||
}
|
||||
|
||||
@Test
|
||||
void billOfLadingNumberIsUniqueInModuleAndUnifiedInitialization() throws Exception {
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
for (String source : new String[] {sql(), unified}) {
|
||||
assertThat(source).contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
|
||||
assertThat(sql())
|
||||
|
||||
+13
-1
@@ -326,7 +326,7 @@ class OceanBookingServiceTest {
|
||||
|
||||
@Test
|
||||
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
|
||||
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ",
|
||||
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", "BL-001", 201L, "40HQ",
|
||||
new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2,
|
||||
"客户A", "深圳中转仓", "美森正班");
|
||||
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
@@ -347,6 +347,18 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).containers(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateBillOfLadingNumberBeforeCreatingDraft() {
|
||||
stubMasterData();
|
||||
when(repository.billOfLadingNoExists("BL-001", null)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-bill",
|
||||
new CreateRequest(0, completeDraft())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提单号已存在");
|
||||
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
|
||||
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
+5
-3
@@ -106,16 +106,18 @@ class ContainerBookingMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
|
||||
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
|
||||
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(module.toLowerCase())
|
||||
.contains("国内唯一权威真实集装箱号")
|
||||
.contains("国内柜次真实集装箱号权威快照")
|
||||
.contains("同一订舱内唯一,不同提单允许复用")
|
||||
.contains("提柜后可原子补录");
|
||||
for (String source : new String[] {module, unified}) {
|
||||
assertThat(source.toLowerCase())
|
||||
.contains("unique key uk_container_booking_container_no (container_no)")
|
||||
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
|
||||
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
|
||||
.contains("constraint chk_container_booking_iso6346")
|
||||
.contains("^[a-z]{3}[ujz][0-9]{7}$");
|
||||
}
|
||||
|
||||
+2
-1
@@ -68,7 +68,8 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("source_type varchar(24) NOT NULL DEFAULT 'SIMULATION'")
|
||||
.contains("record_status varchar(16) NOT NULL DEFAULT 'ACTIVE'")
|
||||
.contains("merged_into_container_booking_id bigint unsigned DEFAULT NULL")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no)")
|
||||
.contains("KEY idx_container_booking_container_no (container_no,updated_at,id)")
|
||||
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
|
||||
.contains("CREATE TRIGGER trg_loading_plan_before_update")
|
||||
.contains("装柜状态只允许单向推进")
|
||||
|
||||
@@ -53,7 +53,7 @@ class ContainerServiceTest {
|
||||
@BeforeEach
|
||||
void setUpWarehouse() {
|
||||
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
|
||||
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
|
||||
when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
|
||||
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
|
||||
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
|
||||
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
|
||||
@@ -77,7 +77,7 @@ class ContainerServiceTest {
|
||||
ContainerBookingEntity saved = captor.getValue();
|
||||
assertThat(created).isSameAs(saved);
|
||||
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
|
||||
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
|
||||
assertThat(saved.getCabinetNo()).isEqualTo("D0001");
|
||||
assertThat(saved.getContainerNo()).isNull();
|
||||
assertThat(saved.getOceanBookingId()).isNull();
|
||||
assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX");
|
||||
@@ -246,7 +246,7 @@ class ContainerServiceTest {
|
||||
|
||||
assertThat(result.getContainerNo()).isNull();
|
||||
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
|
||||
verify(repository, never()).containerNoExists(anyString(), anyLong());
|
||||
verify(repository, never()).containerNoExists(anyLong(), anyString(), anyLong());
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
@@ -303,12 +303,12 @@ class ContainerServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
|
||||
@DisplayName("提柜补录真实柜号拒绝同一订舱内重复号")
|
||||
void rejectsDuplicateRealContainerNumberAfterPickup() {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(linked);
|
||||
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
|
||||
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.assignRealContainerNo(
|
||||
11L, "MSCU6639870", 3, 9L, "admin"))
|
||||
@@ -331,10 +331,10 @@ class ContainerServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联规范化后按全局唯一真实柜号拒绝冲突")
|
||||
@DisplayName("首次关联规范化后按同一订舱唯一真实柜号拒绝冲突")
|
||||
void rejectsNormalizedContainerNumberConflict() {
|
||||
stubLinkContext();
|
||||
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
|
||||
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2)))
|
||||
|
||||
+14
-2
@@ -42,11 +42,23 @@ class FirstlegDocumentNumberGeneratorTest {
|
||||
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
|
||||
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
|
||||
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
|
||||
assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001");
|
||||
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D0001");
|
||||
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||
eq("OB:20260818"));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
|
||||
eq("CABINET"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cabinetSequenceDoesNotResetOrStopAtFourDigits() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L);
|
||||
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D9999");
|
||||
assertThat(generator.nextCabinetNo()).isEqualTo("D10000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,6 +68,6 @@ class FirstlegDocumentNumberGeneratorTest {
|
||||
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||
assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-3
@@ -124,8 +124,8 @@ class InboundMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void locksCalculationStrategyOnlyAfterCustomerConfirmationAndLoadingCompletion() {
|
||||
InboundOrderEntity inbound = inbound("LOADED");
|
||||
void locksCalculationStrategyAsSoonAsCustomerConfirmsFee() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
|
||||
InboundFeeEntity fee = new InboundFeeEntity();
|
||||
fee.setCustomerConfirmedAt(LocalDateTime.now());
|
||||
when(repository.lockInbound(1L, 10L)).thenReturn(inbound);
|
||||
@@ -134,7 +134,7 @@ class InboundMeasurementServiceTest {
|
||||
assertThatThrownBy(() -> service.updateCalculationStrategy(1L, 10L,
|
||||
new InboundMeasurementRequests.UpdateCalculationStrategyRequest(30L, 5, "ACTUAL", null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("客户已确认且该票已装柜完成");
|
||||
.hasMessageContaining("客户已确认费用");
|
||||
|
||||
verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
@@ -245,6 +245,23 @@ class InboundMeasurementServiceTest {
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReportsCustomerFeeConfirmationForTheMeasurementLock() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
InboundFeeEntity fee = new InboundFeeEntity();
|
||||
fee.setCustomerConfirmedAt(LocalDateTime.now());
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
||||
when(pricingRepository.findLatestFee(1L, 10L)).thenReturn(fee);
|
||||
|
||||
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
|
||||
|
||||
assertThat(result.customerFeeConfirmed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReportsOrderContractNotReadyInsteadOfExposingDownstreamBadRequest() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
|
||||
+26
@@ -203,6 +203,32 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
|
||||
}
|
||||
|
||||
@Test void publishesOnlyTheActuallyLoadedFragmentMeasurements() throws Exception {
|
||||
Row loadedFragment = row(11, 100, 101);
|
||||
loadedFragment.setPackageCount(4);
|
||||
loadedFragment.setGrossWeightKg(new BigDecimal("40"));
|
||||
loadedFragment.setNetWeightKg(new BigDecimal("36"));
|
||||
loadedFragment.setDimensionalWeightKg(new BigDecimal("80"));
|
||||
loadedFragment.setVolumeCbm(new BigDecimal("4"));
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(loadedFragment));
|
||||
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
|
||||
label(101, "BOX-001"), label(101, "BOX-002"),
|
||||
label(101, "BOX-003"), label(101, "BOX-004")));
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox).insert(captor.capture());
|
||||
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(event.payload().at("/orders/0/packageCount").asInt()).isEqualTo(4);
|
||||
assertThat(event.payload().at("/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40");
|
||||
assertThat(event.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("80");
|
||||
assertThat(event.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("4");
|
||||
assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(4);
|
||||
assertThat(event.payload().at("/orders/0/items/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40");
|
||||
assertThat(event.payload().at("/orders/0/items/0/volumeCbm").decimalValue()).isEqualByComparingTo("4");
|
||||
}
|
||||
|
||||
@Test void immutableCustomerNameParticipatesInPayloadHash() {
|
||||
Row value = row(11, 100, 101);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
@@ -276,19 +276,19 @@ class LoadingListServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
|
||||
void rejectsSameBookingContainerConflictIncludingConcurrentConstraintRace() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
|
||||
when(containerRepository.containerNoExists(90L, "CSQU3054383", 30L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "unique-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, "唯一冲突")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
|
||||
|
||||
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(false);
|
||||
when(containerRepository.containerNoExists(90L, "CSQU3054383", 30L)).thenReturn(false);
|
||||
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"))
|
||||
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_container_no"));
|
||||
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_ocean_container"));
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "race-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, "并发唯一冲突")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
|
||||
@@ -961,6 +961,7 @@ class LoadingListServiceTest {
|
||||
row.setPlanStatus(status);
|
||||
row.setWarehouseId(100L);
|
||||
row.setChannelCode("OA_LA_TRUCK");
|
||||
row.setOceanBookingId(90L);
|
||||
row.setOutboundConfirmedAt(java.time.LocalDateTime.now());
|
||||
row.setVersion(version);
|
||||
return row;
|
||||
|
||||
+11
-8
@@ -22,9 +22,11 @@ class LoadingPalletLabelServiceTest {
|
||||
ReflectionTestUtils.setField(service, "printClient", print);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "test-token");
|
||||
var label1 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(1L, 1L, 1L, 1L,
|
||||
"IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 1, "MSCU6639870-141-001");
|
||||
"IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 1,
|
||||
"D0002-141-001");
|
||||
var label2 = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel(2L, 1L, 1L, 1L,
|
||||
"IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 2, "MSCU6639870-141-002");
|
||||
"IN-1", "MSCU6639870", "SCK8", "141", "FBA destination", 2,
|
||||
"D0002-141-002");
|
||||
var manifest = new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest(10L, "LP-1", "MSCU6639870", 2,
|
||||
List.of(new com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup("SCK8", "141", "FBA destination", 2, List.of(label1, label2))));
|
||||
org.mockito.Mockito.doReturn(manifest).when(service).prepare(10L, 20L);
|
||||
@@ -39,16 +41,16 @@ class LoadingPalletLabelServiceTest {
|
||||
assertThat(requests.get(0).pageSpec().widthMm()).isEqualByComparingTo("297");
|
||||
assertThat(requests.get(0).pageSpec().heightMm()).isEqualByComparingTo("210");
|
||||
assertThat(requests.get(0).labels()).extracting(com.baiyue.firstleg.client.PrintPdfClient.FbaPalletLabel::palletCode)
|
||||
.containsExactly("MSCU6639870-141-001", "MSCU6639870-141-002");
|
||||
.containsExactly("D0002-141-001", "D0002-141-002");
|
||||
assertThat(requests.get(1).labels()).singleElement().satisfies(label -> {
|
||||
assertThat(label.fbaWarehouseCode()).isEqualTo("SCK8");
|
||||
assertThat(label.palletCode()).isEqualTo("MSCU6639870-141-001");
|
||||
assertThat(label.palletCode()).isEqualTo("D0002-141-001");
|
||||
});
|
||||
org.mockito.Mockito.verify(print, org.mockito.Mockito.never()).generateInboundMarks(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesStableIndexesPerContainerAndFbaAndSkipsDropship() {
|
||||
void generatesStableIndexesPerCabinetAndFbaAndSkipsDropship() {
|
||||
LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class);
|
||||
LoadingPalletLabelService service = new LoadingPalletLabelService();
|
||||
ReflectionTestUtils.setField(service, "mapper", mapper);
|
||||
@@ -72,13 +74,14 @@ class LoadingPalletLabelServiceTest {
|
||||
assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseInternalCode())
|
||||
.containsExactly("154", "CADF");
|
||||
assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode)
|
||||
.containsExactly("MSCU6639870-154-001", "MSCU6639870-CADF-001",
|
||||
"MSCU6639870-CADF-002", "MSCU6639870-CADF-003");
|
||||
.containsExactly("D0002-154-001", "D0002-CADF-001",
|
||||
"D0002-CADF-002", "D0002-CADF-003");
|
||||
}
|
||||
|
||||
private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) {
|
||||
var row = new LoadingPalletLabelMapper.SourceRow(); row.setPlanId(10L); row.setPlanNo("LP-1");
|
||||
row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L);
|
||||
row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setCabinetNo("D0002");
|
||||
row.setWarehouseId(20L);
|
||||
row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000);
|
||||
row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination);
|
||||
row.setDestinationWarehouseId("LAX9".equals(destination) ? 101L : 102L);
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ class OrderDeclarationSyncServiceTest {
|
||||
}
|
||||
|
||||
@MockBean private OrderDeclarationSyncMapper mapper;
|
||||
@MockBean private OrderRemarksSyncService remarksSync;
|
||||
@Resource private OrderDeclarationSyncService service;
|
||||
@Resource private ObjectMapper json;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.OrderRemarksSync;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(OrderRemarksSyncService.class)
|
||||
class OrderRemarksSyncServiceTest {
|
||||
@MockBean private LoadingPlanItemMapper items;
|
||||
@MockBean private LoadingPlanMapper plans;
|
||||
|
||||
@jakarta.annotation.Resource private OrderRemarksSyncService service;
|
||||
|
||||
@Test
|
||||
void updatesAllMutableOrderItemsAndTouchesAffectedPlans() {
|
||||
var command = new OrderRemarksSync.Command(10L, 20L, "客户", "内部", "仓库", "客服", 30L, "操作人");
|
||||
when(items.findMutablePlanIdsWithDifferentRemarks(
|
||||
10L, 20L, "客户", "内部", "仓库", "客服", null)).thenReturn(List.of(101L, 102L));
|
||||
when(items.syncMutableOrderRemarks(
|
||||
10L, 20L, "客户", "内部", "仓库", "客服", 30L, "操作人", null)).thenReturn(3);
|
||||
|
||||
assertThat(service.sync(command)).isEqualTo(3);
|
||||
|
||||
verify(plans).touchForOrderRemarks(List.of(101L, 102L), 30L, "操作人");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesFrozenOrAlreadyMatchingPlansUntouched() {
|
||||
var command = new OrderRemarksSync.Command(10L, 20L, null, null, null, null, 30L, null);
|
||||
when(items.findMutablePlanIdsWithDifferentRemarks(
|
||||
10L, 20L, null, null, null, null, null)).thenReturn(List.of());
|
||||
|
||||
assertThat(service.sync(command)).isZero();
|
||||
|
||||
verify(items, never()).syncMutableOrderRemarks(
|
||||
10L, 20L, null, null, null, null, 30L, null, null);
|
||||
verify(plans, never()).touchForOrderRemarks(org.mockito.ArgumentMatchers.anyList(),
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.nullable(String.class));
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ class SmartLoadingServiceTest {
|
||||
@MockBean private PriceBookQueryUseCase priceBooks;
|
||||
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@MockBean private DropshipPalletizationMapper dropshipPallets;
|
||||
@MockBean private OrderRemarksSyncService orderRemarksSync;
|
||||
@Resource private SmartLoadingService service;
|
||||
|
||||
@TestConfiguration
|
||||
@@ -119,6 +120,12 @@ class SmartLoadingServiceTest {
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
|
||||
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
|
||||
new BigDecimal("10"), new BigDecimal("2"))));
|
||||
when(cargoClient.updateRemarks(anyString(), anyLong(), anyLong(), any()))
|
||||
.thenAnswer(invocation -> {
|
||||
OrderCargoClient.RemarksUpdateRequest request = invocation.getArgument(3);
|
||||
return ApiResponse.success(new OrderCargoClient.RemarksUpdateResponse(2, null,
|
||||
request.internalRemark(), request.warehouseRemark(), request.customerServiceRemark()));
|
||||
});
|
||||
when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString()))
|
||||
.thenReturn(new FbaPricingService.ContainerFeeRecalculationResult(
|
||||
FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0));
|
||||
@@ -1955,6 +1962,7 @@ class SmartLoadingServiceTest {
|
||||
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
|
||||
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
|
||||
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
|
||||
value.setOrderId(30L); value.setOrderNo("O30");
|
||||
value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes);
|
||||
value.setVersion(0); value.setDeleted(false);
|
||||
return value;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| 安全配置项 | `config/GatewaySecurityProperties.java` |
|
||||
| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 |
|
||||
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
|
||||
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 显式覆盖商户仓库与平台 `warehouse-admin` 路径 |
|
||||
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 仅显式覆盖平台仓库资源路径 |
|
||||
| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg |
|
||||
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ spring:
|
||||
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"]
|
||||
- id: warehouse-overseas-service
|
||||
uri: lb://warehouse-overseas-service
|
||||
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
|
||||
predicates: ["Path=/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
|
||||
- id: file-service
|
||||
uri: lb://file-service
|
||||
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
|
||||
|
||||
+10
-3
@@ -52,9 +52,16 @@ public class NotificationRecipientResolver {
|
||||
}
|
||||
|
||||
private List<IamMembership> byRole(CreateNotificationRequest request) {
|
||||
var role = roles.selectOne(new LambdaQueryWrapper<com.baiyue.iam.entity.IamRole>()
|
||||
.eq(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId())
|
||||
.eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()).eq(com.baiyue.iam.entity.IamRole::getStatus,"ACTIVE").last("LIMIT 1"));
|
||||
String appCode = request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.TENANT_ROLE
|
||||
? "MERCHANT" : "PLATFORM";
|
||||
// Merchant members can be assigned tenant-specific roles or platform-provided merchant roles (tenant 0).
|
||||
var candidates = roles.selectList(new LambdaQueryWrapper<com.baiyue.iam.entity.IamRole>()
|
||||
.in(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId(), 0L)
|
||||
.eq(com.baiyue.iam.entity.IamRole::getAppCode, appCode)
|
||||
.eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode())
|
||||
.eq(com.baiyue.iam.entity.IamRole::getStatus, "ACTIVE")
|
||||
.orderByDesc(com.baiyue.iam.entity.IamRole::getTenantId));
|
||||
var role = candidates.isEmpty() ? null : candidates.get(0);
|
||||
if (role == null) fail("角色编码不存在,请检查角色编码是否正确");
|
||||
List<Long> ids = membershipRoles.selectList(new LambdaQueryWrapper<com.baiyue.iam.entity.IamMembershipRole>()
|
||||
.eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId())
|
||||
|
||||
+9
-3
@@ -12,7 +12,9 @@ import java.util.regex.Pattern;
|
||||
|
||||
@Component
|
||||
public class NotificationTemplateRenderer {
|
||||
private static final Pattern VARIABLE = Pattern.compile("\\{\\{([a-z][a-z0-9_]{0,31})}}" );
|
||||
// ${name} is the format inserted by the template editor; retain {{name}} for existing templates.
|
||||
private static final Pattern VARIABLE = Pattern.compile(
|
||||
"\\$\\{([a-z][a-z0-9_]{0,31})}|\\{\\{([a-z][a-z0-9_]{0,31})}}" );
|
||||
|
||||
public String render(String template, Set<String> whitelist, Map<String, String> variables) {
|
||||
Set<String> used = placeholders(template);
|
||||
@@ -20,7 +22,11 @@ public class NotificationTemplateRenderer {
|
||||
if (!whitelist.containsAll(variables.keySet())) fail("NOTIFICATION_VARIABLE_NOT_ALLOWED");
|
||||
if (!variables.keySet().containsAll(used)) fail("NOTIFICATION_VARIABLE_MISSING");
|
||||
String rendered = template;
|
||||
for (String key : used) rendered = rendered.replace("{{" + key + "}}", variables.get(key));
|
||||
for (String key : used) {
|
||||
String value = variables.get(key);
|
||||
rendered = rendered.replace("${" + key + "}", value)
|
||||
.replace("{{" + key + "}}", value);
|
||||
}
|
||||
if (rendered.length() > 10_000) fail("NOTIFICATION_TEMPLATE_RENDER_TOO_LONG");
|
||||
return rendered;
|
||||
}
|
||||
@@ -33,7 +39,7 @@ public class NotificationTemplateRenderer {
|
||||
|
||||
private Set<String> placeholders(String value) {
|
||||
Set<String> keys = new HashSet<>(); Matcher matcher = VARIABLE.matcher(value);
|
||||
while (matcher.find()) keys.add(matcher.group(1));
|
||||
while (matcher.find()) keys.add(matcher.group(1) != null ? matcher.group(1) : matcher.group(2));
|
||||
return keys;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,8 +139,8 @@ public class ProfileResourceService {
|
||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
|
||||
}
|
||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
if (!platformCanUpdateQualifications(tenant)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善、已驳回或已审核客户可上传资质");
|
||||
}
|
||||
requireOptionalQualificationType(request.qualificationType());
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
@@ -163,6 +163,7 @@ public class ProfileResourceService {
|
||||
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
|
||||
certificateNo, request.documentUrl(), request.originalFileName());
|
||||
qualification.setValidTo(request.validTo());
|
||||
markPlatformQualificationApproved(tenant, qualification);
|
||||
repository.saveQualification(qualification);
|
||||
return new PlatformQualificationRegistrationResponse(
|
||||
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
@@ -173,7 +174,7 @@ public class ProfileResourceService {
|
||||
resourceId = "#request.tenantId", description = "上传企业必填资质包", captureAfter = false)
|
||||
public RequiredQualificationPackageResponse submitRequiredQualificationPackage(
|
||||
RequiredQualificationPackageRequest request) {
|
||||
return registerRequiredQualificationPackage(request, "商户在送审前替换文件");
|
||||
return registerRequiredQualificationPackage(request, "商户在送审前替换文件", false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -181,16 +182,17 @@ public class ProfileResourceService {
|
||||
resourceId = "#request.tenantId", description = "平台代客户上传必填资质包", captureAfter = false)
|
||||
public RequiredQualificationPackageResponse registerPlatformRequiredQualificationPackage(
|
||||
RequiredQualificationPackageRequest request) {
|
||||
return registerRequiredQualificationPackage(request, "平台在送审前替换文件");
|
||||
return registerRequiredQualificationPackage(request, "平台在送审前替换文件", true);
|
||||
}
|
||||
|
||||
private RequiredQualificationPackageResponse registerRequiredQualificationPackage(
|
||||
RequiredQualificationPackageRequest request, String replacementComment) {
|
||||
RequiredQualificationPackageRequest request, String replacementComment, boolean platformManaged) {
|
||||
IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId());
|
||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在");
|
||||
}
|
||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||
if (platformManaged ? !platformCanUpdateQualifications(tenant)
|
||||
: (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus()))) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
@@ -212,12 +214,14 @@ public class ProfileResourceService {
|
||||
IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE",
|
||||
creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName());
|
||||
businessLicense.setValidTo(request.businessLicenseValidTo());
|
||||
if (platformManaged) markPlatformQualificationApproved(tenant, businessLicense);
|
||||
repository.saveQualification(businessLicense);
|
||||
IamTenantQualification legalPersonId = null;
|
||||
if (hasLegalPersonId) {
|
||||
legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
|
||||
null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName());
|
||||
legalPersonId.setValidTo(request.legalPersonIdValidTo());
|
||||
if (platformManaged) markPlatformQualificationApproved(tenant, legalPersonId);
|
||||
repository.saveQualification(legalPersonId);
|
||||
}
|
||||
if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
@@ -314,6 +318,17 @@ public class ProfileResourceService {
|
||||
&& !"SUPERSEDED".equals(qualification.getStatus());
|
||||
}
|
||||
|
||||
private boolean platformCanUpdateQualifications(IamTenant tenant) {
|
||||
return "PROFILE_REQUIRED".equals(tenant.getStatus()) || "REJECTED".equals(tenant.getStatus())
|
||||
|| "ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus());
|
||||
}
|
||||
|
||||
private void markPlatformQualificationApproved(IamTenant tenant, IamTenantQualification qualification) {
|
||||
if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) {
|
||||
qualification.setStatus("APPROVED");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireMerchant(long tenantId) {
|
||||
IamTenant tenant = tenantRepository.findTenant(tenantId);
|
||||
if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId
|
||||
|
||||
@@ -78,11 +78,30 @@ VALUES
|
||||
(5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0),
|
||||
(5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0),
|
||||
(5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0),
|
||||
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement','海外采购','/cross-border/procurement',NULL,70,'ACTIVE',1,0),
|
||||
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking',NULL,80,'ACTIVE',1,0),
|
||||
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping',NULL,90,'ACTIVE',1,0),
|
||||
(5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,0),
|
||||
(5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,0),
|
||||
(5306111,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:create','创建美国本土卡订单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306112,0,'MERCHANT','BUTTON',5306110,'merchant:cross-border:domestic-trucking:edit','编辑美国本土卡订单',NULL,'PATCH',20,'ACTIVE',1,0),
|
||||
(5306120,0,'MERCHANT','MENU',0,'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping','GET',90,'ACTIVE',1,0),
|
||||
(5306121,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:create','创建美本土散板订单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306122,0,'MERCHANT','BUTTON',5306120,'merchant:cross-border:pallet-shipping:edit','编辑美本土散板订单',NULL,'PUT',20,'ACTIVE',1,0),
|
||||
(5306130,0,'MERCHANT','MENU',0,'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound',NULL,50,'ACTIVE',1,0),
|
||||
(5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0)
|
||||
(5306140,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory','GET',95,'ACTIVE',1,0),
|
||||
(5306150,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',100,'ACTIVE',1,0),
|
||||
(5306160,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:relabel:list','贴标申请','/overseas-warehouse/labeling','GET',110,'ACTIVE',1,0),
|
||||
(5306161,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:create','创建贴标申请',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306162,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:files','管理贴标文件',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5306163,0,'MERCHANT','BUTTON',5306160,'merchant:overseas-warehouse:relabel:confirm','确认贴标完成',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5306170,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:transfer:list','仓储转运','/overseas-warehouse/storage-transfer','GET',120,'ACTIVE',1,0),
|
||||
(5306171,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:create','创建仓储转运单',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306172,0,'MERCHANT','BUTTON',5306170,'merchant:overseas-warehouse:transfer:confirm','确认仓储转运实测',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0),
|
||||
(5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
|
||||
(5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
@@ -225,11 +244,30 @@ SELECT 0, role.id, permission.id, NOW(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='MERCHANT'
|
||||
AND permission.permission_code IN (
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:cross-border:procurement:list',
|
||||
'merchant:cross-border:procurement:create',
|
||||
'merchant:cross-border:domestic-trucking:list',
|
||||
'merchant:cross-border:domestic-trucking:create',
|
||||
'merchant:cross-border:domestic-trucking:edit',
|
||||
'merchant:cross-border:pallet-shipping:list',
|
||||
'merchant:cross-border:pallet-shipping:create',
|
||||
'merchant:cross-border:pallet-shipping:edit',
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list'
|
||||
'merchant:overseas-warehouse:return-inventory:list',
|
||||
'merchant:overseas-warehouse:last-mile-order:list',
|
||||
'merchant:overseas-warehouse:relabel:list',
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
'merchant:overseas-warehouse:relabel:files',
|
||||
'merchant:overseas-warehouse:relabel:confirm',
|
||||
'merchant:overseas-warehouse:transfer:list',
|
||||
'merchant:overseas-warehouse:transfer:create',
|
||||
'merchant:overseas-warehouse:transfer:confirm',
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'merchant:overseas-warehouse:return-order:create',
|
||||
'merchant:overseas-warehouse:return-order:relabel'
|
||||
)
|
||||
WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
@@ -241,11 +279,30 @@ BEGIN
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE tenant_id=0 AND app_code='MERCHANT' AND status='ACTIVE' AND deleted=0
|
||||
AND permission_code IN (
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:cross-border:procurement:list',
|
||||
'merchant:cross-border:procurement:create',
|
||||
'merchant:cross-border:domestic-trucking:list',
|
||||
'merchant:cross-border:domestic-trucking:create',
|
||||
'merchant:cross-border:domestic-trucking:edit',
|
||||
'merchant:cross-border:pallet-shipping:list',
|
||||
'merchant:cross-border:pallet-shipping:create',
|
||||
'merchant:cross-border:pallet-shipping:edit',
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN
|
||||
'merchant:overseas-warehouse:return-inventory:list',
|
||||
'merchant:overseas-warehouse:last-mile-order:list',
|
||||
'merchant:overseas-warehouse:relabel:list',
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
'merchant:overseas-warehouse:relabel:files',
|
||||
'merchant:overseas-warehouse:relabel:confirm',
|
||||
'merchant:overseas-warehouse:transfer:list',
|
||||
'merchant:overseas-warehouse:transfer:create',
|
||||
'merchant:overseas-warehouse:transfer:confirm',
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'merchant:overseas-warehouse:return-order:create',
|
||||
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:权限资源不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -256,11 +313,30 @@ BEGIN
|
||||
AND permission.tenant_id=0 AND permission.app_code='MERCHANT'
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
'merchant:cross-border:procurement:list',
|
||||
'merchant:cross-border:procurement:create',
|
||||
'merchant:cross-border:domestic-trucking:list',
|
||||
'merchant:cross-border:domestic-trucking:create',
|
||||
'merchant:cross-border:domestic-trucking:edit',
|
||||
'merchant:cross-border:pallet-shipping:list',
|
||||
'merchant:cross-border:pallet-shipping:create',
|
||||
'merchant:cross-border:pallet-shipping:edit',
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:overseas-warehouse:return-inventory:list')) <> 5 THEN
|
||||
'merchant:overseas-warehouse:return-inventory:list',
|
||||
'merchant:overseas-warehouse:last-mile-order:list',
|
||||
'merchant:overseas-warehouse:relabel:list',
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
'merchant:overseas-warehouse:relabel:files',
|
||||
'merchant:overseas-warehouse:relabel:confirm',
|
||||
'merchant:overseas-warehouse:transfer:list',
|
||||
'merchant:overseas-warehouse:transfer:create',
|
||||
'merchant:overseas-warehouse:transfer:confirm',
|
||||
'merchant:overseas-warehouse:discard:list',
|
||||
'merchant:overseas-warehouse:discard:create',
|
||||
'merchant:overseas-warehouse:discard:cancel',
|
||||
'merchant:overseas-warehouse:return-order:list',
|
||||
'merchant:overseas-warehouse:return-order:create',
|
||||
'merchant:overseas-warehouse:return-order:relabel')) <> 24 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外客户权限初始化失败:客户默认管理员授权不完整';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -342,7 +342,7 @@ paths:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板和海外入库订单分别返回 `merchant:cross-border:procurement`、`merchant:cross-border:domestic-trucking`、`merchant:cross-border:pallet-shipping`、`merchant:warehouse:inbound:list`;客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建权限独立授权。
|
||||
description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前商户成员权限查询成功
|
||||
|
||||
@@ -46,9 +46,9 @@ class IamOpenApiContractTest {
|
||||
.contains("不以 `container:list/edit` 兼容放行国外操作")
|
||||
.contains("merchant:warehouse:delivery:list")
|
||||
.contains("merchant:warehouse:delivery:create")
|
||||
.contains("merchant:cross-border:procurement")
|
||||
.contains("merchant:cross-border:domestic-trucking")
|
||||
.contains("merchant:cross-border:pallet-shipping")
|
||||
.contains("merchant:cross-border:procurement:list")
|
||||
.contains("merchant:cross-border:domestic-trucking:list")
|
||||
.contains("merchant:cross-border:pallet-shipping:list")
|
||||
.contains("merchant:warehouse:inbound:list")
|
||||
.contains("lastleg:pda:operate")
|
||||
.contains("OVERSEAS_WAREHOUSE_PDA_OPERATOR")
|
||||
|
||||
+37
-3
@@ -2,6 +2,7 @@ package com.baiyue.iam.notification;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamMembershipRole;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamMembershipMapper;
|
||||
@@ -22,7 +23,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.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NotificationRecipientResolverTest {
|
||||
private final IamMembershipMapper memberships = mock(IamMembershipMapper.class);
|
||||
@@ -70,7 +71,7 @@ class NotificationRecipientResolverTest {
|
||||
|
||||
@Test
|
||||
void explainsWhenRoleCodeDoesNotExist() {
|
||||
when(roles.selectOne(any())).thenReturn(null);
|
||||
when(roles.selectList(any())).thenReturn(List.of());
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "MISSING_ROLE")))
|
||||
.isInstanceOf(BizException.class)
|
||||
@@ -81,7 +82,7 @@ class NotificationRecipientResolverTest {
|
||||
void explainsWhenRoleHasNoActiveMembers() {
|
||||
IamRole role = new IamRole();
|
||||
role.setId(501L);
|
||||
when(roles.selectOne(any())).thenReturn(role);
|
||||
when(roles.selectList(any())).thenReturn(List.of(role));
|
||||
when(membershipRoles.selectList(any())).thenReturn(List.of());
|
||||
|
||||
assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "CUSTOMER_REVIEWER")))
|
||||
@@ -89,6 +90,39 @@ class NotificationRecipientResolverTest {
|
||||
.hasMessage("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesEnterpriseMemberAssignedToPlatformProvidedMerchantRole() {
|
||||
IamRole role = new IamRole();
|
||||
role.setId(501L);
|
||||
role.setTenantId(0L);
|
||||
role.setAppCode("MERCHANT");
|
||||
role.setRoleCode("MERCHANT_OWNER");
|
||||
IamMembershipRole assignment = new IamMembershipRole();
|
||||
assignment.setTenantId(9L);
|
||||
assignment.setRoleId(role.getId());
|
||||
assignment.setMembershipId(301L);
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(301L);
|
||||
membership.setTenantId(9L);
|
||||
membership.setUserId(401L);
|
||||
membership.setStatus("ACTIVE");
|
||||
IamUser user = new IamUser();
|
||||
user.setId(401L);
|
||||
user.setDisplayName("企业管理员");
|
||||
user.setStatus("ACTIVE");
|
||||
when(roles.selectList(any())).thenReturn(List.of(role));
|
||||
when(membershipRoles.selectList(any())).thenReturn(List.of(assignment));
|
||||
when(memberships.selectBatchIds(List.of(301L))).thenReturn(List.of(membership));
|
||||
when(users.selectById(401L)).thenReturn(user);
|
||||
|
||||
var recipients = resolver.resolve(new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED",
|
||||
RecipientType.TENANT_ROLE, null, "MERCHANT_OWNER", Map.of(), null,
|
||||
"notification-tenant-role-key", null, null));
|
||||
|
||||
assertThat(recipients).containsExactly(new NotificationRecipientResolver.ResolvedRecipient(
|
||||
9L, 301L, 401L, "企业管理员", "MERCHANT"));
|
||||
}
|
||||
|
||||
private CreateNotificationRequest request(RecipientType recipientType, List<Long> recipientIds) {
|
||||
return new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", recipientType, recipientIds,
|
||||
null, Map.of(), null, "notification-test-key", null, null);
|
||||
|
||||
@@ -7,6 +7,6 @@ import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
class NotificationTemplateRendererTest {
|
||||
private final NotificationTemplateRenderer renderer=new NotificationTemplateRenderer();
|
||||
@Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order {{order_no}} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");}
|
||||
@Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("{{order_no}}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");}
|
||||
@Test void rendersOnlyWhitelistedVariables(){assertThat(renderer.render("Order ${order_no} is {{status}}",Set.of("order_no","status"),Map.of("order_no","A1","status","ready"))).isEqualTo("Order A1 is ready");}
|
||||
@Test void rejectsMissingAndUnknownVariables(){assertThatThrownBy(()->renderer.render("${order_no}",Set.of("order_no"),Map.of())).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_MISSING");assertThatThrownBy(()->renderer.render("ok",Set.of(),Map.of("secret","x"))).isInstanceOf(BizException.class).hasMessageContaining("NOTIFICATION_VARIABLE_NOT_ALLOWED");}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ class MerchantReturnInventoryPermissionSqlContractTest {
|
||||
assertThat(sql).contains(
|
||||
"(5306140,0,'MERCHANT','MENU',0,'" + PERMISSION + "','退货库存'",
|
||||
"role.role_code='MERCHANT_OWNER'",
|
||||
"'" + PERMISSION + "')) <> 5",
|
||||
")) <> 24 THEN",
|
||||
"客户默认管理员授权不完整");
|
||||
|
||||
int grantStart = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
|
||||
|
||||
+31
-13
@@ -15,30 +15,48 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class OverseasCustomerPermissionSqlContractTest {
|
||||
private static final Pattern MERCHANT_CODE = Pattern.compile("merchant:[a-z0-9:-]+");
|
||||
private static final Set<String> CUSTOMER_OVERSEAS_PERMISSIONS = Set.of(
|
||||
"merchant:cross-border:procurement",
|
||||
"merchant:cross-border:domestic-trucking",
|
||||
"merchant:cross-border:pallet-shipping",
|
||||
"merchant:cross-border:procurement:list",
|
||||
"merchant:cross-border:procurement:create",
|
||||
"merchant:cross-border:domestic-trucking:list",
|
||||
"merchant:cross-border:domestic-trucking:create",
|
||||
"merchant:cross-border:domestic-trucking:edit",
|
||||
"merchant:cross-border:pallet-shipping:list",
|
||||
"merchant:cross-border:pallet-shipping:create",
|
||||
"merchant:cross-border:pallet-shipping:edit",
|
||||
"merchant:warehouse:inbound:list",
|
||||
"merchant:overseas-warehouse:return-inventory:list");
|
||||
"merchant:overseas-warehouse:return-inventory:list",
|
||||
"merchant:overseas-warehouse:last-mile-order:list",
|
||||
"merchant:overseas-warehouse:relabel:list",
|
||||
"merchant:overseas-warehouse:relabel:create",
|
||||
"merchant:overseas-warehouse:relabel:files",
|
||||
"merchant:overseas-warehouse:relabel:confirm",
|
||||
"merchant:overseas-warehouse:transfer:list",
|
||||
"merchant:overseas-warehouse:transfer:create",
|
||||
"merchant:overseas-warehouse:transfer:confirm",
|
||||
"merchant:overseas-warehouse:discard:list",
|
||||
"merchant:overseas-warehouse:discard:create",
|
||||
"merchant:overseas-warehouse:discard:cancel",
|
||||
"merchant:overseas-warehouse:return-order:list",
|
||||
"merchant:overseas-warehouse:return-order:create",
|
||||
"merchant:overseas-warehouse:return-order:relabel");
|
||||
|
||||
@Test
|
||||
void declaresTheFrozenCustomerOverseasPermissionCatalog() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
assertThat(merchantCodes(sql)).containsExactlyInAnyOrderElementsOf(CUSTOMER_OVERSEAS_PERMISSIONS);
|
||||
for (String permission : CUSTOMER_OVERSEAS_PERMISSIONS) {
|
||||
assertThat(sql).contains("0,'MERCHANT','MENU'", "'" + permission + "'");
|
||||
}
|
||||
assertThat(sql).contains(
|
||||
"'merchant:cross-border:procurement','海外采购','/cross-border/procurement'",
|
||||
"'merchant:cross-border:domestic-trucking','美国本土卡','/cross-border/domestic-trucking'",
|
||||
"'merchant:cross-border:pallet-shipping','美本土散板','/cross-border/pallet-shipping'",
|
||||
"'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement'",
|
||||
"'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking'",
|
||||
"'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping'",
|
||||
"'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'",
|
||||
"'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'");
|
||||
"'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'",
|
||||
"'merchant:overseas-warehouse:relabel:files','管理贴标文件'",
|
||||
"'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsAllFivePermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
|
||||
void grantsAllPermissionsOnlyToTheDefaultCustomerAdministrator() throws Exception {
|
||||
String sql = sql();
|
||||
int comment = sql.indexOf("-- 客户默认管理员获得当前客户海外业务入口");
|
||||
int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission", comment);
|
||||
@@ -66,7 +84,7 @@ class OverseasCustomerPermissionSqlContractTest {
|
||||
"海外客户权限初始化失败:客户默认管理员授权不完整",
|
||||
"permission.app_code='MERCHANT'",
|
||||
"role.role_code='MERCHANT_OWNER'",
|
||||
")) <> 5 THEN");
|
||||
")) <> 24 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -95,18 +95,20 @@ class AuthorizationSnapshotServiceTest {
|
||||
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
|
||||
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
|
||||
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
|
||||
"merchant:cross-border:procurement",
|
||||
"merchant:cross-border:domestic-trucking",
|
||||
"merchant:cross-border:pallet-shipping",
|
||||
"merchant:cross-border:procurement:list",
|
||||
"merchant:cross-border:procurement:create",
|
||||
"merchant:cross-border:domestic-trucking:list",
|
||||
"merchant:cross-border:pallet-shipping:list",
|
||||
"merchant:warehouse:inbound:list"));
|
||||
|
||||
var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
|
||||
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
|
||||
|
||||
assertThat(result).containsExactlyInAnyOrder(
|
||||
"merchant:cross-border:procurement",
|
||||
"merchant:cross-border:domestic-trucking",
|
||||
"merchant:cross-border:pallet-shipping",
|
||||
"merchant:cross-border:procurement:list",
|
||||
"merchant:cross-border:procurement:create",
|
||||
"merchant:cross-border:domestic-trucking:list",
|
||||
"merchant:cross-border:pallet-shipping:list",
|
||||
"merchant:warehouse:inbound:list");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamTenantQualification;
|
||||
import com.baiyue.iam.repository.ProfileResourceRepository;
|
||||
@@ -69,4 +70,51 @@ class PlatformQualificationRegistrationTest {
|
||||
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
|
||||
assertEquals("PENDING", captor.getAllValues().get(1).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanReplaceAnApprovedOptionalQualificationForAnActiveTenant() {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("ACTIVE");
|
||||
IamTenantQualification existing = new IamTenantQualification();
|
||||
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BANK_ACCOUNT_LICENSE");
|
||||
existing.setStatus("APPROVED");
|
||||
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
|
||||
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
|
||||
|
||||
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
|
||||
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf",
|
||||
"new-file.pdf", LocalDate.of(2031, 12, 31), true));
|
||||
|
||||
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
|
||||
verify(repository, times(2)).saveQualification(captor.capture());
|
||||
assertEquals("SUPERSEDED", existing.getStatus());
|
||||
assertEquals("APPROVED", captor.getAllValues().get(1).getStatus());
|
||||
assertEquals("ACTIVE", response.tenantStatus());
|
||||
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanReplaceAnApprovedBusinessLicenseForAnActiveTenant() {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("ACTIVE");
|
||||
tenant.setUnifiedSocialCreditCode("91310101ABCDE12345");
|
||||
IamTenantQualification existing = new IamTenantQualification();
|
||||
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BUSINESS_LICENSE");
|
||||
existing.setStatus("APPROVED");
|
||||
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-license.pdf");
|
||||
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
|
||||
|
||||
var response = service.registerPlatformRequiredQualificationPackage(new RequiredQualificationPackageRequest(
|
||||
10001L, "91310101ABCDE12345", "/api/merchant/v1/files/qualifications/new-license.pdf",
|
||||
"new-license.pdf", LocalDate.of(2031, 12, 31), true, null, null, null, null));
|
||||
|
||||
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
|
||||
verify(repository, times(2)).saveQualification(captor.capture());
|
||||
assertEquals("SUPERSEDED", existing.getStatus());
|
||||
assertEquals("APPROVED", captor.getAllValues().get(1).getStatus());
|
||||
assertEquals("ACTIVE", response.tenantStatus());
|
||||
assertEquals(existing.getDocumentUrl(), response.replacedBusinessLicenseDocumentUrl());
|
||||
}
|
||||
}
|
||||
|
||||
+56
-54
@@ -9,68 +9,58 @@
|
||||
|
||||
## 快速索引
|
||||
|
||||
退货预报、签收、库存、合箱与退货出库:
|
||||
本表只保留定位入口;命中业务后必须用 `rg -n -F '关键词' docs/business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。
|
||||
|
||||
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
|
||||
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
|
||||
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
|
||||
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
|
||||
- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
|
||||
退货预报、签收、库存、合箱与退货出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程来源托盘快照:`inboundplan/`。这两类入口的补充段落也在详细索引中,按对应短词查询。
|
||||
|
||||
人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。
|
||||
|
||||
柜级预览同时返回 `sourcePalletCount/supplementPalletCount/targetPalletCount`:头程冻结托盘码原样保留,海外仓最终托数较多时按同柜同 FBA 编码序列补足差额,不因头尾装托密度不同阻断;页面必须分别展示头程已有、海外补充和最终托数,不能把最终总数误称为“本次新增”。
|
||||
|
||||
头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
|
||||
|
||||
| 功能 | 入口 |
|
||||
| 功能 | 首个入口 |
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
|
||||
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java` |
|
||||
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java` |
|
||||
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java` |
|
||||
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
|
||||
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| IAM 海外安全投影与实时校验 | `security/controller/LastlegSecurityController.java` |
|
||||
| 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
|
||||
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
|
||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
|
||||
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 |
|
||||
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
|
||||
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
|
||||
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` |
|
||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口要求 `merchant:cross-border:pallet-shipping`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` |
|
||||
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java` |
|
||||
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java` |
|
||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java` |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/` |
|
||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/` |
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java` |
|
||||
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java` |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java` |
|
||||
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java` |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` |
|
||||
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java` |
|
||||
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` |
|
||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
|
||||
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
|
||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 |
|
||||
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
|
||||
|
||||
## 海外仓全流程开发路由
|
||||
|
||||
@@ -132,6 +122,18 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。
|
||||
|
||||
- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。
|
||||
|
||||
- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。
|
||||
|
||||
- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。
|
||||
|
||||
- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。
|
||||
|
||||
- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。
|
||||
|
||||
- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。
|
||||
|
||||
- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Lastleg 业务详细索引
|
||||
|
||||
本文件是同服务 `AGENTS.md` 的按需补充;命中业务后读取相应行/段,不默认全文加载。路径保持以 `lastleg-service/` 为根;Java 简写包路径从 `src/main/java/com/baiyue/lastleg/` 起算,不相对于本文件的 `docs/` 目录。
|
||||
|
||||
## 快速索引
|
||||
|
||||
退货预报、签收、库存、合箱与退货出库:
|
||||
|
||||
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
|
||||
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
|
||||
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
|
||||
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
|
||||
- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
|
||||
|
||||
人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。
|
||||
|
||||
柜级预览同时返回 `sourcePalletCount/supplementPalletCount/targetPalletCount`:头程冻结托盘码原样保留,海外仓最终托数较多时按同柜同 FBA 编码序列补足差额,不因头尾装托密度不同阻断;页面必须分别展示头程已有、海外补充和最终托数,不能把最终总数误称为“本次新增”。
|
||||
|
||||
头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
||||
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
|
||||
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
|
||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
|
||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
|
||||
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
|
||||
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
|
||||
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
|
||||
| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 拆柜打托成本与客户应收分摊 | `inboundplan/service/DestuffingPalletCostJob.java`、`DestuffingPalletCostService.java`;仅全柜所有 PDA 入仓任务完成后执行,按实物托盘单价汇总并按客户实收 CBM 分摊,尾差落最后一位客户;每份分摊发布 `OVERSEAS_CONTAINER_FEE/DESTUFF_PALLET` 应收费用来源,不直接改国内钱包 |
|
||||
| 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 |
|
||||
| 派送完成回传 | `delivery/service/DeliveryCompletedOutboxService.java`;签收资料保存并自动完成时在业务事务内按关联 `sourceInboundId` 分别写 `OVERSEAS_DELIVERY_COMPLETED` Outbox,同车不同 FBA 派送单互不代完成;不保留人工完成入口 |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,排车和出库使用独立 PDA Controller |
|
||||
| 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/`、`relabel/pda/`;一条 `ll_relabel_item` 对应一个客户填写的我方箱唛任务,`CARTON_LABEL`、`SKU_LABEL`、`CARTON_AND_SKU` 分别保存箱唛、SKU 或组合需求,SKU 子项 JSON 数量按件;涉及箱唛换标时整张申请只能有一个 FBA 目的仓,服务端拒绝明细目的仓不一致的请求。普通尾程入库没有箱唛到实物托盘的精确快照,历史入仓库存仓位只能作为冻结记账锚点和现场大概位置,禁止把该仓位解释为箱唛已绑定具体托盘。库存只按箱任务冻结一次。涉及箱唛换标时申请必须选择 `TRUCK/EXPRESS/TRUCK_PICKUP/EXPRESS_PICKUP`,两种快递方式可要求打印并上传面单;PDA 扫我方箱唛完成逐箱事实和照片,仓库复核后等待客户查看照片确认。对外换标状态只投影为 `PENDING_PROCESSING/COMPLETED`(待处理/换标完成),`PROCESSING`、`CUSTOMER_CONFIRM_PENDING`、`READY_FOR_OUTBOUND` 只属于内部作业节点且不得直接出现在业务列表;客户确认后仅卡车派送进入 `WAIT_PRE_DISPATCH`,快递派送和两种自提直接创建换标出库单。客户确认前不得进入出库 |
|
||||
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java`、`pda/warehouse/service/PdaWarehouseService.java`;解析库存、库位和托盘码并查询操作历史,固定要求 `lastleg:pda:operate` 与 `ROLE_WAREHOUSE_PICKER`,仓库范围只能取自 PDA Token |
|
||||
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;按 `containerBookingId` 柜级接收,信封使用 `tenantId=0` 且门禁不存客户;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
|
||||
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,路线由当前已排托盘的 FBA 仓编码去重后按稳定顺序以英文逗号连接,预排与普通编辑请求均不接收路线;查询、预排与执行边界见“海外仓全流程开发路由” |
|
||||
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 |
|
||||
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
|
||||
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
|
||||
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
|
||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
|
||||
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
|
||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||
| 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 |
|
||||
+2
-1
@@ -179,7 +179,8 @@ public class LastlegOrderController {
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/hold")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "暂扣或解除单票尾程订单",
|
||||
description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
description = "要求版本与幂等键;新增暂扣只允许完成入库且尚未进入出库流程的订单,"
|
||||
+ "暂扣只作用于该票并仅阻断排车和出库。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<LastlegOrderCommandDtos.HoldResponse> hold(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
|
||||
+3
@@ -7,9 +7,11 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -19,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/orders")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST + "')")
|
||||
public class MerchantLastlegOrderController {
|
||||
@Resource private LastlegOrderService orderService;
|
||||
|
||||
|
||||
+21
@@ -70,6 +70,27 @@ public class DeliveryTransferController {
|
||||
return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders/warehouse-transfer/candidates")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "List warehouse-transfer pallets that can create an outbound order")
|
||||
public ApiResponse<java.util.List<DeliveryResponses.WarehouseTransferOutboundCandidate>> warehouseTransferCandidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String warehouseId) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.warehouseTransferOutboundCandidates(customerId, warehouseId));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/warehouse-transfer")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "Create a warehouse-transfer outbound, delivery order and PDA task")
|
||||
public ApiResponse<DeliveryResponses.OutboundDetail> createWarehouseTransferOutbound(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@Valid @RequestBody DeliveryRequests.WarehouseTransferOutbound request) {
|
||||
return ApiResponse.success(service.createWarehouseTransferOutbound(
|
||||
requiredCustomer(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
|
||||
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
|
||||
|
||||
@@ -75,6 +75,20 @@ public final class DeliveryRequests {
|
||||
@NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks,
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record WarehouseTransferOutbound(
|
||||
@NotBlank @Size(max=64) String transferNo,
|
||||
@NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds,
|
||||
@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 deliveryAddress,
|
||||
@Size(max=128) String contactName,
|
||||
@Size(max=64) String contactPhone,
|
||||
@Size(max=32) String deliveryMethod,
|
||||
LocalDateTime plannedDeliveryAt,
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
|
||||
@NotBlank @Size(max=128) String sku,
|
||||
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
|
||||
|
||||
+20
-4
@@ -16,7 +16,9 @@ public final class DeliveryResponses {
|
||||
List<TransferDelivery> deliveries, boolean replayed) {}
|
||||
|
||||
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes,
|
||||
String status, int palletCount, int packageCount, LocalDateTime createdAt, String outboundType) {}
|
||||
String status, int palletCount, int packageCount, BigDecimal grossWeightKg,
|
||||
BigDecimal volumeWeightKg, BigDecimal volumeCbm,
|
||||
LocalDateTime createdAt, String outboundType) {}
|
||||
|
||||
public record DeliveryRow(Long id, String customerId, String customerName, String deliveryNo,
|
||||
String outboundNo, String dispatchNo, String overseasWarehouseId,
|
||||
@@ -31,7 +33,8 @@ public final class DeliveryResponses {
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
|
||||
String bolNo, DeliveryDocumentDtos.Summary documentSummary,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName) {}
|
||||
String destinationWarehouseName, String destinationType,
|
||||
String deliveryAddress, String contactName, String contactPhone, String postalCode) {}
|
||||
|
||||
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
|
||||
String receivingAddress, String shipmentNo, String fbaTrackingNo,
|
||||
@@ -50,15 +53,28 @@ public final class DeliveryResponses {
|
||||
/** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */
|
||||
public record OutboundPalletRow(Long id, String palletNo, String cartonMarks, String inboundCode,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String skuCode, int packageCount, int pickedPackageCount, String status) {}
|
||||
String skuCode, int packageCount, BigDecimal grossWeightKg,
|
||||
BigDecimal volumeWeightKg, BigDecimal volumeCbm,
|
||||
int pickedPackageCount, String status) {}
|
||||
public record OutboundDetail(OutboundRow outbound, List<DeliveryRow> deliveries, List<OutboundPalletRow> pallets) {}
|
||||
public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId,
|
||||
String overseasWarehouseName, String palletNo, String inboundCode,
|
||||
String skuCode, String productName, long availablePackages) {}
|
||||
String skuCode, String productName, long availablePackages,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm) {}
|
||||
public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId,
|
||||
String cartonMark, String fbaCartonMark,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
long totalQuantity, String status) {}
|
||||
public record WarehouseTransferOutboundCandidate(String customerId, String customerName,
|
||||
String transferNo, String overseasWarehouseId,
|
||||
String transferStatus, boolean needRelabel,
|
||||
String relabelNo, String destinationType,
|
||||
String destinationWarehouseCode,
|
||||
String privateDestinationAddress,
|
||||
String privateDestinationPostalCode,
|
||||
Long palletId, Long warehousePalletId, String palletNo,
|
||||
int packageCount, BigDecimal grossWeightKg,
|
||||
BigDecimal volumeCbm) {}
|
||||
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
|
||||
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
|
||||
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
|
||||
|
||||
@@ -26,6 +26,11 @@ public class DeliveryOrder {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String deliveryGroupKey;
|
||||
private String status;
|
||||
private String waybillNo;
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Outbound document created when a dispatch is transferred to delivery. */
|
||||
@@ -24,6 +25,9 @@ public class OutboundOrder {
|
||||
private String status;
|
||||
private Integer packageCount;
|
||||
private Integer palletCount;
|
||||
private BigDecimal grossWeightKg;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private BigDecimal volumeCbm;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Pallet-level immutable snapshot belonging to one outbound document. */
|
||||
@@ -21,6 +22,9 @@ public class OutboundPalletItem {
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private Integer packageCount;
|
||||
private BigDecimal grossWeightKg;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private BigDecimal volumeCbm;
|
||||
private Integer pickedPackageCount;
|
||||
private String status;
|
||||
private String skuCode;
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService {
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
|
||||
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
|
||||
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
|
||||
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
|
||||
requireText(order.getSourceOrderId(), "sourceOrderId");
|
||||
requireText(order.getInboundCode(), "inboundCode");
|
||||
|
||||
+293
-35
@@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -74,6 +76,7 @@ import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -97,6 +100,7 @@ public class DeliveryTransferService {
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
|
||||
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private DropshipCartonMapper dropshipCartons;
|
||||
@Resource private DropshipCartonSkuMapper dropshipCartonSkus;
|
||||
@@ -162,7 +166,11 @@ public class DeliveryTransferService {
|
||||
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY");
|
||||
outbound.setLxCodes(resolveLxCodes(customerId, items));
|
||||
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
outbound.setPalletCount(items.size());
|
||||
outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg));
|
||||
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
|
||||
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
|
||||
outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
try {
|
||||
outboundOrders.insert(outbound);
|
||||
@@ -187,7 +195,9 @@ public class DeliveryTransferService {
|
||||
item.setDestinationWarehouseId(source.getDestinationWarehouseId());
|
||||
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
|
||||
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
|
||||
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0));
|
||||
item.setGrossWeightKg(source.getGrossWeightKg()); item.setVolumeWeightKg(source.getVolumeWeightKg());
|
||||
item.setVolumeCbm(source.getVolumeCbm()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
if (StringUtils.hasText(source.getCartonMarks())) {
|
||||
item.setCartonMarks(source.getCartonMarks());
|
||||
} else if (source.getWarehousePalletId() != null) {
|
||||
@@ -217,6 +227,11 @@ public class DeliveryTransferService {
|
||||
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
|
||||
destination.getDestinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName()));
|
||||
delivery.setDestinationType(destination.getDestinationType());
|
||||
delivery.setDeliveryAddress(destination.getDeliveryAddress());
|
||||
delivery.setPostalCode(destination.getPostalCode());
|
||||
delivery.setContactName(destination.getContactName());
|
||||
delivery.setContactPhone(destination.getContactPhone());
|
||||
delivery.setDeliveryGroupKey(entry.getKey());
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
@@ -228,8 +243,8 @@ public class DeliveryTransferService {
|
||||
delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt());
|
||||
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
delivery.setPalletCount(groupItems.size());
|
||||
delivery.setTotalVolumeCbm(groupItems.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setTotalWeightKg(totalWeight(customerId, groupItems)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
|
||||
delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
|
||||
@@ -308,8 +323,10 @@ public class DeliveryTransferService {
|
||||
if (concurrent == null) throw duplicate;
|
||||
return replayOneClick(concurrent, requestHash);
|
||||
}
|
||||
selectedWarehouseId = validateOneClickItems(customerId, request.items());
|
||||
OneClickValidation validation = validateOneClickItems(customerId, request.items());
|
||||
selectedWarehouseId = validation.warehouseId();
|
||||
Set<Long> physicalPalletIds = new HashSet<>();
|
||||
List<OutboundPalletItem> createdMetricItems = new ArrayList<>();
|
||||
for (DeliveryRequests.OneClickItem input : request.items()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
|
||||
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
|
||||
@@ -320,10 +337,21 @@ public class DeliveryTransferService {
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||
item.setInboundCode(balance.getInboundCode());
|
||||
physicalPalletIds.add(position.getPalletId());
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
|
||||
DropshipOutboundCandidate candidate = validation.candidatesByRequest().get(
|
||||
oneClickCandidateKey(input.inventoryPositionId(), input.sku()));
|
||||
if (candidate == null) throw conflict("一件代发出库重量体积快照缺失");
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue());
|
||||
item.setGrossWeightKg(allocateMetric(candidate.getSourceGrossWeightKg(), candidate.getSourcePackageCount(), input.packages(), 3));
|
||||
item.setVolumeWeightKg(allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), input.packages(), 3));
|
||||
item.setVolumeCbm(allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), input.packages(), 4));
|
||||
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
|
||||
outboundPallets.insert(item);
|
||||
createdMetricItems.add(item);
|
||||
}
|
||||
outbound.setPalletCount(physicalPalletIds.size());
|
||||
outbound.setGrossWeightKg(sum(createdMetricItems, OutboundPalletItem::getGrossWeightKg));
|
||||
outbound.setVolumeWeightKg(sum(createdMetricItems, OutboundPalletItem::getVolumeWeightKg));
|
||||
outbound.setVolumeCbm(sum(createdMetricItems, OutboundPalletItem::getVolumeCbm));
|
||||
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
@@ -395,10 +423,160 @@ public class DeliveryTransferService {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
|
||||
@Transactional(readOnly = true)
|
||||
public List<DeliveryResponses.WarehouseTransferOutboundCandidate> warehouseTransferOutboundCandidates(
|
||||
String customerId, String warehouseId) {
|
||||
List<WarehouseTransferOrder> transfers = warehouseTransfers.selectList(
|
||||
new LambdaQueryWrapper<WarehouseTransferOrder>()
|
||||
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId))
|
||||
.eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId))
|
||||
.eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION")
|
||||
.orderByDesc(WarehouseTransferOrder::getUpdatedAt));
|
||||
List<DeliveryResponses.WarehouseTransferOutboundCandidate> result = new ArrayList<>();
|
||||
for (WarehouseTransferOrder transfer : transfers) {
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
List<WarehouseTransferPallet> completedPallets = warehouseTransferPallets.listOrder(
|
||||
transfer.getCustomerId(), transfer.getId()).stream()
|
||||
.filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null)
|
||||
.toList();
|
||||
long available = 0;
|
||||
for (WarehouseTransferPallet pallet : completedPallets) {
|
||||
long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId())
|
||||
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
|
||||
.mapToLong(position -> value(position.getAvailablePackages())).sum();
|
||||
available += palletAvailable;
|
||||
}
|
||||
if (available <= 0 || completedPallets.isEmpty()) continue;
|
||||
result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate(
|
||||
transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(),
|
||||
transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()),
|
||||
transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(),
|
||||
transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(),
|
||||
null, null, null, Math.toIntExact(available),
|
||||
completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||
completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId,
|
||||
DeliveryRequests.WarehouseTransferOutbound request) {
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, key);
|
||||
if (replay != null) {
|
||||
if (!"WAREHOUSE_TRANSFER_OUTBOUND".equals(replay.getOutboundType())
|
||||
|| !requestHash.equals(replay.getRequestHash())) {
|
||||
throw conflict("相同幂等键不能用于不同的仓储转运出库请求");
|
||||
}
|
||||
return outboundDetail(replay.getOutboundNo());
|
||||
}
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, request.transferNo().trim());
|
||||
if (transfer == null) throw notFound("仓储转运单不存在");
|
||||
if (!Set.of("READY_FOR_DISPATCH", "WAIT_FBA_DESTINATION").contains(transfer.getStatus())) {
|
||||
throw conflict("仓储转运单尚未完成换标或已经生成出库单");
|
||||
}
|
||||
String destinationType = request.destinationType().trim().toUpperCase(Locale.ROOT);
|
||||
if ("FBA".equals(destinationType) && !StringUtils.hasText(request.destinationWarehouseCode())) {
|
||||
throw conflict("FBA出库必须选择目的仓");
|
||||
}
|
||||
if ("PRIVATE".equals(destinationType) && (!StringUtils.hasText(request.deliveryAddress())
|
||||
|| !StringUtils.hasText(request.contactName()) || !StringUtils.hasText(request.contactPhone()))) {
|
||||
throw conflict("私人仓出库必须填写地址、联系人和联系电话");
|
||||
}
|
||||
List<Long> palletIds = request.palletIds().stream().distinct().toList();
|
||||
if (palletIds.size() != request.palletIds().size()) throw conflict("出库托盘不可重复");
|
||||
List<WarehouseTransferPallet> selectedPallets = new ArrayList<>();
|
||||
for (Long palletId : palletIds) {
|
||||
WarehouseTransferPallet pallet = warehouseTransferPallets.lockOne(customerId, transfer.getId(), palletId);
|
||||
if (pallet == null || !"COMPLETED".equals(pallet.getStatus()) || pallet.getWarehousePalletId() == null) {
|
||||
throw conflict("所选托盘不属于该仓储转运单或尚未完成入库:" + palletId);
|
||||
}
|
||||
selectedPallets.add(pallet);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
||||
outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
outbound.setRecipientInfo("PRIVATE".equals(destinationType)
|
||||
? request.contactName().trim() + " / " + request.contactPhone().trim() + " / " + request.deliveryAddress().trim()
|
||||
: request.destinationWarehouseCode().trim());
|
||||
outbound.setPalletCount(selectedPallets.size()); outbound.setIdempotencyKey(key);
|
||||
outbound.setRequestHash(requestHash); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
outboundOrders.insert(outbound);
|
||||
|
||||
int packageCount = 0;
|
||||
for (WarehouseTransferPallet pallet : selectedPallets) {
|
||||
List<InventoryPosition> positions = inventoryPositions.listPallet(customerId, pallet.getWarehousePalletId())
|
||||
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
|
||||
.filter(position -> value(position.getAvailablePackages()) > 0).toList();
|
||||
if (positions.isEmpty()) throw conflict("所选托盘没有可出库库存:" + pallet.getPalletNo());
|
||||
for (InventoryPosition position : positions) {
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
if (balance == null || !customerId.equals(balance.getCustomerId())) throw conflict("托盘库存余额不存在");
|
||||
int packages = Math.toIntExact(value(position.getAvailablePackages()));
|
||||
OutboundPalletItem item = new OutboundPalletItem();
|
||||
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||
item.setWarehousePalletId(pallet.getWarehousePalletId()); item.setPalletNo(pallet.getPalletNo());
|
||||
item.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
|
||||
item.setDestinationWarehouseCode(trim(request.destinationWarehouseCode()));
|
||||
item.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
|
||||
item.setPackageCount(packages); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setSkuCode(balance.getSkuCode()); item.setInboundCode(balance.getInboundCode());
|
||||
item.setOrderRefs(transfer.getTransferNo()); item.setCreatedAt(now); outboundPallets.insert(item);
|
||||
packageCount = Math.addExact(packageCount, packages);
|
||||
}
|
||||
}
|
||||
outbound.setPackageCount(packageCount); outboundOrders.updateById(outbound);
|
||||
|
||||
LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId()));
|
||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||
delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
||||
delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName());
|
||||
delivery.setDestinationType(destinationType); delivery.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseCode(trim(request.destinationWarehouseCode()));
|
||||
delivery.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
|
||||
delivery.setDeliveryAddress(trim(request.deliveryAddress())); delivery.setContactName(trim(request.contactName()));
|
||||
delivery.setContactPhone(trim(request.contactPhone())); delivery.setDeliveryGroupKey(destinationType + ":" +
|
||||
firstText(request.destinationWarehouseCode(), request.deliveryAddress()));
|
||||
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryMethod(firstText(request.deliveryMethod(), "TRUCK"));
|
||||
delivery.setPackageCount(packageCount); delivery.setPalletCount(selectedPallets.size());
|
||||
delivery.setTotalWeightKg(selectedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setTotalVolumeCbm(selectedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setIdempotencyKey(childKey(key, "DELIVERY")); delivery.setCreatedBy(operatorId); delivery.setVersion(0L);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now);
|
||||
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId);
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
|
||||
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
transfer.setStatus("WAIT_OUTBOUND");
|
||||
if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim());
|
||||
transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
|
||||
if (tailOrder != null && customerId.equals(tailOrder.getCustomerId())) {
|
||||
tailOrder.setCurrentStatus("WAIT_OUTBOUND"); tailOrder.setOutboundOrderNo(outbound.getOutboundNo());
|
||||
tailOrder.setDeliveryOrderNo(delivery.getDeliveryNo()); tailOrder.setFbaWarehouseCode(trim(request.destinationWarehouseCode()));
|
||||
tailOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:WAIT_OUTBOUND"); tailOrder.setUpdatedAt(now); tailOrders.updateById(tailOrder);
|
||||
}
|
||||
return outboundDetail(outbound.getOutboundNo());
|
||||
}
|
||||
|
||||
private OneClickValidation validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
|
||||
String warehouseId = null;
|
||||
Map<String, Long> requestedPackages = new LinkedHashMap<>();
|
||||
Map<String, DropshipOutboundCandidate> candidates = new HashMap<>();
|
||||
Map<String, DropshipOutboundCandidate> candidatesByRequest = new HashMap<>();
|
||||
Set<String> requestedMarks = new HashSet<>();
|
||||
for (DeliveryRequests.OneClickItem input : items) {
|
||||
String sku = input.sku().trim();
|
||||
@@ -414,6 +592,7 @@ public class DeliveryTransferService {
|
||||
String key = candidate.getProfileId() + "\u0000" + sku;
|
||||
requestedPackages.merge(key, input.packages(), Long::sum);
|
||||
candidates.put(key, candidate);
|
||||
candidatesByRequest.put(oneClickCandidateKey(input.inventoryPositionId(), sku), candidate);
|
||||
if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) {
|
||||
if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) {
|
||||
throw conflict("Specified carton marks must be unique and equal the package count");
|
||||
@@ -439,7 +618,7 @@ public class DeliveryTransferService {
|
||||
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
|
||||
}
|
||||
}
|
||||
return warehouseId;
|
||||
return new OneClickValidation(warehouseId, Map.copyOf(candidatesByRequest));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -541,6 +720,40 @@ public class DeliveryTransferService {
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
if (request.inventoryPositionId() == null) throw conflict("仓储转运出库必须确认实际库存位置");
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId())
|
||||
|| !warehouseId.equals(selectedPosition.getOverseasWarehouseId())
|
||||
|| !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) {
|
||||
throw conflict("确认的库存位置不属于该仓储转运出库托盘");
|
||||
}
|
||||
InventoryBalance balance = inventoryBalances.selectById(selectedPosition.getBalanceId());
|
||||
if (!matchesOutboundItem(customerId, warehouseId, item, balance)) {
|
||||
throw conflict("确认的库存SKU或入仓码与出库明细不一致");
|
||||
}
|
||||
if (request.packages() > value(item.getPackageCount()) - value(item.getPickedPackageCount())) {
|
||||
throw conflict("出库数量超过仓储转运出库明细余量");
|
||||
}
|
||||
inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":ALLOCATE", "仓储转运出库分配"));
|
||||
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":PICK", "仓储转运出库拣货"));
|
||||
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":SHIP", "仓储转运出库"));
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId);
|
||||
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId());
|
||||
pick.setInventoryPositionId(selectedPosition.getId()); pick.setPackages(request.packages());
|
||||
pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue());
|
||||
item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED");
|
||||
outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound);
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
|
||||
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
|
||||
@@ -612,8 +825,8 @@ public class DeliveryTransferService {
|
||||
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
|
||||
throw notFound("Outbound order not found in the bound warehouse");
|
||||
}
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) {
|
||||
throw conflict("整托扫码仅适用于一件代发出库");
|
||||
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
|
||||
throw conflict("整托扫码仅适用于一件代发或换标出库");
|
||||
}
|
||||
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
|
||||
throw conflict("PDA task is not available for picking");
|
||||
@@ -695,6 +908,23 @@ public class DeliveryTransferService {
|
||||
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
|
||||
}
|
||||
}
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
List<String> transferNos = items.stream().map(OutboundPalletItem::getOrderRefs)
|
||||
.filter(StringUtils::hasText).distinct().toList();
|
||||
for (String transferNo : transferNos) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
|
||||
if (transfer != null && "WAIT_OUTBOUND".equals(transfer.getStatus())) {
|
||||
transfer.setStatus("OUTBOUNDED"); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
|
||||
warehouseTransfers.updateById(transfer);
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
if (order != null && customerId.equals(order.getCustomerId())) {
|
||||
order.setCurrentStatus("IN_TRANSIT");
|
||||
order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now);
|
||||
tailOrders.updateById(order);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@@ -724,6 +954,23 @@ public class DeliveryTransferService {
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs)
|
||||
.filter(StringUtils::hasText).distinct().toList()) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
|
||||
if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue;
|
||||
String restored = StringUtils.hasText(transfer.getFbaWarehouseCode())
|
||||
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
|
||||
transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
if (order != null && customerId.equals(order.getCustomerId())) {
|
||||
order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored);
|
||||
order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now);
|
||||
tailOrders.updateById(order);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!existingDeliveries.isEmpty()) {
|
||||
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
|
||||
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
|
||||
@@ -946,29 +1193,37 @@ public class DeliveryTransferService {
|
||||
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
|
||||
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
||||
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
|
||||
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
|
||||
item.getSkuCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus());
|
||||
item.getSkuCode(), value(item.getPackageCount()), item.getGrossWeightKg(), item.getVolumeWeightKg(),
|
||||
item.getVolumeCbm(), value(item.getPickedPackageCount()), item.getStatus());
|
||||
}
|
||||
private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) {
|
||||
long available = value(candidate.getAvailablePackages());
|
||||
return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), candidate.getOrderNo(), candidate.getCustomerId(), candidate.getCustomerName(),
|
||||
candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(),
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
available,
|
||||
allocateMetric(candidate.getSourceGrossWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
|
||||
private String deliveryGroupKey(DispatchItem item) {
|
||||
if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) {
|
||||
return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim();
|
||||
}
|
||||
String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode());
|
||||
if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT);
|
||||
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim();
|
||||
throw conflict("Dispatch pallet is missing its destination FBA warehouse");
|
||||
throw conflict("Dispatch pallet is missing its destination");
|
||||
}
|
||||
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
|
||||
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
|
||||
@@ -999,6 +1254,8 @@ public class DeliveryTransferService {
|
||||
throw new IllegalStateException("无法生成整托拣货幂等号", error);
|
||||
}
|
||||
}
|
||||
private record OneClickValidation(String warehouseId,
|
||||
Map<String, DropshipOutboundCandidate> candidatesByRequest) {}
|
||||
private record PalletPickPlan(Long itemId, Long positionId, long packages,
|
||||
List<String> cartonMarks, int sequence) {}
|
||||
private record SkuShipment(long packages, long quantity) {
|
||||
@@ -1007,28 +1264,29 @@ public class DeliveryTransferService {
|
||||
Math.addExact(quantity, other.quantity));
|
||||
}
|
||||
}
|
||||
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) {
|
||||
List<Long> palletIds = items.stream().map(DispatchItem::getWarehousePalletId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
List<WarehouseTransferPallet> transferPallets = palletIds.isEmpty() ? List.of()
|
||||
: warehouseTransferPallets.selectList(new LambdaQueryWrapper<WarehouseTransferPallet>()
|
||||
.eq(WarehouseTransferPallet::getCustomerId, customerId)
|
||||
.in(WarehouseTransferPallet::getWarehousePalletId, palletIds));
|
||||
Set<Long> measuredPalletIds = transferPallets.stream()
|
||||
.map(WarehouseTransferPallet::getWarehousePalletId).collect(java.util.stream.Collectors.toSet());
|
||||
BigDecimal measuredWeight = transferPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
List<Long> orderIds = items.stream()
|
||||
.filter(item -> item.getWarehousePalletId() == null || !measuredPalletIds.contains(item.getWarehousePalletId()))
|
||||
.map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
|
||||
if (orderIds.isEmpty()) return measuredWeight;
|
||||
BigDecimal orderWeight = tailOrderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
|
||||
.eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds))
|
||||
.stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return measuredWeight.add(orderWeight);
|
||||
private static <T> BigDecimal sum(List<T> items,
|
||||
java.util.function.Function<T, BigDecimal> getter) {
|
||||
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
|
||||
long selectedPackages, int scale) {
|
||||
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
|
||||
|| sourcePackages <= 0 || selectedPackages <= 0) return BigDecimal.ZERO.setScale(scale);
|
||||
return sourceValue.multiply(BigDecimal.valueOf(selectedPackages))
|
||||
.divide(BigDecimal.valueOf(sourcePackages), scale, RoundingMode.HALF_UP);
|
||||
}
|
||||
private static String oneClickCandidateKey(Long inventoryPositionId, String sku) {
|
||||
return inventoryPositionId + "\u0000" + trim(sku);
|
||||
}
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException("无法生成请求摘要", error);
|
||||
}
|
||||
}
|
||||
private static Long parseLong(String value) {
|
||||
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
|
||||
+7
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||
import com.baiyue.lastleg.discard.service.CargoDiscardService;
|
||||
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;
|
||||
@@ -19,18 +21,23 @@ import java.util.List;
|
||||
public class MerchantCargoDiscardController {
|
||||
@Resource private CargoDiscardService service;
|
||||
@GetMapping("/inventory-candidates")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
|
||||
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
|
||||
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
|
||||
@GetMapping("/{discardNo}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
|
||||
@PostMapping("/{discardNo}/cancel")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String discardNo) { return ApiResponse.success(service.cancel(customer(user), discardNo, operator(user), false)); }
|
||||
private String customer(AuthenticatedUser user) {
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ public class SmartDispatchController {
|
||||
@RequestParam(required = false) String sourceStage,
|
||||
@RequestParam(required = false) String draftToken) {
|
||||
requirePlatform(user);
|
||||
if (planIds == null || planIds.isEmpty()) {
|
||||
if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) {
|
||||
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
|
||||
}
|
||||
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
|
||||
|
||||
@@ -51,6 +51,7 @@ public final class DispatchRequests {
|
||||
|
||||
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
|
||||
LocalDateTime departAt,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
|
||||
@@ -17,6 +17,7 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeWeightKg,
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,14 @@ public class DispatchItem {
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String fbaWarehouseCode;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private Integer packageCount;
|
||||
private BigDecimal grossWeightKg;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private BigDecimal volumeCbm;
|
||||
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
|
||||
private String sourceStage;
|
||||
|
||||
+48
-10
@@ -29,6 +29,14 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
|
||||
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
@@ -38,7 +46,13 @@ public interface DispatchCandidateMapper {
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
@@ -64,16 +78,20 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
|
||||
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
|
||||
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
|
||||
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE 1=1 " +
|
||||
"WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
|
||||
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
|
||||
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
|
||||
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
@@ -111,6 +129,14 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
"COALESCE(NULLIF(SUM(CASE WHEN COALESCE(l.volume_cbm,0)>0 THEN " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"* COALESCE(a.allocated_volume_cbm,0) / l.volume_cbm ELSE " +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d WHERE d.customer_id=ip.customer_id AND d.order_id=l.order_id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound WHERE inbound.id=l.order_id AND inbound.customer_id=ip.customer_id LIMIT 1),0) " +
|
||||
"/ NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0)," +
|
||||
"MIN(o.volume_weight_kg)) AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
"(SELECT MAX(fa.appointment_at) FROM ll_inbound_fba_appointment_item fi " +
|
||||
"JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " +
|
||||
@@ -120,7 +146,13 @@ public interface DispatchCandidateMapper {
|
||||
"(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
@@ -146,13 +178,18 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
|
||||
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
|
||||
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
|
||||
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
|
||||
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
|
||||
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
@@ -176,15 +213,16 @@ public interface DispatchCandidateMapper {
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
|
||||
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS containerNo," +
|
||||
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
|
||||
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS grossWeightKg,NULL AS containerNo," +
|
||||
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_code) AS overseasWarehouseCode,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
|
||||
"MIN(COALESCE(NULLIF(ri.source_inbound_order_no,''),NULLIF(a.customer_order_no,''),a.relabel_no)) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," +
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseName," +
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
|
||||
"SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," +
|
||||
"NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " +
|
||||
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " +
|
||||
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks," +
|
||||
"'FBA' AS destinationType,NULL AS deliveryAddress,NULL AS postalCode,NULL AS contactName,NULL AS contactPhone " +
|
||||
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
|
||||
"JOIN ll_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
|
||||
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
|
||||
@@ -193,7 +231,7 @@ public interface DispatchCandidateMapper {
|
||||
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
"AND pos.hold_packages > 0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
|
||||
|
||||
+12
-5
@@ -13,10 +13,12 @@ public record DispatchPalletCandidate(
|
||||
String planNo,
|
||||
String orderType,
|
||||
BigDecimal volumeWeightKg,
|
||||
BigDecimal grossWeightKg,
|
||||
String containerNo,
|
||||
LocalDateTime appointmentAt,
|
||||
LocalDateTime expectedLoadingAt,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseCode,
|
||||
String overseasWarehouseName,
|
||||
String orderNo,
|
||||
String orderRefs,
|
||||
@@ -30,7 +32,12 @@ public record DispatchPalletCandidate(
|
||||
BigDecimal volumeCbm,
|
||||
String sourceStage,
|
||||
String cartonMarks,
|
||||
String relabelCartonMarks
|
||||
String relabelCartonMarks,
|
||||
String destinationType,
|
||||
String deliveryAddress,
|
||||
String postalCode,
|
||||
String contactName,
|
||||
String contactPhone
|
||||
) {
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
|
||||
@@ -38,10 +45,10 @@ public record DispatchPalletCandidate(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm,
|
||||
String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, overseasWarehouseId, null,
|
||||
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
|
||||
sourceStage, null, null);
|
||||
sourceStage, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
@@ -49,9 +56,9 @@ public record DispatchPalletCandidate(
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
|
||||
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null);
|
||||
}
|
||||
public String sourcePalletKey() {
|
||||
// Once received, the physical pallet is the shared resource even when it contains
|
||||
|
||||
+38
-7
@@ -66,6 +66,7 @@ public class DispatchService {
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
|
||||
@@ -107,12 +108,14 @@ public class DispatchService {
|
||||
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
|
||||
String sourceStage, String draftToken) {
|
||||
validateSourceStage(sourceStage);
|
||||
List<DispatchPalletCandidate> result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
List<DispatchPalletCandidate> result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
trimToNull(sourceStage), trimToNull(draftToken));
|
||||
if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) {
|
||||
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
|
||||
if (relabel != null && !relabel.isEmpty()) {
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList();
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
|
||||
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
|
||||
.toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -270,6 +273,11 @@ public class DispatchService {
|
||||
if (selected.size() != palletIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
|
||||
}
|
||||
if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) {
|
||||
boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType()));
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba
|
||||
? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车");
|
||||
}
|
||||
assertNoDetainedOrders(customerId, selected);
|
||||
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.anyMatch(value -> value == null || value.isBlank());
|
||||
@@ -396,7 +404,14 @@ public class DispatchService {
|
||||
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
|
||||
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
|
||||
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
|
||||
item.setDestinationType(candidate.destinationType());
|
||||
item.setDeliveryAddress(candidate.deliveryAddress());
|
||||
item.setPostalCode(candidate.postalCode());
|
||||
item.setContactName(candidate.contactName());
|
||||
item.setContactPhone(candidate.contactPhone());
|
||||
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
|
||||
item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO));
|
||||
item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO));
|
||||
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
|
||||
item.setSourceStage(candidate.sourceStage());
|
||||
item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks()));
|
||||
@@ -433,6 +448,7 @@ public class DispatchService {
|
||||
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
|
||||
}
|
||||
relabelBindingGuard.requireBound(application);
|
||||
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
|
||||
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
|
||||
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
|
||||
@@ -443,7 +459,7 @@ public class DispatchService {
|
||||
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
|
||||
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
|
||||
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
|
||||
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
|
||||
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
|
||||
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
@@ -522,6 +538,7 @@ public class DispatchService {
|
||||
dispatch.setDriverName(trimToNull(request.driverName()));
|
||||
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
|
||||
dispatch.setCarrierName(trimToNull(request.carrierName()));
|
||||
dispatch.setDepartAt(request.departAt());
|
||||
dispatch.setRoute(itemRoute(assignedItems));
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
@@ -638,7 +655,8 @@ public class DispatchService {
|
||||
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
|
||||
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
|
||||
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
|
||||
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList(),
|
||||
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
|
||||
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
|
||||
bolLineList == null ? List.of() : bolLineList);
|
||||
}
|
||||
|
||||
@@ -653,10 +671,23 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode())
|
||||
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" "));
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static boolean hasValidDestination(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode())
|
||||
&& StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone());
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
|| StringUtils.hasText(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static String itemFbaCode(DispatchItem item) {
|
||||
return StringUtils.hasText(item.getFbaWarehouseCode())
|
||||
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
|
||||
@@ -716,8 +747,8 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private static void validateSourceStage(String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -17,7 +17,13 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
|
||||
"WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) product_name," +
|
||||
"LEAST(pos.available_packages,(SELECT COUNT(*) FROM ll_dropship_carton c " +
|
||||
"WHERE c.customer_id=p.customer_id AND c.profile_id=p.id " +
|
||||
"AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " +
|
||||
"AND c.status='AVAILABLE')) available_packages,COALESCE(o.package_count,0) source_package_count," +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d " +
|
||||
"WHERE d.customer_id=o.customer_id AND d.order_id=o.id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound " +
|
||||
"WHERE inbound.customer_id=o.customer_id AND inbound.id=o.id LIMIT 1),0) source_gross_weight_kg," +
|
||||
"COALESCE(o.volume_weight_kg,0) source_volume_weight_kg,COALESCE(o.volume_cbm,0) source_volume_cbm " +
|
||||
"FROM ll_dropship_order_profile p " +
|
||||
"JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " +
|
||||
"AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " +
|
||||
"JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " +
|
||||
|
||||
+7
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.dropship.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */
|
||||
@Data
|
||||
public class DropshipOutboundCandidate {
|
||||
@@ -18,4 +20,9 @@ public class DropshipOutboundCandidate {
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private Long availablePackages;
|
||||
/** Loaded-order denominator and immutable metrics used for proportional outbound snapshots. */
|
||||
private Long sourcePackageCount;
|
||||
private BigDecimal sourceGrossWeightKg;
|
||||
private BigDecimal sourceVolumeWeightKg;
|
||||
private BigDecimal sourceVolumeCbm;
|
||||
}
|
||||
|
||||
@@ -114,12 +114,6 @@ public final class LastlegOrderRequests {
|
||||
) {
|
||||
}
|
||||
|
||||
public record SetDetained(
|
||||
@NotNull Boolean detained,
|
||||
@NotBlank @Size(max = 500) String remark
|
||||
) {
|
||||
}
|
||||
|
||||
public record SetPalletMergeRequired(
|
||||
@NotNull Boolean palletMergeRequired
|
||||
) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user