diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V30__add_fba_appointment_replacement_numbers.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V30__add_fba_appointment_replacement_numbers.sql new file mode 100644 index 000000000..6c75426d9 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V30__add_fba_appointment_replacement_numbers.sql @@ -0,0 +1,17 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 补齐预约分批/合并后的替代预约业务号快照,供入仓计划查询过滤原预约。 +-- 可重复执行;不修改既有预约数据。 +SET @ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'll_inbound_fba_appointment' + AND column_name = 'replacement_nos_json' + ), + 'SELECT 1', + 'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN replacement_nos_json LONGTEXT NULL COMMENT ''分批或合并后的新预约业务号列表,非空时原批次只读'' AFTER selected_count' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockLedgerRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockLedgerRepository.java new file mode 100644 index 000000000..ec641c963 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockLedgerRepository.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Repository; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; + +/** 废弃实物扣减对应的退货余额和普通库存总账。 */ +@Repository +public class CargoDiscardStockLedgerRepository { + @Resource private ReturnInventoryBalanceMapper returnBalances; + @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + + /** 箱内 SKU 扣减时同步扣汇总,只有该 SKU 在箱内耗尽才减一箱。 */ + public void deductReturn(String customerId, CargoDiscardApplication application, CargoDiscardItem item, + long before, long quantity) { + var carton = returnCartons.lockId(item.getReturnCartonId()); + if (carton == null || !customerId.equals(carton.getCustomerId()) + || !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !"DISCARD_PENDING".equals(carton.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货废弃箱归属或状态已变化"); + } + long boxes = before == quantity && !"PARCEL".equals(carton.getSourceUnitType()) ? 1 : 0; + var balance = returnBalances.lockSku(customerId, carton.getOverseasWarehouseId(), item.getSkuCode()); + if (balance == null || returnBalances.deductConversion(customerId, carton.getOverseasWarehouseId(), + item.getSkuCode(), quantity, boxes) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货库存汇总数量不足,不能完成废弃"); + } + } + + /** 一件代发已扣实物仓位后,按同事务余额快照追加库存总账。 */ + public void recordDropship(String customerId, CargoDiscardApplication application, CargoDiscardItem item, + InventoryPosition position, long quantity, String operator, LocalDateTime at) { + var balance = balances.selectById(position.getBalanceId()); + if (balance == null || !customerId.equals(balance.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "废弃库存余额不存在"); + } + var fact = new InventoryLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setCustomerName(balance.getCustomerName()); + fact.setBalanceId(balance.getId()); fact.setPositionId(position.getId()); + fact.setOverseasWarehouseId(balance.getOverseasWarehouseId()); fact.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + fact.setInboundOrderId(balance.getInboundOrderId()); fact.setInboundCode(balance.getInboundCode()); + fact.setInboundOrderNo(balance.getInboundOrderNo()); fact.setPalletId(position.getPalletId()); + fact.setPalletNo(position.getPalletNo()); fact.setBatchNo(position.getBatchNo()); + fact.setWarehouseLocationCode(position.getWarehouseLocationCode()); fact.setSkuCode(item.getSkuCode()); + fact.setBusinessType("DISCARD"); fact.setBusinessOrderId(application.getId()); fact.setBusinessOrderNo(application.getDiscardNo()); + fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + item.getId() + ":STOCK"); + fact.setStatusFrom("AVAILABLE"); fact.setStatusTo("DISCARDED"); + fact.setBeforePackages(balance.getAvailablePackages() + quantity); fact.setDeltaPackages(-quantity); + fact.setAfterPackages(balance.getAvailablePackages()); fact.setOperatorId(operator); + fact.setRemark("货物废弃"); fact.setOccurredAt(at); fact.setCreatedAt(at); + if (ledgers.insert(fact) != 1) throw new IllegalStateException("废弃库存流水写入失败"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java index 82020dc5e..06c8ba23b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -33,6 +33,7 @@ import java.util.Map; /** Final inventory mutation for a warehouse-approved cargo discard. */ @Service public class CargoDiscardInventoryAdjustmentService { + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockLedgerRepository stockLedger; @Resource private CargoDiscardInventoryMapper inventory; @Resource private CargoDiscardStockService stockService; @Resource private DropshipCartonLedgerMapper cartonLedgers; @@ -44,6 +45,7 @@ public class CargoDiscardInventoryAdjustmentService { @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private ReturnInventoryCartonItemMapper returnCartonItems; + /** 实物废弃、来源库存扣减与流水在同一事务内完成。 */ @Transactional(propagation = Propagation.MANDATORY) public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application, CargoDiscardItem item, List links, @@ -53,7 +55,7 @@ public class CargoDiscardInventoryAdjustmentService { stockService.discard(customerId, operatorId, application, item, actualPackages); return new AdjustmentResult(item.getDiscardPackages(), actualPackages, 0); } - if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages); + if (isReturnItem(item)) return discardReturnItem(customerId, application, item, actualPackages); if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量"); DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode()); @@ -97,10 +99,12 @@ public class CargoDiscardInventoryAdjustmentService { } long after = value(inventory.skuPackages(customerId, sku.getId())); if (after != before - actualPackages) throw conflict("托盘 SKU 库存扣减不守恒"); + stockLedger.recordDropship(customerId, application, item, position, actualPackages, operatorId, occurredAt); return new AdjustmentResult(before, actualPackages, after); } - private AdjustmentResult discardReturnItem(String customerId, CargoDiscardItem item, int actualPackages) { + /** 退货箱按件扣减,同时同步库存余额;完成事实由明细的 PDA 完成快照提供。 */ + private AdjustmentResult discardReturnItem(String customerId, CargoDiscardApplication application, CargoDiscardItem item, int actualPackages) { if (actualPackages < 0 || actualPackages > item.getDiscardPackages()) { throw conflict("实际废弃数量超出退货库存申请数量"); } @@ -110,6 +114,7 @@ public class CargoDiscardInventoryAdjustmentService { if (before < actualPackages || (actualPackages > 0 && returnCartonItems.deduct(sku.getId(), actualPackages) != 1)) { throw conflict("退货库存数量不足"); } + if (actualPackages > 0) stockLedger.deductReturn(customerId, application, item, before, actualPackages); return new AdjustmentResult(before, actualPackages, before - actualPackages); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 1589e311a..141b3a2d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -26,7 +26,10 @@ public final class ReturnInventoryResponses { int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, LocalDateTime receivedAt, List items, String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory, - long relabelledQuantity, long relabelReservedQuantity, List skuConversions) { } + long relabelledQuantity, long relabelReservedQuantity, List skuConversions, + List discardHistory) { } + public record DiscardHistory(String discardNo, String skuCode, String productNameZh, long beforeQuantity, + long quantity, long afterQuantity, String operatorId, LocalDateTime occurredAt) { } public record SkuConversion(String relabelNo, String cartonMark, String oldSkuCode, String newSkuCode, long quantity, String status, LocalDateTime completedAt) { public static SkuConversion from(com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion row) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnDiscardMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnDiscardMovementMapper.java new file mode 100644 index 000000000..8c74cb275 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnDiscardMovementMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.returnreceipt.mapper; + +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** 从不可重复执行的 PDA 完成快照读取退货废弃事实,兼容已完成的历史单据。 */ +@Mapper +public interface ReturnDiscardMovementMapper { + String FACTS = " FROM ll_cargo_discard_item i JOIN ll_cargo_discard_application a " + + "ON a.id=i.application_id AND a.customer_id=i.customer_id " + + "JOIN ll_return_inventory_carton c ON c.id=i.return_carton_id AND c.customer_id=i.customer_id " + + "AND c.overseas_warehouse_id=a.overseas_warehouse_id " + + "WHERE i.pda_status='COMPLETED' AND i.actual_discard_packages>0 "; + String MOVEMENTS = "SELECT i.customer_id customerId,a.overseas_warehouse_id overseasWarehouseId,i.sku_code skuCode,i.product_name productNameZh," + + "'DISCARD' movementType,a.discard_no referenceNo,-i.actual_discard_packages totalDelta,-i.actual_discard_packages availableDelta,i.pda_completed_at occurredAt" + FACTS; + + /** 按客户与实物箱读取每次废弃的件数、操作者和操作时间。 */ + @Select("SELECT a.discard_no discardNo,i.sku_code skuCode,i.product_name productNameZh," + + "i.available_packages_snapshot beforeQuantity,i.actual_discard_packages quantity," + + "i.available_packages_snapshot-i.actual_discard_packages afterQuantity," + + "i.pda_operator_id operatorId,i.pda_completed_at occurredAt" + FACTS + + "AND i.customer_id=#{customerId} AND i.return_carton_id=#{cartonId} ORDER BY i.pda_completed_at DESC,i.id DESC") + List cartonHistory(@Param("customerId") String customerId, @Param("cartonId") long cartonId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java index 8a832d43a..5263f6690 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java @@ -22,7 +22,8 @@ public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus. " UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_OUT',relabel_no,-quantity,-quantity,completed_at FROM ll_return_sku_conversion" + " UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_IN',relabel_no,quantity,quantity,completed_at FROM ll_return_sku_conversion" + " UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_IN',relabel_no,quantity,quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" + - " UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_OUT',relabel_no,-quantity,-quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'"; + " UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_OUT',relabel_no,-quantity,-quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" + + " UNION ALL " + ReturnDiscardMovementMapper.MOVEMENTS; @Select("