From 0993dd446e3eedf1f5167b73b70c6d10bd0da312 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 4 Nov 2026 15:55:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/docs/business-index.md | 2 +- .../overseas-fees-storage-rent-release.md | 2 + .../storagerent/dto/StorageRentDtos.java | 15 ++- .../StorageRentBusinessSourceMapper.java | 37 +++++- .../repository/StorageRentRepository.java | 8 ++ .../service/StorageRentReviewService.java | 1 + .../service/StorageRentService.java | 10 +- .../service/StorageRentSourceResolver.java | 62 ++++++++- .../service/StorageRentServiceTest.java | 25 ++++ ...rageRentTransferVolumePersistenceTest.java | 54 ++++++++ .../StorageRentTransferVolumeTest.java | 122 ++++++++++++++++++ 11 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumeTest.java diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 04df2a5bc..70b8b97f2 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -60,7 +60,7 @@ | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `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/OverseasManualFeeService.java`、`OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`;费用必须匹配当前客户的真实服务单,公共费用目录通过 Firstleg 内部接口读取。头程附加费审核后进入账单;海外人工应收创建先冻结、审核后扣款,驳回或取消释放,扣款成功后仅登记本地已收来源,不再推送财务账单;采购、换标原业务收费仍按其独立资金身份处理。头程账单费用及供应商应付经直接 Outbox 投递 Finance,余额应收仅通过资金指令取得扣款回执,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml`。新菜单列表权限与财务编辑、审核按钮权限分离;本地检查不能代替真实资金、账单联调 | -| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,中转部分出库按原批次份额计费,退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` | +| 自动仓租及出库资金门禁 | `storagerent/`;实际 PDA 收货建立原计租批次,头程自有海外仓目的地与仓储转运用中转阶梯,退货用原托阶梯;每24小时进一天,头程无逐箱测量时按原批次份额计费;仓储转运的排车、正常出库及废弃按实际箱长宽高汇总CBM计费,部分出库需明确箱唛,缺尺寸或来源不符则阻断,冻结快照保存实箱与体积;退货原托余一箱仍按整托计费,同托同计租日不重复收取,余货不重置库龄。退货已计费截止日与实际出库、费用生成同事务推进,后续只收新增库龄;并发推进后旧预占必须释放重建。实际出库命令前单独提交资金预校验,冻结成功后才允许扣库存并生成待审核仓租,订单费用与海外费用管理展示实际入库、出库、原计算额及实扣额;审核前可调价且说明必填,批准后沿原冻结按实额结算,驳回释放冻结;全部仓租使用余额及授信。客户仓租配置优先,无配置按报价表默认,内部查询失败不得回退。预占保存配置版本、完整阶梯、USD/CNY 原币及结算汇率快照,实际扣款不读最新价格。发布及真实验收清单见 `docs/overseas-fees-storage-rent-release.md` | | 美国/本土卡车订单 | `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`,车辆档案不含派送履约历史 | diff --git a/lastleg-service/docs/overseas-fees-storage-rent-release.md b/lastleg-service/docs/overseas-fees-storage-rent-release.md index d6a8cc447..e1affd830 100644 --- a/lastleg-service/docs/overseas-fees-storage-rent-release.md +++ b/lastleg-service/docs/overseas-fees-storage-rent-release.md @@ -18,6 +18,8 @@ 头程同单同仓多次实际入库合并为一个计租批次,统一从首次实际入库起算;补入库只追加箱数和计费体积,不重置库龄、免费期或已出库记录。单位体积沿用首次入库比例,后续修改订单体积不改写既有计租比例。其他业务保留各批实际入库时间分别计龄。头程未逐箱清点时,部分出库按来源单据箱数比例估算体积,审核前可调整金额并填写原因。本次不合并历史已存在的计租批次。 +仓储转运库存的正常出库、排车及废弃按本次实际箱长×宽×高÷1,000,000汇总CBM,再乘原入库库龄对应的客户阶梯累计单价,不按原批次箱数平均分摊体积。部分出库必须指明实际箱唛;整托可按该仓位全部可追溯实箱求和。缺少尺寸、箱数不符或无法核对原实收批次时阻断预校验。箱唛与实测体积一并固化在冻结快照,变化后须重新预校验。正常出库同事务保存实箱SHIPPED状态,废弃由原作业保存DISCARDED状态,避免后续重复计租。历史已生成费用保持原快照,不自动重算。本次修改不需要新增数据库字段,尚未发布到测试环境。 + | 规则 | 计费阶梯(USD) | 数量口径 | |---|---|---| | 中转仓租 | 第1–7天免费;8–30天0.8;31–60天1;61–90天1.5;91天起2 | CBM/天,按阶梯累加 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java index b0826e8b3..d2c44688a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/dto/StorageRentDtos.java @@ -34,13 +34,24 @@ public final class StorageRentDtos { public record Owner(String customerId, String customerName, String businessType, Long businessId, String businessNo, Long orderId, String inboundCode, String warehouseId) { } public record Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount, - LocalDateTime inboundAt, long days, Long previouslyChargedDays) { - /** 中转按数量分摊及既有快照不携带退货整托已计费天数。 */ + LocalDateTime inboundAt, long days, Long previouslyChargedDays, + BigDecimal actualVolumeCbm, List cartonMarks) { + /** 实测体积与原箱身份一并冻结,消费时核对同一批实物。 */ + public Allocation { cartonMarks=cartonMarks==null?List.of():List.copyOf(cartonMarks); } + /** 原托计费及历史快照不携带转运实测箱体积。 */ + public Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount, + LocalDateTime inboundAt, long days, Long previouslyChargedDays) { + this(lotId,shareId,quantity,amount,inboundAt,days,previouslyChargedDays,null,List.of()); + } + /** 普通来源及既有快照不携带退货整托已计费天数。 */ public Allocation(long lotId, Long shareId, BigDecimal quantity, BigDecimal amount, LocalDateTime inboundAt, long days) { this(lotId, shareId, quantity, amount, inboundAt, days, null); } } + /** 转运原批次对应的当前实箱测量,体积由服务端按厘米换算。 */ + public record TransferRentCarton(Long id,String cartonMark,String customerCartonMark,String status, + BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm) { } public record PricingTier(String id,Integer startDay,Integer endDay,BigDecimal unitPrice,Integer sortNo, String feeType,String billingUnit) { } public record Pricing(String configId,Long customerId,Integer version,String currency,List tiers,String source) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java index 728005d24..a55916b2b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/mapper/StorageRentBusinessSourceMapper.java @@ -3,9 +3,44 @@ package com.baiyue.lastleg.storagerent.mapper; import com.baiyue.lastleg.storagerent.entity.StorageRentBusinessSource; import org.apache.ibatis.annotations.*; -/** 同服务本地只读业务归属投影,不借用其他聚合的写 Mapper。 */ +/** 同服务计租业务归属、实箱测量及随出库原子保存的箱级状态。 */ @Mapper public interface StorageRentBusinessSourceMapper { + /** 只读取原实收批次、同客户同仓及当前托盘内的转运箱,禁止按箱数猜测体积。 */ + @Select(""" + SELECT c.id,c.carton_mark,c.customer_carton_mark,c.status,c.length_cm,c.width_cm,c.height_cm + FROM ll_storage_rent_lot l + JOIN ll_inbound_stock_receipt r ON l.source_key=CONCAT('INBOUND:',r.id) AND r.customer_id=l.customer_id + JOIN ll_warehouse_transfer_order t ON t.id=l.business_id AND t.customer_id=l.customer_id + AND t.operating_warehouse_id=l.warehouse_id + JOIN ll_inventory_position p ON p.id=#{position} AND p.customer_id=l.customer_id + AND p.overseas_warehouse_id=l.warehouse_id + JOIN ll_warehouse_transfer_pallet tp ON tp.transfer_order_id=t.id AND tp.customer_id=t.customer_id + AND tp.warehouse_pallet_id=r.warehouse_pallet_id AND tp.warehouse_pallet_id=p.pallet_id + AND tp.status='COMPLETED' + JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id + AND c.transfer_pallet_id=tp.id AND c.status IN ('COUNTED','DISCARD_PENDING') + JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id + AND i.transfer_order_id=t.id AND i.sku_code=r.sku_code + WHERE l.id=#{lot} AND l.customer_id=#{customer} AND l.warehouse_id=#{warehouse} + AND l.business_type='WAREHOUSE_TRANSFER' AND l.rule_code='TRANSIT' + ORDER BY c.id + FOR UPDATE + """) + java.util.List transferRentCartons( + @Param("customer") String customer,@Param("warehouse") String warehouse, + @Param("position") Long position,@Param("lot") long lot); + /** 普通出库同事务标记已计费实箱,后续部分出库不能重复选择原箱。 */ + @Update(""" + + """) + int shipTransferRentCartons(@Param("customer") String customer,@Param("transfer") Long transfer, + @Param("marks") java.util.List marks); /** 换标出库号解析该次独立服务单。 */ @Select("SELECT id,relabel_no AS business_no,lastleg_order_id FROM ll_relabel_application WHERE customer_id=#{customer} AND outbound_order_no=#{outbound} LIMIT 1") StorageRentBusinessSource relabel(@Param("customer") String customer,@Param("outbound") String outbound); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java index 1ec305169..a408e926f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/repository/StorageRentRepository.java @@ -22,6 +22,14 @@ import java.util.*; /** 仓租全部持久化及同服务来源解析,服务不直接访问 Mapper。 */ @Repository public class StorageRentRepository { + /** 转运计费只使用本仓位原实收批次可追溯的实箱尺寸。 */ + public List transferRentCartons(String customer,String warehouse,Long position,long lot) { + return businessSources.transferRentCartons(customer,warehouse,position,lot); + } + /** 箱级状态与仓租及实际库存共同提交,废弃状态由废弃作业推进。 */ + public int shipTransferRentCartons(String customer,Long transfer,List marks) { + return marks.isEmpty()?0:businessSources.shipTransferRentCartons(customer,transfer,marks); + } @Resource private StorageRentLotMapper lots; @Resource private StorageRentShareMapper shares; @Resource private StorageRentPreparationMapper preparations; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java index 400cec94d..48033b572 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentReviewService.java @@ -95,6 +95,7 @@ public class StorageRentReviewService implements StorageRentFeeWorkflow { boolean whole=parts.get(0).previouslyChargedDays()!=null; var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add); var units=whole?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING) + :parts.get(0).actualVolumeCbm()!=null?parts.stream().map(Allocation::actualVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add) :lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP); long previous=whole?parts.get(0).previouslyChargedDays():0;long days=parts.get(0).days(); calculations.add(new Calculation(id,whole?"RETURN_PALLET":"RETURN".equals(lot.getRuleCode())?"RETURN_SHARE":"TRANSIT", diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java index 8cfb51461..b6f947d1c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/storagerent/service/StorageRentService.java @@ -298,6 +298,10 @@ public class StorageRentService implements WalletResultHandler { for(var allocation:saved.allocations().stream().sorted(Comparator.comparingLong(Allocation::lotId)).toList()){ var lot=repository.lockLot(allocation.lotId()); if(lot==null||!value.getCustomerId().equals(lot.getCustomerId())||lot.getRemainingQuantity().compareTo(allocation.quantity())<0)throw conflict("原计租批次剩余数量已变化"); + // 转运按实箱结算,普通出库立即保留箱级离仓事实;废弃仍由原扣减流程改为 DISCARDED。 + if(allocation.actualVolumeCbm()!=null&&!"DISCARD".equals(request.mode()) + &&repository.shipTransferRentCartons(value.getCustomerId(),lot.getBusinessId(),allocation.cartonMarks())!=allocation.cartonMarks().size()) + throw conflict("转运计租实箱已出库或被其他作业占用,请重新预校验"); if(allocation.shareId()!=null){var share=repository.lockShare(allocation.shareId()); if(share==null||share.getQuantity().compareTo(allocation.quantity())<0)throw conflict("退货计租商品份额已变化"); share.setQuantity(share.getQuantity().subtract(allocation.quantity()));repository.save(share);} @@ -447,12 +451,14 @@ public class StorageRentService implements WalletResultHandler { if("SETTLE".equals(operation))value.setSettleEventId(event);else value.setReleaseEventId(event); value.setErrorMessage(null);value.setUpdatedAt(now);repository.save(value); } - /** 原托计费期间变化必须重建预占释放旧冻结,避免两次并发出库重复收整托。 */ + /** 原托期间、转运实际体积或选箱变化必须重建预占,不能沿用其他货物的冻结。 */ private static boolean sameBasis(Plan a,Plan b) { if(!Objects.equals(a.owner(),b.owner())||a.allocations().size()!=b.allocations().size())return false; for(int i=0;i(); var chargedLots=new HashSet(); @@ -43,7 +43,12 @@ public class StorageRentSourceResolver { ?StorageRentCalculator.returnPalletAmount(pricing,lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),previous) :BigDecimal.ZERO; allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),amount,a.inboundAt(),a.days(),previous)); - } else allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()),a.inboundAt(),a.days())); + } else { + var amount=a.actualVolumeCbm()==null + ?StorageRentCalculator.amount(pricing,lot.getRuleCode(),lot.getInboundAt(),plan.calculatedAt(),lot.getOriginalUnits(),lot.getOriginalQuantity(),a.quantity()) + :StorageRentCalculator.unitAmount(pricing,"TRANSIT",a.days()).multiply(a.actualVolumeCbm()); + allocations.add(new Allocation(a.lotId(),a.shareId(),a.quantity(),amount,a.inboundAt(),a.days(),null,a.actualVolumeCbm(),a.cartonMarks())); + } } var calculations=new ArrayList(); for(long id:allocations.stream().map(Allocation::lotId).distinct().toList()) { @@ -51,6 +56,7 @@ public class StorageRentSourceResolver { var quantity=parts.stream().map(Allocation::quantity).reduce(BigDecimal.ZERO,BigDecimal::add); boolean returns="RETURN".equals(lot.getRuleCode());long previous=returns?lot.getChargedDays():0; var units=returns?lot.getOriginalUnits().setScale(0,RoundingMode.CEILING) + :parts.get(0).actualVolumeCbm()!=null?parts.stream().map(Allocation::actualVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add) :lot.getOriginalUnits().multiply(quantity).divide(lot.getOriginalQuantity(),12,RoundingMode.HALF_UP); long days=parts.get(0).days(); calculations.add(new Calculation(id,returns?"RETURN_PALLET":"TRANSIT",units,returns?"PALLET":"CBM",days,previous, @@ -125,7 +131,9 @@ public class StorageRentSourceResolver { } var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); if(total.compareTo(BigDecimal.valueOf(request.packages()))<0)throw conflict("原实收批次计租数量不足,不能出库"); - allocations=proportionShares(shares,BigDecimal.valueOf(request.packages()),total,at); + var selectedMarks=request.cartonMarks()==null?List.of():request.cartonMarks(); + if(selectedMarks.isEmpty()&&request.cartonMark()!=null&&!request.cartonMark().isBlank())selectedMarks=List.of(request.cartonMark()); + allocations=stockAllocations(shares,position,request.packages(),selectedMarks,false,at); if(owner.businessId()==null||owner.businessNo()==null)owner=business(customer,balance.getCustomerName(),"LASTLEG_ORDER",null,balance.getInboundOrderId(),warehouse,balance.getInboundCode()); } return new Plan(owner,allocations,at,allocations.stream().map(Allocation::amount).reduce(BigDecimal.ZERO,BigDecimal::add).setScale(2,RoundingMode.HALF_UP)); @@ -153,7 +161,13 @@ public class StorageRentSourceResolver { if(shares.isEmpty()){ var balance=repository.balance(position.getBalanceId());var order=balance==null?null:repository.order(balance.getInboundOrderId()); if(order==null||StorageRentService.eligible(order))throw conflict("废弃原实收批次缺少计租记录"); - }else if(line.actualPackages()>0){var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add);if(total.compareTo(BigDecimal.valueOf(line.actualPackages()))<0)throw conflict("废弃原计租批次数量不足");result.addAll(proportionShares(shares,BigDecimal.valueOf(line.actualPackages()),total,at));} + }else if(line.actualPackages()>0){ + var links=discards.cartons(customer,application.getId()).stream().filter(c->item.getId().equals(c.getItemId())).map(c->c.getCartonMark()).toList(); + var scanned=line.scannedCartonMarks()!=null?line.scannedCartonMarks():request.scannedCartonMarks(); + // 有申请选箱时只从本行原箱中取实际扫码箱,不能借其他行箱唛扩大计费范围。 + var selected=scanned==null||scanned.isEmpty()?links:links.isEmpty()?scanned:links.stream().filter(scanned::contains).toList(); + result.addAll(stockAllocations(shares,position,line.actualPackages(),selected,true,at)); + } } if(!Objects.equals(pallet==null||pallet.isBlank()?"散箱":pallet.trim(),request.palletNo().trim()))throw conflict("废弃明细不属于本次托盘"); } @@ -177,6 +191,46 @@ public class StorageRentSourceResolver { for(int i=0;i0)result.add(allocation(repository.lockLot(share.getLotId()),share.getId(),part,at));} return result; } + /** 转运按实际箱尺寸计费,数量仅用于扣减原库存;整托也必须有完整箱级测量。 */ + private List stockAllocations(List shares, + com.baiyue.lastleg.inventory.entity.InventoryPosition position,long packages,List marks, + boolean discard,LocalDateTime at) { + var total=shares.stream().map(StorageRentShare::getQuantity).reduce(BigDecimal.ZERO,BigDecimal::add); + if(total.compareTo(BigDecimal.valueOf(packages))<0)throw conflict("原实收批次计租数量不足,不能出库"); + var transfer=shares.stream().filter(s->{var lot=repository.lockLot(s.getLotId());return lot!=null&&"WAREHOUSE_TRANSFER".equals(lot.getBusinessType())&&"TRANSIT".equals(lot.getRuleCode());}).toList(); + if(transfer.isEmpty())return proportionShares(shares,BigDecimal.valueOf(packages),total,at); + if(transfer.size()!=shares.size())throw conflict("转运计租来源混合,需按实际箱核对原批次"); + var selected=new HashSet(); + for(String mark:marks)if(mark==null||mark.isBlank()||!selected.add(mark.trim()))throw conflict("转运计租箱唛不能为空或重复"); + var result=new ArrayList();var matched=new HashSet();var physical=new HashSet();long count=0; + for(var share:transfer){ + var lot=repository.lockLot(share.getLotId()); + var boxes=repository.transferRentCartons(position.getCustomerId(),position.getOverseasWarehouseId(),position.getId(),lot.getId()); + var chosen=boxes.stream().filter(c->discard?"DISCARD_PENDING".equals(c.status()):"COUNTED".equals(c.status())) + .filter(c->selected.isEmpty()||selected.contains(c.cartonMark())||selected.contains(c.customerCartonMark())).toList(); + if(chosen.isEmpty())continue; + BigDecimal volume=BigDecimal.ZERO;var canonical=new ArrayList(); + for(var box:chosen){ + if(!physical.add(box.id())||box.cartonMark()==null)throw conflict("转运箱的原计租批次不唯一"); + if(box.lengthCm()==null||box.widthCm()==null||box.heightCm()==null + ||box.lengthCm().signum()<=0||box.widthCm().signum()<=0||box.heightCm().signum()<=0)throw conflict("转运箱缺少实际长宽高,不能计算仓租"); + volume=volume.add(box.lengthCm().multiply(box.widthCm()).multiply(box.heightCm()).movePointLeft(6)); + canonical.add(box.cartonMark()); + if(selected.contains(box.cartonMark()))matched.add(box.cartonMark()); + if(selected.contains(box.customerCartonMark()))matched.add(box.customerCartonMark()); + } + var quantity=BigDecimal.valueOf(chosen.size()); + if(quantity.compareTo(share.getQuantity())>0)throw conflict("转运实箱与原计租剩余数量不一致"); + canonical.sort(String::compareTo);long days=StorageRentCalculator.days(lot.getInboundAt(),at); + result.add(new Allocation(lot.getId(),share.getId(),quantity, + StorageRentCalculator.unitAmount("TRANSIT",days).multiply(volume),lot.getInboundAt(),days,null,volume,canonical)); + count+=chosen.size(); + } + // 无逐箱扫码仅允许本仓位全部实箱一次出库,部分出库不得挑选平均体积或猜箱。 + if(count!=packages||!matched.equals(selected)||!selected.isEmpty()&&selected.size()!=packages) + throw conflict("转运仓租需提交本次实际出库箱唛,数量或原批次尺寸记录不完整"); + return result; + } /** 商品份额仅追溯原托及扣减库存;金额在完整扫码汇总后按原托去重计算。 */ private List returnAllocations(String customer,String warehouse,String mark,String sku,Long quantity,LocalDateTime at) { var carton=repository.carton(mark);if(carton==null||!customer.equals(carton.getCustomerId())||!warehouse.equals(carton.getOverseasWarehouseId()))throw conflict("原退货箱不属于当前客户和仓库"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java index e2b29d7ac..43d572839 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentServiceTest.java @@ -82,6 +82,31 @@ class StorageRentServiceTest { var fee=new OverseasFeeSource();fee.setSourceFeeId(701L);when(fees.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(fee); } + /** 同数量同金额也必须核对原箱身份,实测体积变化不能继续沿用冻结。 */ + @ParameterizedTest @ValueSource(booleans={false,true}) + void transferVolumeOrCartonChangeInvalidatesFreeze(boolean changeVolume) { + var a=plan.allocations().get(0); + plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),a.amount(),a.inboundAt(),a.days(),null,new BigDecimal("0.036"),List.of("LARGE"))),plan.calculatedAt(),plan.amount()); + ready(); + plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),a.amount(),a.inboundAt(),a.days(),null, + new BigDecimal(changeVolume?"0.035":"0.036"),List.of(changeVolume?"LARGE":"OTHER"))),plan.calculatedAt(),plan.amount()); + assertThatThrownBy(()->service.consume("9",request)).hasMessageContaining("计费时间或货物已变化"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("10"); + verifyNoInteractions(fees); + } + + /** 成功消费实箱后同事务保存离仓状态,后续扫码不能再次使用这些箱计费。 */ + @Test void transferConsumptionPersistsCartonIdentityWithOriginalVolume() { + var a=plan.allocations().get(0);lot.setBusinessId(81L); + plan=new Plan(plan.owner(),List.of(new Allocation(a.lotId(),a.shareId(),a.quantity(),a.amount(),a.inboundAt(),a.days(),null, + new BigDecimal("0.036"),List.of("LARGE"))),plan.calculatedAt(),plan.amount()); + when(repository.shipTransferRentCartons("101",81L,List.of("LARGE"))).thenReturn(1); + ready();service.consume("9",request); + verify(repository).shipTransferRentCartons("101",81L,List.of("LARGE")); + assertThat(current.getPlanJson()).contains("0.036").contains("LARGE"); + assertThat(lot.getRemainingQuantity()).isEqualByComparingTo("6"); + } + /** 未收到成功冻结回执时,原库存与计租份额都不能扣减。 */ @Test void pendingOrFailedFreezeCannotConsumeInventory() { assertThat(service.prepare("9",request).ready()).isFalse(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumePersistenceTest.java new file mode 100644 index 000000000..4066bff86 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumePersistenceTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.storagerent.mapper.StorageRentBusinessSourceMapper; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +/** 执行实际Mapper验证实收批次、客户、仓位、原托隔离及实箱离仓更新。 */ +class StorageRentTransferVolumePersistenceTest { + /** 大小箱测量按原批次读取,其他仓位和客户不得混入,出库后不再返回原箱。 */ + @Test void readsMeasuredCartonsFromExactReceiptAndUpdatesOnlyOwnedCarton() { + var ds=new DriverManagerDataSource("jdbc:h2:mem:rent-volume-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_storage_rent_lot(id BIGINT,source_key VARCHAR(100),customer_id VARCHAR(30),warehouse_id VARCHAR(30),business_id BIGINT,business_type VARCHAR(40),rule_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(30),warehouse_pallet_id BIGINT,sku_code VARCHAR(30))"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(30),operating_warehouse_id VARCHAR(30))"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(30),overseas_warehouse_id VARCHAR(30),pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,transfer_order_id BIGINT,customer_id VARCHAR(30),warehouse_pallet_id BIGINT,status VARCHAR(30))"); + db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,transfer_order_id BIGINT,customer_id VARCHAR(30),sku_code VARCHAR(30))"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,transfer_order_id BIGINT,customer_id VARCHAR(30),transfer_pallet_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR(30),customer_carton_mark VARCHAR(30),status VARCHAR(30),length_cm DECIMAL(12,2),width_cm DECIMAL(12,2),height_cm DECIMAL(12,2),updated_at TIMESTAMP)"); + db.update("INSERT INTO ll_storage_rent_lot VALUES(51,'INBOUND:101','A','WH',81,'WAREHOUSE_TRANSFER','TRANSIT')"); + db.update("INSERT INTO ll_inbound_stock_receipt VALUES(101,'A',31,'SKU')"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(81,'A','WH')"); + db.update("INSERT INTO ll_inventory_position VALUES(21,'A','WH',31),(22,'A','WH',32),(23,'B','WH',31)"); + db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(71,81,'A',31,'COMPLETED'),(72,81,'A',32,'COMPLETED')"); + db.update("INSERT INTO ll_warehouse_transfer_item VALUES(91,81,'A','SKU'),(92,81,'A','OTHER')"); + db.update("INSERT INTO ll_warehouse_transfer_carton VALUES(1,81,'A',71,91,'SMALL',NULL,'COUNTED',30,20,20,NULL),(2,81,'A',71,91,'LARGE',NULL,'DISCARD_PENDING',40,30,30,NULL),(3,81,'B',71,91,'FOREIGN',NULL,'COUNTED',40,30,30,NULL),(4,81,'A',72,91,'OTHER-PALLET',NULL,'COUNTED',40,30,30,NULL),(5,81,'A',71,92,'OTHER-SKU',NULL,'COUNTED',40,30,30,NULL)"); + var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true);config.addMapper(StorageRentBusinessSourceMapper.class); + try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) { + var repository=new StorageRentRepository();ReflectionTestUtils.setField(repository,"businessSources",session.getMapper(StorageRentBusinessSourceMapper.class)); + var boxes=repository.transferRentCartons("A","WH",21L,51L); + assertThat(boxes).extracting(b->b.cartonMark()).containsExactly("SMALL","LARGE"); + assertThat(boxes.get(1).lengthCm()).isEqualByComparingTo("40"); + assertThat(repository.transferRentCartons("B","WH",21L,51L)).isEmpty(); + assertThat(repository.transferRentCartons("A","OTHER",21L,51L)).isEmpty(); + assertThat(repository.transferRentCartons("A","WH",22L,51L)).isEmpty(); + assertThat(repository.transferRentCartons("A","WH",23L,51L)).isEmpty(); + assertThat(repository.shipTransferRentCartons("B",81L,List.of("SMALL"))).isZero(); + assertThat(repository.shipTransferRentCartons("A",81L,List.of("SMALL","LARGE"))).isEqualTo(1); + assertThat(repository.transferRentCartons("A","WH",21L,51L)).extracting(b->b.cartonMark()).containsExactly("LARGE"); + assertThat(repository.shipTransferRentCartons("A",81L,List.of("SMALL"))).isZero(); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumeTest.java new file mode 100644 index 000000000..a5f0223d3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/storagerent/service/StorageRentTransferVolumeTest.java @@ -0,0 +1,122 @@ +package com.baiyue.lastleg.storagerent.service; + +import com.baiyue.lastleg.storagerent.dto.StorageRentDtos.*; +import com.baiyue.lastleg.storagerent.entity.*; +import com.baiyue.lastleg.storagerent.repository.StorageRentRepository; +import com.baiyue.lastleg.storagerent.gateway.StorageRentPricingGateway; +import com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository; +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos.ActualLine; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.inventory.entity.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 大小箱混托必须按实际出库箱体积计租,数量不再充当体积比例。 */ +class StorageRentTransferVolumeTest { + private final StorageRentRepository repository=mock(StorageRentRepository.class); + private final CargoDiscardPdaRepository discards=mock(CargoDiscardPdaRepository.class); + private final StorageRentPricingGateway pricing=mock(StorageRentPricingGateway.class); + private final StorageRentSourceResolver resolver=new StorageRentSourceResolver(); + private final LocalDateTime at=LocalDateTime.of(2026,11,4,7,37); + private final TransferRentCarton small=box(1L,"SMALL","COUNTED","30","20","20"); + private final TransferRentCarton large=box(2L,"LARGE","COUNTED","40","30","30"); + + /** 原批次两箱共0.048立方米,库龄96天,模拟本次真实单据。 */ + @BeforeEach void setup() { + ReflectionTestUtils.setField(resolver,"repository",repository); + ReflectionTestUtils.setField(resolver,"discards",discards); + ReflectionTestUtils.setField(resolver,"pricingGateway",pricing); + when(pricing.current("101")).thenReturn(StorageRentCalculator.defaults(101L)); + var lot=new StorageRentLot();lot.setId(51L);lot.setBusinessType("WAREHOUSE_TRANSFER");lot.setRuleCode("TRANSIT"); + lot.setOriginalQuantity(new BigDecimal("2"));lot.setRemainingQuantity(new BigDecimal("2")); + lot.setOriginalUnits(new BigDecimal("0.048"));lot.setInboundAt(at.minusDays(96)); + var share=new StorageRentShare();share.setId(61L);share.setLotId(51L);share.setQuantity(new BigDecimal("2")); + var position=new InventoryPosition();position.setId(21L);position.setCustomerId("101");position.setOverseasWarehouseId("9"); + position.setBalanceId(71L);position.setBatchNo("ZY-1");position.setPalletNo("PALLET"); + var balance=new InventoryBalance();balance.setCustomerId("101");balance.setInboundOrderId(91L); + var transfer=new StorageRentBusinessSource();transfer.setId(81L);transfer.setBusinessNo("ZY-1");transfer.setInboundOrderId(91L); + var dispatch=new DispatchAggregate();dispatch.setId(1L);dispatch.setStatus("ACTIVE");dispatch.setStage("LOADING");dispatch.setOverseasWarehouseId("9"); + var item=new DispatchItem();item.setDispatchOrderId(1L);item.setCustomerId("101");item.setOverseasWarehouseId("9"); + item.setSourceBusinessType("WAREHOUSE_TRANSFER");item.setSourceBusinessNo("ZY-1"); + when(repository.lockLot(51L)).thenReturn(lot);when(repository.stockShares("101","9",71L,"ZY-1")).thenReturn(List.of(share)); + when(repository.position(21L)).thenReturn(position);when(repository.balance(71L)).thenReturn(balance); + when(repository.transfer("101","ZY-1")).thenReturn(transfer); + when(repository.dispatch("PC-1")).thenReturn(dispatch);when(repository.dispatchItem(11L)).thenReturn(item); + when(repository.transferRentCartons("101","9",21L,51L)).thenReturn(List.of(small,large)); + } + + /** 大箱0.036立方米应收3.79美元,小箱0.012立方米应收1.26美元。 */ + @Test void dispatchUsesSelectedCartonVolumeAndKeepsSavedPricing() { + var plan=resolver.resolve("9",request(1,List.of("LARGE")),at); + assertThat(plan.amount()).isEqualByComparingTo("3.79"); + assertThat(plan.calculations().get(0).billingQuantity()).isEqualByComparingTo("0.036"); + assertThat(plan.allocations().get(0).quantity()).isEqualByComparingTo("1"); + assertThat(plan.allocations().get(0).cartonMarks()).containsExactly("LARGE"); + clearInvocations(pricing); + assertThat(resolver.resolve("9",request(1,List.of("LARGE")),at,plan.pricing()).amount()).isEqualByComparingTo("3.79"); + verifyNoInteractions(pricing); + assertThat(resolver.resolve("9",request(1,List.of("SMALL")),at).amount()).isEqualByComparingTo("1.26"); + } + + /** 整托按所有实箱体积求和,未指定箱唛的部分出库必须阻断。 */ + @Test void wholePalletSumsRealVolumesButPartialPickNeedsIdentity() { + var plan=resolver.resolve("9",request(2,List.of()),at); + assertThat(plan.amount()).isEqualByComparingTo("5.06"); + assertThat(plan.calculations().get(0).billingQuantity()).isEqualByComparingTo("0.048"); + assertThatThrownBy(()->resolver.resolve("9",request(1,List.of()),at)).hasMessageContaining("实际出库箱唛"); + assertThatThrownBy(()->resolver.resolve("9",request(1,List.of("OTHER")),at)).hasMessageContaining("实际出库箱唛"); + assertThatThrownBy(()->resolver.resolve("9",request(2,List.of("LARGE","LARGE")),at)).hasMessageContaining("重复"); + } + + /** 直接出库与排车使用同一真实计费体积。 */ + @Test void outboundUsesActualVolume() { + var task=new OutboundPdaTask();task.setCustomerId("101");task.setOutboundOrderId(41L);task.setStatus("PENDING"); + var outbound=new OutboundOrder();outbound.setOverseasWarehouseId("9");outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); + var item=new OutboundPalletItem();item.setCustomerId("101");item.setOutboundOrderId(41L);item.setOrderRefs("ZY-1"); + when(repository.task("9","TASK")).thenReturn(task);when(repository.outbound(41L)).thenReturn(outbound);when(repository.outboundItem(11L)).thenReturn(item); + var request=new PrepareRequest("OUTBOUND","TASK",11L,21L,1L,null,null,"scan",List.of("LARGE"),null,null); + assertThat(resolver.resolve("9",request,at).amount()).isEqualByComparingTo("3.79"); + } + + /** 废弃选中的大箱仍按0.036立方米计费,预留状态不使它被平均分摊。 */ + @Test void discardBillsOnlyTheReservedActualCarton() { + var application=new CargoDiscardApplication();application.setId(101L);application.setCustomerId("101");application.setDiscardNo("CD-1");application.setStatus("APPROVED"); + var item=new CargoDiscardItem();item.setId(201L);item.setApplicationId(101L);item.setCustomerId("101");item.setDiscardPackages(1);item.setInventoryPositionId(21L); + var link=new CargoDiscardCarton();link.setItemId(201L);link.setCartonMark("LARGE"); + when(discards.lockApplication("CD-1","9")).thenReturn(application);when(discards.item(201L)).thenReturn(item);when(discards.cartons("101",101L)).thenReturn(List.of(link)); + when(repository.transferRentCartons("101","9",21L,51L)).thenReturn(List.of(small,box(2L,"LARGE","DISCARD_PENDING","40","30","30"))); + var request=new PrepareRequest("DISCARD","CD-1",null,null,null,null,"PALLET","scan",null,null,null, + List.of(new ActualLine(201L,1)),List.of(),List.of("LARGE"),false); + var plan=resolver.resolve("9",request,at); + assertThat(plan.amount()).isEqualByComparingTo("3.79"); + assertThat(plan.calculations().get(0).billingQuantity()).isEqualByComparingTo("0.036"); + assertThat(plan.owner().businessNo()).isEqualTo("CD-1"); + } + + /** 缺测量、错误来源或已被废弃预留的箱不允许降级成平均体积。 */ + @Test void missingMeasurementsAndUnavailableCartonsBlockCharging() { + when(repository.transferRentCartons("101","9",21L,51L)).thenReturn(List.of(box(2L,"LARGE","COUNTED","0","30","30"))); + assertThatThrownBy(()->resolver.resolve("9",request(1,List.of("LARGE")),at)).hasMessageContaining("实际长宽高"); + when(repository.transferRentCartons("101","9",21L,51L)).thenReturn(List.of()); + assertThatThrownBy(()->resolver.resolve("9",request(1,List.of("LARGE")),at)).hasMessageContaining("实际出库箱唛"); + when(repository.transferRentCartons("101","9",21L,51L)).thenReturn(List.of(box(2L,"LARGE","DISCARD_PENDING","40","30","30"))); + assertThatThrownBy(()->resolver.resolve("9",request(1,List.of("LARGE")),at)).hasMessageContaining("实际出库箱唛"); + } + + /** 创建真实排车扫码请求,费用客户和仓库仅由持久化单据给出。 */ + private PrepareRequest request(long packages,List marks) { + return new PrepareRequest("DISPATCH","PC-1",11L,21L,packages,null,null,"scan",marks,null,null); + } + /** 尺寸保持厘米原精度,不预先将单箱体积舍入到三位。 */ + private TransferRentCarton box(Long id,String mark,String status,String length,String width,String height) { + return new TransferRentCarton(id,mark,null,status,new BigDecimal(length),new BigDecimal(width),new BigDecimal(height)); + } +}