diff --git a/src/main/java/com/peanut/modules/book/service/ShopProductService.java b/src/main/java/com/peanut/modules/book/service/ShopProductService.java index ffd9a61..37aedad 100644 --- a/src/main/java/com/peanut/modules/book/service/ShopProductService.java +++ b/src/main/java/com/peanut/modules/book/service/ShopProductService.java @@ -38,6 +38,14 @@ public interface ShopProductService extends IService { PageUtils queryPageactivityprice(Map params); - void rollbackStock(BuyOrder buyOrder); + /** + * 回滚订单行 shop_product 库存与销量。 + * @param skipShopProductIds 已由 inv 出库回滚加过 shop 的商品,跳过库存回加(仍回滚销量) + */ + void rollbackStock(BuyOrder buyOrder, java.util.Set skipShopProductIds); + + default void rollbackStock(BuyOrder buyOrder) { + rollbackStock(buyOrder, null); + } } diff --git a/src/main/java/com/peanut/modules/book/service/impl/BuyOrderServiceImpl.java b/src/main/java/com/peanut/modules/book/service/impl/BuyOrderServiceImpl.java index d3ef609..5e45fc3 100644 --- a/src/main/java/com/peanut/modules/book/service/impl/BuyOrderServiceImpl.java +++ b/src/main/java/com/peanut/modules/book/service/impl/BuyOrderServiceImpl.java @@ -1201,6 +1201,7 @@ public class BuyOrderServiceImpl extends ServiceImpl impl return refundFee; } @Override + @Transactional(rollbackFor = Exception.class) public void refundOrder(BuyOrder buyOrder, MyUserEntity user, int refundId){ if(buyOrder.getCouponId()!=null && buyOrder.getCouponId()!=0){ couponService.rollbackCoupon(buyOrder.getCouponId()); @@ -1229,10 +1230,10 @@ public class BuyOrderServiceImpl extends ServiceImpl impl //撤回课程权限 log.info("====remove========="+buyOrder.getOrderSn()); removeCourseToUser(buyOrder); - //回滚库存 - shopProductService.rollbackStock(buyOrder); - // 回滚 inv 售出流水:回加采购 remain_quantity,售出记录 del_flag=1 - inventoryManagementService.rollbackSaleByOrderId(buyOrder.getOrderId()); + // 先按出库流水回加 inv remain + 出库商品 shop;再回加订单行中未出现在出库流水的商品 shop(如套书本身) + java.util.Set invRestoredShopIds = + inventoryManagementService.rollbackSaleByOrderId(buyOrder.getOrderId()); + shopProductService.rollbackStock(buyOrder, invRestoredShopIds); } } diff --git a/src/main/java/com/peanut/modules/book/service/impl/ShopProductServiceImpl.java b/src/main/java/com/peanut/modules/book/service/impl/ShopProductServiceImpl.java index 24a5b13..8a89f18 100644 --- a/src/main/java/com/peanut/modules/book/service/impl/ShopProductServiceImpl.java +++ b/src/main/java/com/peanut/modules/book/service/impl/ShopProductServiceImpl.java @@ -141,14 +141,23 @@ public class ShopProductServiceImpl extends ServiceImpl skipShopProductIds){ QueryWrapper buyOrderProductQueryWrapper = new QueryWrapper<>(); buyOrderProductQueryWrapper.eq("order_id", buyOrder.getOrderId()); List buyOrderProductList = buyOrderProductService.list(buyOrderProductQueryWrapper); for (BuyOrderProduct buyOrderProduct : buyOrderProductList) { Integer productId = buyOrderProduct.getProductId(); ShopProduct product = this.getById(productId); - product.setProductStock(product.getProductStock() + buyOrderProduct.getQuantity()); + if (product == null) { + continue; + } + // inv 出库回滚已加过 shop 的商品不再重复加库存;销量仍回滚 + if (skipShopProductIds == null || !skipShopProductIds.contains(productId)) { + int stock = product.getProductStock() == null ? 0 : product.getProductStock(); + product.setProductStock(stock + buyOrderProduct.getQuantity()); + } + int sales = product.getSumSales() == null ? 0 : product.getSumSales(); + product.setSumSales(Math.max(0, sales - buyOrderProduct.getQuantity())); this.updateById(product); } } diff --git a/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java b/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java index 2be88bd..d220582 100644 --- a/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java +++ b/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java @@ -46,9 +46,10 @@ public class InventoryManagementController { /** * 采购/售出; - * 采购 source:ls灵枢 zm众秒 other其他 publisher出版社,默认 other;跨商户采购自动两步; + * 采购 source:ls灵枢 zm众秒 other其他 publisher出版社,默认 other;跨商户采购自动两步;套书不支持入库; * 列表返回时 source 按 sys_dict_data(dict_label=inventory_source_type) 的 dict_type→dict_value 转换; - * 售出出库类型:outboundType 或 source,取自 sys_dict_data(dict_label=inventory_outbound_type) 的 dict_type + * 售出出库类型:outboundType 或 source,取自 sys_dict_data(dict_label=inventory_outbound_type) 的 dict_type; + * 套书售出拆成单本出库 */ @RequestMapping("/purchaseProduct") public R purchaseProduct(@RequestBody InvStockRecordDto dto) { diff --git a/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java b/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java index 46bef10..6de4f9a 100644 --- a/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java +++ b/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java @@ -20,19 +20,21 @@ public interface InventoryManagementService { /** * 图书订单售出:写入 inv_stock_record 售出流水。 - * 天医币支付固定扣众秒库存(merchant_id=2);其它支付按商品 payMerchant。 + * 天医币支付固定扣众秒(merchant_id=2);其它支付严格按商品 payMerchant(1→灵枢,0→众秒),不跨商户回退。 + * 套书(shop_product_book 关联多本)拆成单本商品分别出库扣库存。 */ void saleByBookOrder(Integer productId, int quantity, BuyOrder buyOrder, ShopProduct product); /** * inv_stock_record 中是否存在该商品记录。 * 天医币支付只查众秒(merchant_id=2);其它支付查对应商户(无则任一商户有流水也算)。 + * 套书查套内单本商品流水。 */ boolean hasInvStockRecord(Integer productId, BuyOrder buyOrder, ShopProduct product); /** - * 校验图书库存流水:无记录则通过;有记录则剩余库存须不少于购买数量。 - * 天医币支付固定校验众秒库存。 + * 校验图书库存流水:无记录则通过;有记录则目标商户剩余须不少于购买数量。 + * 天医币支付固定校验众秒库存。套书校验套内每本单品库存。 */ boolean validateBookInvStock(Integer productId, int quantity, BuyOrder buyOrder, ShopProduct product); @@ -43,12 +45,14 @@ public interface InventoryManagementService { List> exportStockStatistics(Map params); /** - * 按订单回滚库存流水售出:回加采购批次 remain_quantity,售出记录 del_flag=1 + * 按订单回滚出库:根据 order_id 查出全部售出流水,回加采购 remain, + * 并按出库商品回加对应 shop_product;返回已回加 shop 的商品ID集合,供业务侧避免重复回加。 */ - void rollbackSaleByOrderId(Integer orderId); + java.util.Set rollbackSaleByOrderId(Integer orderId); /** - * Excel 批量售出:全部解析/匹配成功才入库,任一条失败则全部不入库 + * Excel 批量售出:全部解析/匹配成功才入库,任一条失败则全部不入库。 + * 套书行按 App 逻辑拆成单本出库。 * * @param merchantId 商户ID * @param outboundType 出库类型 donation/taobao/app diff --git a/src/main/java/com/peanut/modules/master/service/impl/InventoryManagementServiceImpl.java b/src/main/java/com/peanut/modules/master/service/impl/InventoryManagementServiceImpl.java index 6cdd1ca..3700866 100644 --- a/src/main/java/com/peanut/modules/master/service/impl/InventoryManagementServiceImpl.java +++ b/src/main/java/com/peanut/modules/master/service/impl/InventoryManagementServiceImpl.java @@ -2,6 +2,7 @@ package com.peanut.modules.master.service.impl; import com.alipay.api.domain.Product; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.github.yulichang.wrapper.MPJLambdaWrapper; @@ -228,6 +229,13 @@ public class InventoryManagementServiceImpl extends ServiceImpl splits = deductPurchaseStockFifo(dto.getProductId(), dto.getMerchantId(), dto.getQuantity()); for (CostSplit split : splits) { BigDecimal saleUnitCost = (dto.getUnitCost() != null && dto.getUnitCost().compareTo(BigDecimal.ZERO) > 0) @@ -244,6 +252,11 @@ public class InventoryManagementServiceImpl extends ServiceImpl bookIds = listBookIdsByProductId(setProduct.getProductId()); - if (bookIds.isEmpty()) { - throw new RuntimeException("套书未在shop_product_book中关联图书"); - } - int merchantId = dto.getMerchantId(); - int quantity = dto.getQuantity(); - validateSetBookComponentStock(bookIds, merchantId, quantity, setProduct.getProductId()); - - String saleSource = merchantIdToSource(merchantId); - String outboundRemark = "套书采购出库[" + setProduct.getProductName() + ",productId=" + setProduct.getProductId() + "]"; - Long setPurchaseRecordId = insertRecord(dto.getProductId(), merchantId, normalizeSource(dto.getSource()), - null, InvStockRecord.BIZ_TYPE_PURCHASE, quantity, dto.getUnitCost(), dto.getRemark(), - null, null, null, null, null); - for (Integer bookId : bookIds) { - ShopProduct component = findComponentProductByBookId(bookId, setProduct.getProductId()); - List splits = deductPurchaseStockFifo(component.getProductId(), merchantId, quantity); - for (CostSplit split : splits) { - insertRecord(component.getProductId(), merchantId, saleSource, InvStockRecord.OUTBOUND_BOOKS, - InvStockRecord.BIZ_TYPE_SALE, -split.quantity, split.unitCost, outboundRemark, - null, null, null, split.purchaseRecordId, setPurchaseRecordId); - } - adjustShopProductStock(component.getProductId(), -quantity); - } - adjustShopProductStock(dto.getProductId(), quantity); - } - /** 套书判定:shop_product_book 中关联图书数 > 1 */ private boolean isSetBookProduct(ShopProduct product) { if (product == null || product.getProductId() == null) { @@ -346,7 +323,7 @@ public class InventoryManagementServiceImpl extends ServiceImpl() .eq(ShopProductBookEntity::getProductId, candidateId) @@ -355,9 +332,6 @@ public class InventoryManagementServiceImpl extends ServiceImpl splits = deductPurchaseStockFifo(productId, merchantId, quantity); for (CostSplit split : splits) { @@ -440,11 +420,45 @@ public class InventoryManagementServiceImpl extends ServiceImpl bookIds = listBookIdsByProductId(setProductId); + if (bookIds.isEmpty()) { + throw new RuntimeException("套书未在shop_product_book中关联图书"); + } + validateSetBookComponentStock(bookIds, merchantId, quantity, setProductId); + String outboundRemark = (remark == null || remark.isBlank()) + ? "套书出库[" + setProduct.getProductName() + ",productId=" + setProductId + "]" + : remark; + for (Integer bookId : bookIds) { + ShopProduct component = findComponentProductByBookId(bookId, setProductId); + List splits = deductPurchaseStockFifo(component.getProductId(), merchantId, quantity); + for (CostSplit split : splits) { + BigDecimal saleUnitCost = (overrideUnitCost != null && overrideUnitCost.compareTo(BigDecimal.ZERO) > 0) + ? overrideUnitCost : split.unitCost; + insertSaleRecord(component.getProductId(), merchantId, outboundType, + -split.quantity, saleUnitCost, outboundRemark, buyOrder, createTime, orderSn, + split.purchaseRecordId); + } + adjustShopProductStock(component.getProductId(), -quantity); + } + } + + /** + * 按订单回滚出库:出库记录有哪些商品,就回加哪些商品的 remain 与 shop_product。 + * @return 已回加 shop 的商品ID,供取消/退款跳过订单行重复回加 + */ @Override @Transactional(rollbackFor = Exception.class) - public void rollbackSaleByOrderId(Integer orderId) { + public Set rollbackSaleByOrderId(Integer orderId) { if (orderId == null) { - return; + return Collections.emptySet(); } List saleRecords = invStockRecordDao.selectList( new LambdaQueryWrapper() @@ -453,43 +467,69 @@ public class InventoryManagementServiceImpl extends ServiceImpl shopRestoreMap = new LinkedHashMap<>(); + // 先全部回加 remain,失败则整单回滚,避免只软删出库流水、库存没加回 for (InvStockRecord sale : saleRecords) { int restoreQty = sale.getQuantity() == null ? 0 : Math.abs(sale.getQuantity()); - if (restoreQty > 0) { - if (sale.getPurchaseRecordId() != null) { - restorePurchaseRemainById(sale.getPurchaseRecordId(), restoreQty); - } else { - restorePurchaseRemain(sale.getProductId(), sale.getMerchantId(), sale.getUnitCost(), restoreQty); - } + if (restoreQty <= 0) { + continue; } - // @TableLogic:deleteById 将 del_flag 置为 1 + if (sale.getPurchaseRecordId() != null) { + restorePurchaseRemainById(sale.getPurchaseRecordId(), restoreQty); + } else { + restorePurchaseRemain(sale.getProductId(), sale.getMerchantId(), sale.getUnitCost(), restoreQty); + } + Integer saleProductId = sale.getProductId(); + if (saleProductId != null) { + shopRestoreMap.merge(saleProductId, restoreQty, Integer::sum); + } + } + for (InvStockRecord sale : saleRecords) { invStockRecordDao.deleteById(sale.getId()); } + for (Map.Entry entry : shopRestoreMap.entrySet()) { + adjustShopProductStock(entry.getKey(), entry.getValue()); + } + return shopRestoreMap.keySet(); } - /** 按采购流水ID精确回加 remain_quantity(不超过该批次原始 quantity) */ + /** 按采购流水ID原子回加 remain_quantity;失败抛错,禁止静默跳过 */ private void restorePurchaseRemainById(Long purchaseRecordId, int restoreQty) { if (purchaseRecordId == null || restoreQty <= 0) { + throw new RuntimeException("回滚库存参数无效,purchaseRecordId=" + purchaseRecordId + ", qty=" + restoreQty); + } + // 原子加回,避免 select+updateById 静默不生效 + Integer rows = invStockRecordDao.update(null, + new LambdaUpdateWrapper() + .eq(InvStockRecord::getId, purchaseRecordId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE) + .apply("IFNULL(remain_quantity,0) + {0} <= IFNULL(quantity,0)", restoreQty) + .setSql("remain_quantity = IFNULL(remain_quantity,0) + " + restoreQty)); + if (rows != null && rows > 0) { return; } InvStockRecord purchase = invStockRecordDao.selectById(purchaseRecordId); if (purchase == null || purchase.getBizType() == null - || purchase.getBizType() != InvStockRecord.BIZ_TYPE_PURCHASE) { - return; + || !Integer.valueOf(InvStockRecord.BIZ_TYPE_PURCHASE).equals(purchase.getBizType())) { + throw new RuntimeException("回滚库存失败,采购流水不存在或类型错误,purchaseRecordId=" + purchaseRecordId); } int originQty = purchase.getQuantity() == null ? 0 : purchase.getQuantity(); int remainQty = purchase.getRemainQuantity() == null ? 0 : purchase.getRemainQuantity(); int capacity = Math.max(0, originQty - remainQty); int add = Math.min(capacity, restoreQty); - if (add <= 0) { - return; + if (add > 0) { + Integer updated = invStockRecordDao.update(null, + new LambdaUpdateWrapper() + .eq(InvStockRecord::getId, purchaseRecordId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE) + .setSql("remain_quantity = IFNULL(remain_quantity,0) + " + add)); + if (updated == null || updated <= 0) { + throw new RuntimeException("回滚库存失败,更新采购剩余未生效,purchaseRecordId=" + purchaseRecordId); + } } - purchase.setRemainQuantity(remainQty + add); - invStockRecordDao.updateById(purchase); if (add < restoreQty) { - // 容量不够时按旧逻辑兜底补足(兼容脏数据) restorePurchaseRemain(purchase.getProductId(), purchase.getMerchantId(), purchase.getUnitCost(), restoreQty - add); } @@ -499,9 +539,12 @@ public class InventoryManagementServiceImpl extends ServiceImpl 0 && unitCost != null) { @@ -541,6 +591,9 @@ public class InventoryManagementServiceImpl extends ServiceImpl 0; } + /** 套书:任一单本组件有流水即进入 inv 校验(天医币只认众秒) */ + private boolean hasSetBookComponentInvRecord(Integer setProductId, BuyOrder buyOrder) { + List bookIds = listBookIdsByProductId(setProductId); + if (bookIds.isEmpty()) { + return false; + } + boolean peanut = isPeanutCoinPay(buyOrder); + for (Integer bookId : bookIds) { + ShopProduct component = findComponentProductByBookId(bookId, setProductId); + if (component == null || component.getProductId() == null) { + continue; + } + if (peanut) { + if (hasInvOnMerchant(component.getProductId(), InvStockRecord.MERCHANT_ZM)) { + return true; + } + } else { + Long count = invStockRecordDao.selectCount( + new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, component.getProductId()) + ); + if (count != null && count > 0) { + return true; + } + } + } + return false; + } + @Override public boolean validateBookInvStock(Integer productId, int quantity, BuyOrder buyOrder, ShopProduct product) { if (!hasInvStockRecord(productId, buyOrder, product)) { return true; } - int merchantId = resolveBookOrderMerchantId(productId, buyOrder, product, quantity); + int merchantId = resolveBookOrderMerchantId(buyOrder, product); + if (isSetBookProduct(product)) { + List bookIds = listBookIdsByProductId(productId); + try { + validateSetBookComponentStock(bookIds, merchantId, quantity, productId); + return true; + } catch (RuntimeException ex) { + return false; + } + } int remain = sumRemainStockByMerchant(productId, merchantId); return remain > 0 && remain >= quantity; } @@ -566,35 +657,16 @@ public class InventoryManagementServiceImpl extends ServiceImpl= needQty) { - return preferred; - } - int otherRemain = sumRemainStockByMerchant(productId, other); - if (otherRemain >= needQty) { - return other; - } - if (preferredRemain > 0 || hasInvOnMerchant(productId, preferred)) { - return preferred; - } - if (otherRemain > 0 || hasInvOnMerchant(productId, other)) { - return other; - } - return preferred; + return payMerchant == 1 ? InvStockRecord.MERCHANT_LS : InvStockRecord.MERCHANT_ZM; } private boolean hasInvOnMerchant(Integer productId, int merchantId) { @@ -874,11 +946,39 @@ public class InventoryManagementServiceImpl extends ServiceImpl needQtyMap = new LinkedHashMap<>(); Map productNameMap = new HashMap<>(); for (BatchSaleRow row : rows) { - needQtyMap.merge(row.productId, row.quantity, Integer::sum); - if (row.matchedName != null && !row.matchedName.isBlank()) { - productNameMap.putIfAbsent(row.productId, row.matchedName); + ShopProduct matched = shopProductDao.selectById(row.productId); + if (isSetBookProduct(matched)) { + List bookIds = listBookIdsByProductId(row.productId); + if (bookIds.isEmpty()) { + errors.add("第" + row.excelRowNum + "行:套书未在shop_product_book中关联图书,productId=" + row.productId); + continue; + } + for (Integer bookId : bookIds) { + ShopProduct component = findComponentProductByBookId(bookId, row.productId); + if (component == null || component.getProductId() == null) { + String bookName = resolveBookName(bookId); + String bookPart = bookName.isBlank() + ? "书籍ID[" + bookId + "]" + : "《" + bookName + "》(书籍ID:" + bookId + ")"; + errors.add("第" + row.excelRowNum + "行:套书拆分失败," + bookPart + "无只绑1本的单品商品"); + continue; + } + needQtyMap.merge(component.getProductId(), row.quantity, Integer::sum); + String cName = component.getProductName(); + if (cName != null && !cName.isBlank()) { + productNameMap.putIfAbsent(component.getProductId(), cName); + } + } + } else { + needQtyMap.merge(row.productId, row.quantity, Integer::sum); + if (row.matchedName != null && !row.matchedName.isBlank()) { + productNameMap.putIfAbsent(row.productId, row.matchedName); + } } } + if (!errors.isEmpty()) { + throw new RuntimeException("全部未入库:" + String.join(";", errors)); + } for (Map.Entry entry : needQtyMap.entrySet()) { int remain = sumRemainStockByMerchant(entry.getKey(), merchantId); if (remain < entry.getValue()) { @@ -892,8 +992,14 @@ public class InventoryManagementServiceImpl extends ServiceImpl splits = deductPurchaseStockFifo(row.productId, merchantId, row.quantity); + ShopProduct matched = shopProductDao.selectById(row.productId); String remark = "批量导入售出 orderSn=" + row.orderSn; + if (isSetBookProduct(matched)) { + saleSetBook(merchantId, row.quantity, matched, normalizedOutbound, + null, remark, null, row.createTime, row.orderSn); + continue; + } + List splits = deductPurchaseStockFifo(row.productId, merchantId, row.quantity); for (CostSplit split : splits) { insertSaleRecord(row.productId, merchantId, normalizedOutbound, -split.quantity, split.unitCost, remark, null, row.createTime, row.orderSn, split.purchaseRecordId); @@ -1254,7 +1360,7 @@ public class InventoryManagementServiceImpl extends ServiceImpl invRestoredShopIds = + inventoryManagementService.rollbackSaleByOrderId(buyOrder.getOrderId()); + + // 回滚订单行 shop:出库流水已回加过的商品只回销量,不再加库存(单本避免双加;套书订单行仍会加回套书库存) LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(BuyOrderProduct::getOrderId, buyOrder.getOrderId()); List buyOrderProducts = buyOrderProductDao.selectList(wrapper); for (BuyOrderProduct b : buyOrderProducts) { ShopProduct shopProduct = shopProductDao.selectById(b.getProductId()); if (shopProduct != null) { - int stock = shopProduct.getProductStock() == null ? 0 : shopProduct.getProductStock(); int sales = shopProduct.getSumSales() == null ? 0 : shopProduct.getSumSales(); int qty = b.getQuantity(); - shopProduct.setProductStock(stock + qty); + if (invRestoredShopIds == null || !invRestoredShopIds.contains(b.getProductId())) { + int stock = shopProduct.getProductStock() == null ? 0 : shopProduct.getProductStock(); + shopProduct.setProductStock(stock + qty); + } shopProduct.setSumSales(Math.max(0, sales - qty)); shopProductDao.updateById(shopProduct); } } - // 回滚 inv 售出流水:回加采购 remain_quantity,售出记录 del_flag=1 - inventoryManagementService.rollbackSaleByOrderId(buyOrder.getOrderId()); buyOrderService.updateById(buyOrder); //buyOrderService.removeById(buyOrder);