diff --git a/AGENTS.md b/AGENTS.md index dd3922234..f1232234c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | | 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | -| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | +| 海外业务费用来源跨区入池、人工费用与自动仓租 | `lastleg-service/`(`financefee/`、`storagerent/`)、`common-event-contract/`、`finance/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index f3dd81425..07cc594ff 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -33,7 +33,7 @@ | 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” | | 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” | | FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” | -| 费用项目、价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`pricebookquery/application/PriceBookQueryService.java`;查“价格簿查询内部切片” | +| 费用项目、共享费用目录与价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`controller/InternalFeeCatalogController.java`、`pricebookquery/application/PriceBookQueryService.java`;共享目录契约 `src/main/resources/openapi/fee-catalog-internal-v1.yaml`,价格簿查“价格簿查询内部切片” | | 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” | | 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” | | 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” | diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 003891a52..a0c618ffc 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -17,7 +17,7 @@ 拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 -原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 +`fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 为装车后运营视图;判定与权限分别见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`;OpenAPI 为各自 `fleet-*-business-v1.yaml`,部署/验收只查环境主契约。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 @@ -68,17 +68,18 @@ | 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java` | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/` | | 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java` | -| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java` | +| 海外业务费用来源、费用明细审核与重试 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasManualFeeService.java`;目录入口 `financefee/controller/PlatformOverseasFeeCatalogController.java`,细则查详细索引同主题 | +| 自动仓租、仓租库存与出库资金预校验 | `storagerent/controller/PlatformStorageRentController.java`、`storagerent/controller/PdaStorageRentController.java`、`storagerent/service/preparation/StorageRentPreparationService.java`;查详细索引“自动仓租及出库资金门禁” | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java` | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` | | 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` | | 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` | | 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 | | 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 | -| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | +| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java`;两级审核、依据失效与零扫码退回规则查当前排车主契约 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | +| 实物装载、逐段容量与双端整车发车 | `dispatch/service/DispatchLoadingDepartureService.java`、`DispatchLoadingDocumentService.java`;双端使用 `recordVersion`;装车确认、照片齐全后的自动派送、容量与幂等只查当前排车主契约,无公开 `/depart` | | 车次货物结案、车辆和司机自动释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;全车派送完成自动结案释放车辆和司机,提货全部到仓及本土卡车完成同事务释放;不等待二次确认,不改变车辆主档状态,收款不触发释放 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index 6964d1a31..0992bd580 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -107,7 +107,11 @@ public class PdaCargoDiscardService { || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()))) throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); - if (terminal && palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; + // 已完成托盘在整单未结束时也只核对原结果,不能再次计入数量池或扣减库存。 + if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) { + if (!terminal) photoService.confirm(application, task.palletNo(), request.photoFileIds(), true); + return task; + } var allItems = repository.items(application.getCustomerId(), application.getId()); boolean quantityPool = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.quantityPool(application, allItems); if (quantityPool) { @@ -119,10 +123,7 @@ public class PdaCargoDiscardService { if ((long)done + current > application.getTotalPackages()) throw conflict("累计废弃箱数不能超过客户申请箱数,请刷新核对剩余数量"); if ((long)done + current + availableLater < application.getTotalPackages()) throw conflict("剩余托盘数量不足以完成申请,请核对本托盘实际废弃箱数"); } - photoService.confirm(application, task.palletNo(), request.photoFileIds(), - palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); - // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 - if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; + photoService.confirm(application, task.palletNo(), request.photoFileIds(), false); if (terminal) throw conflict("废弃终态不能执行未完成托盘"); storageRent.consume(String.valueOf(identity.overseasWarehouseId()),new com.baiyue.lastleg.storagerent.dto.StorageRentDtos.PrepareRequest( "DISCARD",discardNo,null,null,null,null,palletNo,request.idempotencyKey(),null,null,null, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index 690401d99..7c05160b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -83,7 +83,9 @@ public class CargoDiscardStockService { relabelStock.lock(customer, List.of(request.inventoryPositionId())); if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); var balance = validate(customer, request); - reserveValidated(customer, operator, application, request, at, balance, cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>()); + boolean relabelReserved = reserveValidated(customer, operator, application, request, at, balance, + cartons(customer, position(customer, request.inventoryPositionId())), new HashSet<>(), new HashSet<>()); + if (relabelReserved) relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer); } /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ @@ -111,12 +113,17 @@ public class CargoDiscardStockService { } var lockedBoxes = new HashSet(); var heldBoxes = new HashSet(); - for (var request : stocks) reserveValidated(customer, operator, application, request, at, - balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes); + boolean relabelReserved = false; + for (var request : stocks) { + if (reserveValidated(customer, operator, application, request, at, + balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes)) relabelReserved = true; + } + // 全部仓位认领和预留完成后统一暂停原换标出库,避免后续行被本事务提前改成不可选状态。 + if (relabelReserved) relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer); } - /** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */ - private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at, + /** 使用变更箱状态前的完整事实保存各 SKU 快照,返回本行是否新增换标箱预留。 */ + private boolean reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at, InventoryBalance balance, List availableCartons, Set lockedBoxes, Set heldBoxes) { var position = position(customer, request.inventoryPositionId()); var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId()); @@ -147,8 +154,7 @@ public class CargoDiscardStockService { commands.hold(customer, operator, move(application, line, physical, "HOLD")); } repository.saveItem(line); - if (newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType()))) - relabelStock.synchronize(repository.selectedCartons(customer, application.getId()), customer); + return newlyHeld.stream().anyMatch(c -> "RELABEL".equals(c.getSourceType())); } /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java index 5b2a75370..a5dffcab9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetMapper.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.wallet.budget; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.*; import java.util.List; +import java.time.LocalDateTime; /** 全部预算冻结查询按客户和业务隔离,订单绑定与资金回执串行更新。 */ @Mapper @@ -19,11 +20,12 @@ public interface OrderBudgetMapper extends BaseMapper { /** 清理未下单预占及已经取消、拒绝或履约结束的订单预算,实际费用仍沿共用费用链。 */ @Select(""" SELECT b.* FROM ll_order_budget_reservation b - WHERE (b.status='RELEASE_FAILED' AND b.updated_at < TIMESTAMPADD(MINUTE,-1,CURRENT_TIMESTAMP(3))) OR (b.status IN ('FREEZE_PENDING','FROZEN','FAILED') AND b.release_requested=0 AND ( - (b.order_no IS NULL AND b.created_at < TIMESTAMPADD(MINUTE,-15,CURRENT_TIMESTAMP(3))) + WHERE (b.status='RELEASE_FAILED' AND b.updated_at < #{retryBefore}) OR (b.status IN ('FREEZE_PENDING','FROZEN','FAILED') AND b.release_requested=0 AND ( + (b.order_no IS NULL AND b.created_at < #{expiredBefore}) OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED'))) OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED'))) )) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED """) - List releasable(); + List releasable(@Param("expiredBefore") LocalDateTime expiredBefore, + @Param("retryBefore") LocalDateTime retryBefore); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetRepository.java index 87c614404..a22b850e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetRepository.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.wallet.budget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; +import java.time.LocalDateTime; +import java.time.ZoneOffset; /** 下单预算的唯一持久化入口。 */ @Repository @RequiredArgsConstructor @@ -18,6 +20,9 @@ public class OrderBudgetRepository { public OrderBudget lockOrder(String customer,String kind,String no) { return mapper.lockOrder(customer,kind,no); } /** 资金回执按原始记录及客户锁定。 */ public OrderBudget lockId(long id,String customer) { return mapper.lockId(id,customer); } - /** 有界读取待释放记录。 */ - public List releasable() { return mapper.releasable(); } + /** 到期边界与预占记录统一使用 UTC,不受数据库会话时区影响。 */ + public List releasable() { + var now = LocalDateTime.now(ZoneOffset.UTC); + return mapper.releasable(now.minusMinutes(15), now.minusMinutes(1)); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index bb9349dce..1f922190f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -163,7 +163,7 @@ class PdaCargoDiscardServiceTest { noWrites(); verifyNoInteractions(photoService); } /** 数量申请可仅使用一个托盘,也可跨托盘完成,剩余预留只释放一次。 */ - @ParameterizedTest @ValueSource(ints = {0, 2}) + @ParameterizedTest @ValueSource(ints = {0, 2, 3, 4}) void quantityTargetUsesChosenPalletsAndReleasesUnusedReservations(int firstActual) { var stock = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); ReflectionTestUtils.setField(service, "stockService", stock); scannedStock(stock); @@ -187,7 +187,14 @@ class PdaCargoDiscardServiceTest { if (firstActual > 0) { service.confirm(identity, "CD-1", "PALLET-1", request(firstActual)); assertEquals(CargoDiscardService.APPROVED, application.getStatus()); - assertEquals(3, service.resolve(identity, "PALLET-2", "CD-1").remainingPackages()); + assertEquals(5-firstActual, service.resolve(identity, "PALLET-2", "CD-1").remainingPackages()); + // 整单尚未结束时重放首托,也只能核对原结果,不能再次占用剩余目标数量。 + var replay = service.confirm(identity, "CD-1", "PALLET-1", request(firstActual)); + assertEquals("COMPLETED", replay.status()); + assertEquals(5-firstActual, replay.remainingPackages()); + verify(stock, times(1)).discard("CUSTOMER", "OP", application, item, firstActual); + verify(photoService).confirm(application, "PALLET-1", List.of(101L), true); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(firstActual-1))); } var secondRequest = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(12L, 5-firstActual, marks(12L, 5-firstActual))), List.of(101L)); if (firstActual > 0) assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-2", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java index 18901b172..c46471598 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java @@ -10,6 +10,8 @@ import com.baiyue.lastleg.inventory.mapper.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -26,13 +28,14 @@ class CargoDiscardStockServiceTest { final CargoDiscardCartonMapper links = mock(CargoDiscardCartonMapper.class); final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class); final InventoryCommandService commands = mock(InventoryCommandService.class); + final CargoDiscardRelabelStockService relabelStock = mock(CargoDiscardRelabelStockService.class); final CargoDiscardStockService service = new CargoDiscardStockService(); final InventoryPosition position = new InventoryPosition(); final InventoryBalance balance = new InventoryBalance(); final CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); } @BeforeEach void setup() { - ReflectionTestUtils.setField(service, "relabelStock", mock(CargoDiscardRelabelStockService.class)); + ReflectionTestUtils.setField(service, "relabelStock", relabelStock); var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(repository, "stock", stock); @@ -86,6 +89,35 @@ class CargoDiscardStockServiceTest { var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow(); carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton; } + /** 同一换标单的跨仓位预留全部保存后才暂停排车,单行入口同样保存暂停状态。 */ + @ParameterizedTest @ValueSource(ints = {1, 2}) + void relabelRowsStayEligibleUntilWholeReservationCompletes(int rowCount) { + position.setAvailablePackages(0L); position.setHoldPackages(1L); + balance.setAvailablePackages(0L); balance.setHoldPackages(2L); + var second = new InventoryPosition(); second.setId(43L); second.setCustomerId("C1"); second.setBalanceId(51L); + second.setOverseasWarehouseId("99"); second.setPalletNo("P-43"); second.setAvailablePackages(0L); second.setHoldPackages(1L); + when(positions.findTenantPosition("C1", 43L)).thenReturn(second); + when(stock.lockGenericPosition("C1", 43L)).thenReturn(second); + var firstBox = carton(101L, "RELABEL-1"); firstBox.setSourceType("RELABEL"); + var secondBox = carton(102L, "RELABEL-2"); secondBox.setSourceType("RELABEL"); + when(relabelStock.cartons("C1", position)).thenReturn(List.of(firstBox)); + when(relabelStock.cartons("C1", second)).thenReturn(List.of(secondBox)); + var eligible = new java.util.concurrent.atomic.AtomicBoolean(true); + when(relabelStock.claim(eq("C1"), anyLong())).thenAnswer(call -> eligible.get() ? 1 : 0); + var savedLinks = new java.util.ArrayList(); + doAnswer(call -> { savedLinks.add(call.getArgument(0)); return 1; }).when(links).insert(any(CargoDiscardCarton.class)); + when(links.listApplication("C1", 11L)).thenAnswer(call -> List.copyOf(savedLinks)); + doAnswer(call -> { eligible.set(false); return null; }).when(relabelStock).synchronize(anyList(), eq("C1")); + var firstRequest = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(101L)); + if (rowCount == 1) service.reserve("C1", "OP", application, firstRequest, LocalDateTime.now()); + else service.reserveAll("C1", "OP", application, List.of(firstRequest, + new CargoDiscardRequests.Item(null, null, "SKU", 1, 43L, List.of(102L))), LocalDateTime.now()); + assertFalse(eligible.get()); + assertEquals(rowCount, savedLinks.size()); + verify(commands, times(rowCount)).hold(eq("C1"), eq("OP"), any()); + verify(items, times(rowCount)).insert(any(CargoDiscardItem.class)); + verify(relabelStock).synchronize(argThat(values -> values.size() == rowCount), eq("C1")); + } /** 同一混装实箱的两个 SKU 保留完整快照,物理仓位只冻结一次,实箱只锁定和保存一次,漏 SKU 时不得产生任何冻结。 */ @Test void mixedTransferCartonRequiresAllSkusAndLocksPhysicalBoxOnce() { position.setAvailablePackages(1L); balance.setAvailablePackages(1L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java index 4f3e016c3..88758d520 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetPersistenceTest.java @@ -9,6 +9,7 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource; import java.math.BigDecimal; import java.nio.file.*; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.UUID; import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.*; @@ -18,7 +19,7 @@ class OrderBudgetPersistenceTest { OrderBudgetRepository repository; JdbcTemplate jdbc; @BeforeEach void setup() throws Exception { - var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", ""); jdbc = new JdbcTemplate(ds); String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); var match = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_order_budget_reservation \\(.*?;").matcher(sql); @@ -39,17 +40,20 @@ class OrderBudgetPersistenceTest { assertThat(repository.lockOrder("10","procurement","CG1")).isNull(); } @Test void selectsExpiredTerminalAndFailedReleaseButKeepsLiveOrders() { - var expired = row(1,"FREEZE_PENDING");expired.setCreatedAt(LocalDateTime.now().minusMinutes(16));repository.insert(expired); + var expired = row(1,"FREEZE_PENDING");expired.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(16));repository.insert(expired); var terminal = row(2,"FROZEN");terminal.setOrderNo("CG2");repository.insert(terminal); jdbc.update("INSERT INTO ll_overseas_procurement_order VALUES('9','CG2','COMPLETED')"); - var failed = row(3,"RELEASE_FAILED");failed.setReleaseRequested(true);failed.setUpdatedAt(LocalDateTime.now().minusMinutes(2));repository.insert(failed); + var failed = row(3,"RELEASE_FAILED");failed.setReleaseRequested(true);failed.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC).minusMinutes(2));repository.insert(failed); repository.insert(row(4,"FROZEN")); + repository.insert(row(5,"FREEZE_PENDING")); + var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure); assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L); } + /** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */ private OrderBudget row(long id,String status) { var row=new OrderBudget();row.setId(id);row.setCustomerId("9");row.setKind("procurement");row.setRequestKey("key"+id); row.setOriginalAmount(new BigDecimal("100"));row.setCurrency("USD");row.setAmountCny(new BigDecimal("800")); row.setStatus(status);row.setCommandVersion(1L);row.setReleaseRequested(false); - row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(LocalDateTime.now());return row; + row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));return row; } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java index be0f8021a..8f331e7cf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java @@ -3,6 +3,8 @@ import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.math.BigDecimal; import java.time.*; import java.util.*; @@ -63,6 +65,19 @@ class OrderBudgetServiceTest { assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING"); verify(repository).save(row); } + /** 已绑定订单的预算释放后仍允许原键恢复原单,但客户、金额与币种不能借重放改写。 */ + @ParameterizedTest @ValueSource(strings={"RELEASE_PENDING","RELEASED","RELEASE_FAILED"}) + void releasedBoundBudgetReplaysOnlyTheOriginalOrder(String status) { + row.setOrderNo("CG-1");row.setStatus(status);row.setReleaseRequested(true); + var view=service.prepare("9","procurement","key",new BigDecimal("100"),"USD"); + assertThat(view.usable()).isFalse();assertThat(view.orderNo()).isEqualTo("CG-1"); + assertThat(service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG-1",v->v)).isEqualTo("CG-1"); + assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"USD",()->"CG-OTHER",v->v)).hasMessageContaining("其他订单"); + assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("200"),"USD",()->"CG-1",v->v)).hasMessageContaining("变化"); + assertThatThrownBy(()->service.submit("9","procurement","key",new BigDecimal("100"),"CNY",()->"CG-1",v->v)).hasMessageContaining("变化"); + assertThatThrownBy(()->service.submit("10","procurement","key",new BigDecimal("100"),"USD",()->"CG-1",v->v)).hasMessageContaining("校验"); + verifyNoInteractions(wallet); + } private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) { return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null); } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 2fe63a877..1ac2ad0c9 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -702,7 +702,8 @@ $authAccountService = Read-RequiredText (Join-Path $backendRoot 'auth/src/main/j $iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java/com/baiyue/iam/service/MemberService.java') $adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue') $clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue') -$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue') +$clientCompanyProfileEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileEditView.vue') +$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MemberEditorView.vue') Assert-Contains $businessFacts '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界' Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界' Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界' @@ -713,7 +714,7 @@ Assert-Contains $iamOpenApi '公司联系电话,不作为登录标识且不要 Assert-Contains $authAccountService 'tokenSessionStore.revokeAll(account.getId())' 'Auth 登录标识变更会话撤销' Assert-Contains $iamMemberService 'authClient.revokeSessions' 'IAM 成员变更会话撤销' Assert-Contains $adminCustomersView '不会修改企业管理员登录手机号' 'Admin 公司联系电话提示' -Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机号' 'Client 公司联系电话提示' +Assert-Contains $clientCompanyProfileEditor '不会修改任何成员的登录手机号' 'Client 公司联系电话提示' Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示' $fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') $qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml') @@ -1056,7 +1057,10 @@ Assert-Contains $outboundDispatchOpenApi 'No partial success is possible.' '整 Assert-Contains $outboundDispatchOpenApi 'truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status' '拖车状态非门禁语义' Assert-Contains $carrierScheduleOpenApi 'strictly greater standard-node sequence than latestActualNode' '手工实际节点单调推进' Assert-Contains $carrierScheduleOpenApi 'estimated facts never advance actual progress' '预计节点不推进实际进度' -Assert-Contains $adminContainerDetail 'confirmDeparture' 'Admin 货柜详情确认离仓' +$adminCustomsDeparturePanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue') +Assert-Contains $adminContainerDetail ':departure-state="customsDepartureState"' 'Admin 货柜详情传递权威离仓状态' +Assert-Contains $adminCustomsDeparturePanel "departureState === 'DEPARTED'" 'Admin 报关面板展示实际离仓状态' +Assert-NotContains $adminContainerDetail 'confirmDeparture' 'Admin 货柜详情不恢复独立确认离仓' # 装柜完成后的整柜报关必须保持客户退税资料、平台冻结/完成与 File 二进制边界一致。 $firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex @@ -1147,6 +1151,8 @@ $containerFeeOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m $containerFeeController = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java') $containerFeeService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java') $overseasFinanceOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml') +$overseasFeeBusinessGuard = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java') +$overseasManualFeeService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasManualFeeService.java') $businessFeeEvent = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java') $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java') $directEventClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java') @@ -1162,8 +1168,9 @@ Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费 Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由' Assert-Contains $businessFacts '海外仓费用统一接入阶段0技术契约.md' '根海外费用阶段 0 按需入口' Assert-Contains $financeAgents '海外仓费用统一接入阶段0技术契约.md' 'Finance 海外费用阶段 0 按需入口' -Assert-Contains $lastlegAgents '海外仓费用统一接入阶段0技术契约.md' 'Lastleg 海外费用阶段 0 按需入口' -Assert-Contains $lastlegAgents '不能因文档存在宣称已实现' '阶段 0 设计与代码事实边界' +Assert-Contains $lastlegIndex 'docs/overseas-fees-storage-rent-release.md' 'Lastleg 海外费用与仓租当前发布清单' +Read-RequiredText (Join-Path $backendRoot 'lastleg-service/docs/overseas-fees-storage-rent-release.md') | Out-Null +Assert-Contains $rootAgents '代码、构建配置、实际部署和整体验收分别核对,不相互推定' '设计、代码与部署验收事实边界' Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界' Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界' Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI' @@ -1173,7 +1180,8 @@ Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{group Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口' Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验' Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验' -Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属' +Assert-Contains $lastlegIndex '费用必须匹配当前客户的真实服务单' 'Lastleg 海外费用真实业务归属' +Assert-Contains $overseasFeeBusinessGuard 'repository.owned(' 'Lastleg 费用服务校验业务及客户归属' # 在 BusinessType 枚举内逐项核对,新增类型或调整顺序不应使原有覆盖误报。 $businessTypeEnumMatch = [regex]::Match($overseasFinanceOpenApi, '(?s)BusinessType:\s*\{\s*type:\s*string,\s*enum:\s*\[([^\]]*)\]') Assert-Matches $overseasFinanceOpenApi 'BusinessType:\s*\{\s*type:\s*string,\s*enum:' '海外业务类型枚举定义' @@ -1182,7 +1190,11 @@ foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'PROCURE $checked++ if ($businessType -notin $businessTypeValues) { Add-Failure "海外 BusinessType 枚举缺少: $businessType" } } -Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约' +# 余额实扣与账单投递为两条链,不能要求余额费用再次进入账单。 +Assert-Matches $overseasFinanceOpenApi 'collectionMode:\s*\{\s*type:\s*string,\s*enum:\s*\[BILL,\s*WALLET\]' '海外费用区分账单与余额收款' +Assert-Matches $overseasManualFeeService 'if\s*\(approved\s*&&\s*walletCollection\)\s*send\(fee,\s*state,\s*"SETTLE"\)' '余额费用审核发送实扣指令' +Assert-Matches $overseasManualFeeService 'else\s+if\s*\(approved\)\s*outbox\.publish\(' '账单费用审核保留可靠投递' +Assert-Contains $overseasFinanceOpenApi '已发布BILL作废等待Finance持久化确认才VOIDED' '账单作废等待财务确认' Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口' Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型' Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本'